Author: Manos Pitsidianakis [manos@pitsidianak.is]
Hash: f8cc3852bb2d97ce5261f2f44d54a40d6bcd79ab
Timestamp: Tue, 09 May 2023 10:38:41 +0000 (1 year ago)

+279 -129 +/-23 browse
web: add urlize() and heading() template filters
1diff --git a/Cargo.lock b/Cargo.lock
2index 0d1c5c2..422f381 100644
3--- a/Cargo.lock
4+++ b/Cargo.lock
5 @@ -1844,6 +1844,7 @@ dependencies = [
6 "build-info",
7 "build-info-build",
8 "chrono",
9+ "convert_case",
10 "dyn-clone",
11 "eyre",
12 "http",
13 diff --git a/core/src/connection.rs b/core/src/connection.rs
14index ff9f2b5..875d451 100644
15--- a/core/src/connection.rs
16+++ b/core/src/connection.rs
17 @@ -494,7 +494,7 @@ impl Connection {
18 ) -> Result<Vec<DbVal<Post>>> {
19 let mut stmt = self.connection.prepare(
20 "SELECT *, strftime('%Y-%m', CAST(timestamp AS INTEGER), 'unixepoch') AS month_year \
21- FROM post WHERE list = ?;",
22+ FROM post WHERE list = ? ORDER BY timestamp ASC;",
23 )?;
24 let iter = stmt.query_map(rusqlite::params![&list_pk], |row| {
25 let pk = row.get("pk")?;
26 diff --git a/core/src/lib.rs b/core/src/lib.rs
27index 199fccb..0d25046 100644
28--- a/core/src/lib.rs
29+++ b/core/src/lib.rs
30 @@ -152,17 +152,25 @@
31
32 #[macro_use]
33 extern crate error_chain;
34+
35+ /// Error library
36 pub extern crate anyhow;
37+ /// Date library
38 pub extern crate chrono;
39+ /// Sql library
40 pub extern crate rusqlite;
41
42 /// Alias for [`chrono::DateTime<chrono::Utc>`].
43 pub type DateTime = chrono::DateTime<chrono::Utc>;
44
45+ /// Serde
46 #[macro_use]
47 pub extern crate serde;
48+ /// Log
49 pub extern crate log;
50+ /// melib
51 pub extern crate melib;
52+ /// serde_json
53 pub extern crate serde_json;
54
55 mod config;
56 diff --git a/web/Cargo.toml b/web/Cargo.toml
57index e991741..57c48e5 100644
58--- a/web/Cargo.toml
59+++ b/web/Cargo.toml
60 @@ -25,6 +25,7 @@ axum-login = { version = "^0.5" }
61 axum-sessions = { version = "^0.5" }
62 build-info = { version = "0.0.31" }
63 chrono = { version = "^0.4" }
64+ convert_case = { version = "^0.4" }
65 dyn-clone = { version = "^1" }
66 eyre = { version = "0.6" }
67 http = "0.2"
68 diff --git a/web/src/auth.rs b/web/src/auth.rs
69index aaefb0a..84d3ef6 100644
70--- a/web/src/auth.rs
71+++ b/web/src/auth.rs
72 @@ -128,7 +128,6 @@ pub async fn ssh_signin(
73 );
74 let timeout_left = ((timestamp + EXPIRY_IN_SECS) - now) as f64 / 60.0;
75
76- let root_url_prefix = &state.root_url_prefix;
77 let crumbs = vec![
78 Crumb {
79 label: "Home".into(),
80 @@ -142,10 +141,7 @@ pub async fn ssh_signin(
81
82 let context = minijinja::context! {
83 namespace => &state.public_url,
84- site_title => state.site_title.as_ref(),
85- site_subtitle => state.site_subtitle.as_ref(),
86 page_title => "Log in",
87- root_url_prefix => &root_url_prefix,
88 ssh_challenge => token,
89 timeout_left => timeout_left,
90 current_user => auth.current_user,
91 diff --git a/web/src/help.rs b/web/src/help.rs
92index 924a785..9a3c9c4 100644
93--- a/web/src/help.rs
94+++ b/web/src/help.rs
95 @@ -24,7 +24,6 @@ pub async fn help(
96 _: HelpPath,
97 mut session: WritableSession,
98 auth: AuthContext,
99- State(state): State<Arc<AppState>>,
100 ) -> Result<Html<String>, ResponseError> {
101 let crumbs = vec![
102 Crumb {
103 @@ -37,10 +36,7 @@ pub async fn help(
104 },
105 ];
106 let context = minijinja::context! {
107- site_title => state.site_title.as_ref(),
108- site_subtitle => state.site_subtitle.as_ref(),
109 page_title => "Help & Documentation",
110- root_url_prefix => &state.root_url_prefix,
111 current_user => auth.current_user,
112 messages => session.drain_messages(),
113 crumbs => crumbs,
114 diff --git a/web/src/lists.rs b/web/src/lists.rs
115index a01d7f6..00ba71d 100644
116--- a/web/src/lists.rs
117+++ b/web/src/lists.rs
118 @@ -119,23 +119,20 @@ pub async fn list(
119 },
120 ];
121 let context = minijinja::context! {
122- site_title => state.site_title.as_ref(),
123- site_subtitle => state.site_subtitle.as_ref(),
124 canonical_url => ListPath::from(&list).to_crumb(),
125 page_title => &list.name,
126 description => &list.description,
127- post_policy => &post_policy,
128- subscription_policy => &subscription_policy,
129+ post_policy,
130+ subscription_policy,
131 preamble => true,
132- months => &months,
133+ months,
134 hists => &hist,
135 posts => posts_ctx,
136- root_url_prefix => &state.root_url_prefix,
137 list => Value::from_object(MailingList::from(list)),
138 current_user => auth.current_user,
139- user_context => user_context,
140+ user_context,
141 messages => session.drain_messages(),
142- crumbs => crumbs,
143+ crumbs,
144 };
145 Ok(Html(
146 TEMPLATES.get_template("lists/list.html")?.render(context)?,
147 @@ -200,8 +197,6 @@ pub async fn list_post(
148 },
149 ];
150 let context = minijinja::context! {
151- site_title => state.site_title.as_ref(),
152- site_subtitle => state.site_subtitle.as_ref(),
153 canonical_url => ListPostPath(ListPathIdentifier::from(list.id.clone()), msg_id.to_string()).to_crumb(),
154 page_title => subject_ref,
155 description => &list.description,
156 @@ -220,7 +215,6 @@ pub async fn list_post(
157 timestamp => post.timestamp,
158 datetime => post.datetime,
159 thread => thread,
160- root_url_prefix => &state.root_url_prefix,
161 current_user => auth.current_user,
162 user_context => user_context,
163 messages => session.drain_messages(),
164 @@ -302,27 +296,24 @@ pub async fn list_edit(
165 url: ListPath(list.id.to_string().into()).to_crumb(),
166 },
167 Crumb {
168- label: list.name.clone().into(),
169+ label: format!("Edit {}", list.name).into(),
170 url: ListEditPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
171 },
172 ];
173 let context = minijinja::context! {
174- site_title => state.site_title.as_ref(),
175- site_subtitle => state.site_subtitle.as_ref(),
176 canonical_url => ListEditPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
177 page_title => format!("Edit {} settings", list.name),
178 description => &list.description,
179- post_policy => &post_policy,
180- subscription_policy => &subscription_policy,
181- list_owners => list_owners,
182- post_count => post_count,
183- subs_count => subs_count,
184- sub_requests_count => sub_requests_count,
185- root_url_prefix => &state.root_url_prefix,
186+ post_policy,
187+ subscription_policy,
188+ list_owners,
189+ post_count,
190+ subs_count,
191+ sub_requests_count,
192 list => Value::from_object(MailingList::from(list)),
193 current_user => auth.current_user,
194 messages => session.drain_messages(),
195- crumbs => crumbs,
196+ crumbs,
197 };
198 Ok(Html(
199 TEMPLATES.get_template("lists/edit.html")?.render(context)?,
200 @@ -661,7 +652,7 @@ pub async fn list_subscribers(
201 url: ListPath(list.id.to_string().into()).to_crumb(),
202 },
203 Crumb {
204- label: list.name.clone().into(),
205+ label: format!("Edit {}", list.name).into(),
206 url: ListEditPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
207 },
208 Crumb {
209 @@ -670,12 +661,9 @@ pub async fn list_subscribers(
210 },
211 ];
212 let context = minijinja::context! {
213- site_title => state.site_title.as_ref(),
214- site_subtitle => state.site_subtitle.as_ref(),
215 canonical_url => ListEditSubscribersPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
216 page_title => format!("Subscribers of {}", list.name),
217 subs,
218- root_url_prefix => &state.root_url_prefix,
219 list => Value::from_object(MailingList::from(list)),
220 current_user => auth.current_user,
221 messages => session.drain_messages(),
222 @@ -747,7 +735,7 @@ pub async fn list_candidates(
223 url: ListPath(list.id.to_string().into()).to_crumb(),
224 },
225 Crumb {
226- label: list.name.clone().into(),
227+ label: format!("Edit {}", list.name).into(),
228 url: ListEditPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
229 },
230 Crumb {
231 @@ -756,12 +744,9 @@ pub async fn list_candidates(
232 },
233 ];
234 let context = minijinja::context! {
235- site_title => state.site_title.as_ref(),
236- site_subtitle => state.site_subtitle.as_ref(),
237 canonical_url => ListEditCandidatesPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
238 page_title => format!("Requests of {}", list.name),
239 subs,
240- root_url_prefix => &state.root_url_prefix,
241 list => Value::from_object(MailingList::from(list)),
242 current_user => auth.current_user,
243 messages => session.drain_messages(),
244 diff --git a/web/src/main.rs b/web/src/main.rs
245index 4043ecf..b0c3223 100644
246--- a/web/src/main.rs
247+++ b/web/src/main.rs
248 @@ -19,6 +19,7 @@
249
250 use std::{collections::HashMap, sync::Arc};
251
252+ use chrono::TimeZone;
253 use mailpot::{Configuration, Connection};
254 use mailpot_web::*;
255 use minijinja::value::Value;
256 @@ -175,12 +176,18 @@ async fn root(
257 .map(|list| {
258 let months = db.months(list.pk)?;
259 let posts = db.list_posts(list.pk, None)?;
260+ let newest = posts.last().and_then(|p| {
261+ chrono::Utc
262+ .timestamp_opt(p.timestamp as i64, 0)
263+ .earliest()
264+ .map(|d| d.to_string())
265+ });
266 Ok(minijinja::context! {
267 name => &list.name,
268+ newest,
269 posts => &posts,
270 months => &months,
271 description => &list.description.as_deref().unwrap_or_default(),
272- root_url_prefix => &state.root_url_prefix,
273 list => Value::from_object(MailingList::from(list.clone())),
274 })
275 })
276 @@ -191,11 +198,8 @@ async fn root(
277 }];
278
279 let context = minijinja::context! {
280- site_title => state.site_title.as_ref(),
281- site_subtitle => state.site_subtitle.as_ref(),
282 page_title => Option::<&'static str>::None,
283 lists => &lists,
284- root_url_prefix => &state.root_url_prefix,
285 current_user => auth.current_user,
286 messages => session.drain_messages(),
287 crumbs => crumbs,
288 diff --git a/web/src/minijinja_utils.rs b/web/src/minijinja_utils.rs
289index a0fcc11..338a627 100644
290--- a/web/src/minijinja_utils.rs
291+++ b/web/src/minijinja_utils.rs
292 @@ -38,6 +38,8 @@ lazy_static::lazy_static! {
293 }
294 add!(function calendarize,
295 strip_carets,
296+ urlize,
297+ heading,
298 login_path,
299 logout_path,
300 settings_path,
301 @@ -74,6 +76,10 @@ lazy_static::lazy_static! {
302 }
303 env.set_source(source);
304 }
305+ env.add_global("root_url_prefix", Value::from_safe_string( std::env::var("ROOT_URL_PREFIX").unwrap_or_default()));
306+ env.add_global("public_url",Value::from_safe_string(std::env::var("PUBLIC_URL").unwrap_or_default()));
307+ env.add_global("site_title", Value::from_safe_string(std::env::var("SITE_TITLE").unwrap_or_else(|_| "mailing list archive".to_string())));
308+ env.add_global("site_subtitle", std::env::var("SITE_SUBTITLE").ok().map(Value::from_safe_string).unwrap_or_default());
309
310 env
311 };
312 @@ -232,7 +238,7 @@ pub fn calendarize(
313 year => date.year(),
314 weeks => cal::calendarize_with_offset(date, 1),
315 hist => hist,
316- sum => sum,
317+ sum,
318 })
319 }
320
321 @@ -387,6 +393,29 @@ pub fn pluralize(
322 })
323 }
324
325+ /// `strip_carets` filter for [`minijinja`].
326+ ///
327+ /// Removes `[<>]` from message ids.
328+ ///
329+ /// # Examples
330+ ///
331+ /// ```rust
332+ /// # use mailpot_web::strip_carets;
333+ /// # use minijinja::Environment;
334+ ///
335+ /// let mut env = Environment::new();
336+ /// env.add_filter("strip_carets", strip_carets);
337+ /// assert_eq!(
338+ /// &env.render_str(
339+ /// "{{ msg_id | strip_carets }}",
340+ /// minijinja::context! {
341+ /// msg_id => "<hello1@example.com>",
342+ /// }
343+ /// )
344+ /// .unwrap(),
345+ /// "hello1@example.com",
346+ /// );
347+ /// ```
348 pub fn strip_carets(_state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
349 Ok(Value::from(
350 arg.as_str()
351 @@ -399,3 +428,136 @@ pub fn strip_carets(_state: &minijinja::State, arg: Value) -> std::result::Resul
352 .strip_carets(),
353 ))
354 }
355+
356+ /// `urlize` filter for [`minijinja`].
357+ ///
358+ /// Returns a safe string for use in <a> href= attributes.
359+ ///
360+ /// # Examples
361+ ///
362+ /// ```rust
363+ /// # use mailpot_web::urlize;
364+ /// # use minijinja::Environment;
365+ /// # use minijinja::value::Value;
366+ ///
367+ /// let mut env = Environment::new();
368+ /// env.add_function("urlize", urlize);
369+ /// env.add_global(
370+ /// "root_url_prefix",
371+ /// Value::from_safe_string("/lists/prefix/".to_string()),
372+ /// );
373+ /// assert_eq!(
374+ /// &env.render_str(
375+ /// "<a href=\"{{ urlize(\"path/index.html\") }}\">link</a>",
376+ /// minijinja::context! {}
377+ /// )
378+ /// .unwrap(),
379+ /// "<a href=\"/lists/prefix/path/index.html\">link</a>",
380+ /// );
381+ /// ```
382+ pub fn urlize(state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
383+ let Some(prefix) = state.lookup("root_url_prefix") else {
384+ return Ok(arg);
385+ };
386+ Ok(Value::from_safe_string(format!("{prefix}{arg}")))
387+ }
388+
389+ /// Make an html heading: `h1, h2, h3` etc.
390+ ///
391+ /// # Example
392+ /// ```
393+ /// use mailpot_web::minijinja_utils::heading;
394+ /// use minijinja::value::Value;
395+ ///
396+ /// assert_eq!(
397+ /// "<h1 id=\"bl-bfa-b-ah-b-asdb-hadas-d\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" href=\"#bl-bfa-b-ah-b-asdb-hadas-d\"></a></h1>",
398+ /// &heading(1.into(), "bl bfa B AH bAsdb hadas d".into(), None).unwrap().to_string()
399+ /// );
400+ /// assert_eq!(
401+ /// "<h2 id=\"short\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" href=\"#short\"></a></h2>",
402+ /// &heading(2.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap().to_string()
403+ /// );
404+ /// assert_eq!(
405+ /// r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
406+ /// &heading(0.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
407+ /// );
408+ /// assert_eq!(
409+ /// r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
410+ /// &heading(8.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
411+ /// );
412+ /// assert_eq!(
413+ /// r#"invalid operation: first heading() argument is not an integer < 7 but of type sequence"#,
414+ /// &heading(Value::from(vec![Value::from(1)]), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
415+ /// );
416+ /// ```
417+ pub fn heading(level: Value, text: Value, id: Option<Value>) -> std::result::Result<Value, Error> {
418+ use convert_case::{Case, Casing};
419+ macro_rules! test {
420+ () => {
421+ |n| *n > 0 && *n < 7
422+ };
423+ }
424+
425+ macro_rules! int_try_from {
426+ ($ty:ty) => {
427+ <$ty>::try_from(level.clone()).ok().filter(test!{}).map(|n| n as u8)
428+ };
429+ ($fty:ty, $($ty:ty),*) => {
430+ int_try_from!($fty).or_else(|| int_try_from!($($ty),*))
431+ }
432+ }
433+ let level: u8 = level
434+ .as_str()
435+ .and_then(|s| s.parse::<i128>().ok())
436+ .filter(test! {})
437+ .map(|n| n as u8)
438+ .or_else(|| int_try_from!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, usize))
439+ .ok_or_else(|| {
440+ if matches!(level.kind(), minijinja::value::ValueKind::Number) {
441+ minijinja::Error::new(
442+ minijinja::ErrorKind::InvalidOperation,
443+ "first heading() argument must be an unsigned integer less than 7 and positive",
444+ )
445+ } else {
446+ minijinja::Error::new(
447+ minijinja::ErrorKind::InvalidOperation,
448+ format!(
449+ "first heading() argument is not an integer < 7 but of type {}",
450+ level.kind()
451+ ),
452+ )
453+ }
454+ })?;
455+ let text = text.as_str().ok_or_else(|| {
456+ minijinja::Error::new(
457+ minijinja::ErrorKind::InvalidOperation,
458+ format!(
459+ "second heading() argument is not a string but of type {}",
460+ text.kind()
461+ ),
462+ )
463+ })?;
464+ if let Some(v) = id {
465+ let kebab = v.as_str().ok_or_else(|| {
466+ minijinja::Error::new(
467+ minijinja::ErrorKind::InvalidOperation,
468+ format!(
469+ "third heading() argument is not a string but of type {}",
470+ v.kind()
471+ ),
472+ )
473+ })?;
474+ Ok(Value::from_safe_string(format!(
475+ "<h{level} id=\"{kebab}\">{text}<a class=\"self-link\" \
476+ href=\"#{kebab}\"></a></h{level}>"
477+ )))
478+ } else {
479+ let kebab_v = text.to_case(Case::Kebab);
480+ let kebab =
481+ percent_encoding::utf8_percent_encode(&kebab_v, crate::typed_paths::PATH_SEGMENT);
482+ Ok(Value::from_safe_string(format!(
483+ "<h{level} id=\"{kebab}\">{text}<a class=\"self-link\" \
484+ href=\"#{kebab}\"></a></h{level}>"
485+ )))
486+ }
487+ }
488 diff --git a/web/src/settings.rs b/web/src/settings.rs
489index 5dff893..aa2f693 100644
490--- a/web/src/settings.rs
491+++ b/web/src/settings.rs
492 @@ -30,7 +30,6 @@ pub async fn settings(
493 Extension(user): Extension<User>,
494 state: Arc<AppState>,
495 ) -> Result<Html<String>, ResponseError> {
496- let root_url_prefix = &state.root_url_prefix;
497 let crumbs = vec![
498 Crumb {
499 label: "Home".into(),
500 @@ -66,10 +65,7 @@ pub async fn settings(
501 >>()?;
502
503 let context = minijinja::context! {
504- site_title => state.site_title.as_ref(),
505- site_subtitle => state.site_subtitle.as_ref(),
506 page_title => "Account settings",
507- root_url_prefix => &root_url_prefix,
508 user => user,
509 subscriptions => subscriptions,
510 current_user => user,
511 @@ -252,7 +248,6 @@ pub async fn user_list_subscription(
512 Extension(user): Extension<User>,
513 State(state): State<Arc<AppState>>,
514 ) -> Result<Html<String>, ResponseError> {
515- let root_url_prefix = &state.root_url_prefix;
516 let db = Connection::open_db(state.conf.clone())?;
517 let Some(list) = (match id {
518 ListPathIdentifier::Pk(id) => db.list(id)?,
519 @@ -307,10 +302,7 @@ pub async fn user_list_subscription(
520 ];
521
522 let context = minijinja::context! {
523- site_title => state.site_title.as_ref(),
524- site_subtitle => state.site_subtitle.as_ref(),
525 page_title => "Subscription settings",
526- root_url_prefix => &root_url_prefix,
527 user => user,
528 list => list,
529 subscription => subscription,
530 diff --git a/web/src/templates/calendar.html b/web/src/templates/calendar.html
531index ec2ce72..8eccf8f 100644
532--- a/web/src/templates/calendar.html
533+++ b/web/src/templates/calendar.html
534 @@ -1,4 +1,4 @@
535- {% macro cal(date, hists, root_url_prefix, pk) %}
536+ {% macro cal(date, hists) %}
537 {% set c=calendarize(date, hists) %}
538 {% if c.sum > 0 %}
539 <table>
540 diff --git a/web/src/templates/footer.html b/web/src/templates/footer.html
541index abc5202..15b74a9 100644
542--- a/web/src/templates/footer.html
543+++ b/web/src/templates/footer.html
544 @@ -1,5 +1,5 @@
545 <footer>
546- <p>Generated by <a href="https://github.com/meli/mailpot">mailpot</a>.</p>
547+ <p>Generated by <a href="https://github.com/meli/mailpot" target="_blank">mailpot</a>.</p>
548 </footer>
549 </main>
550 </body>
551 diff --git a/web/src/templates/header.html b/web/src/templates/header.html
552index 6c293cc..6d2ab7d 100644
553--- a/web/src/templates/header.html
554+++ b/web/src/templates/header.html
555 @@ -4,7 +4,7 @@
556 <meta charset="utf-8">
557 <meta name="viewport" content="width=device-width, initial-scale=1">
558 <title>{{ title if title else page_title if page_title else site_title }}</title>{% if canonical_url %}
559- <link href="{{ root_url_prefix }}{{ canonical_url | safe }}" rel="canonical" />{% endif %}
560+ <link href="{{ urlize(canonical_url) }}" rel="canonical" />{% endif %}
561 {% include "css.html" %}
562 </head>
563 <body>
564 @@ -17,7 +17,7 @@
565 {% include "menu.html" %}
566 <div class="page-header">
567 {% if crumbs|length > 1 %}<nav aria-labelledby="breadcrumb-menu" class="breadcrumbs">
568- <ol id="breadcrumb-menu" role="menu" aria-label="Breadcrumb menu">{% for crumb in crumbs %}<li class="crumb" aria-describedby="bread_{{ loop.index }}">{% if loop.last %}<span role="menuitem" id="bread_{{ loop.index }}" aria-current="page" title="current page">{{ crumb.label }}</span>{% else %}<a role="menuitem" id="bread_{{ loop.index }}" href="{{ root_url_prefix }}{{ crumb.url }}" tabindex="0">{{ crumb.label }}</a>{% endif %}</li>{% endfor %}</ol>
569+ <ol id="breadcrumb-menu" role="menu" aria-label="Breadcrumb menu">{% for crumb in crumbs %}<li class="crumb" aria-describedby="bread_{{ loop.index }}">{% if loop.last %}<span role="menuitem" id="bread_{{ loop.index }}" aria-current="page" title="current page">{{ crumb.label }}</span>{% else %}<a role="menuitem" id="bread_{{ loop.index }}" href="{{ urlize(crumb.url) }}" tabindex="0">{{ crumb.label }}</a>{% endif %}</li>{% endfor %}</ol>
570 </nav>{% endif %}
571 {% if page_title %}
572 <h2 class="page-title">{{ page_title }}</h2>
573 diff --git a/web/src/templates/help.html b/web/src/templates/help.html
574index 8ceed68..3c846ae 100644
575--- a/web/src/templates/help.html
576+++ b/web/src/templates/help.html
577 @@ -1,18 +1,18 @@
578 {% include "header.html" %}
579 <div class="body body-grid">
580- <h3 id="subscribe">Subscribing to a list<a class="self-link" href="#subscribe"></a></h3>
581+ {{ heading(3, "Subscribing to a list") }}
582
583 <p>A mailing list can have different subscription policies, or none at all (which would disable subscriptions). If subscriptions are open or require manual approval by the list owners, you can send an e-mail request to its <code>+request</code> sub-address with the subject <code>subscribe</code>.</p>
584
585- <h3 id="unsubscribe">Unsubscribing from a list<a class="self-link" href="#unsubscribe"></a></h3>
586+ {{ heading(3, "Unsubscribing from a list") }}
587
588 <p>Similarly to subscribing, send an e-mail request to the list's <code>+request</code> sub-address with the subject <code>unsubscribe</code>.</p>
589
590- <h3 id="do-i-need-an-account">Do I need an account?<a class="self-link" href="#do-i-need-an-account"></a></h3>
591+ {{ heading(3, "Do I need an account?") }}
592
593 <p>An account's utility is only to manage your subscriptions and preferences from the web interface. Thus you don't need one if you want to perform all list operations from your e-mail client instead.</p>
594
595- <h3 id="create-account">Creating an account<a class="self-link" href="#create-account"></a></h3>
596+ {{ heading(3, "Creating an account") }}
597
598 <p>After successfully subscribing to a list, simply send an e-mail request to its <code>+request</code> sub-address with the subject <code>password</code> and an SSH public key in the e-mail body as plain text.</p>
599 <p>This will either create you an account with this key, or change your existing key if you already have one.</p>
600 diff --git a/web/src/templates/index.html b/web/src/templates/index.html
601index 58ca4b2..48fa708 100644
602--- a/web/src/templates/index.html
603+++ b/web/src/templates/index.html
604 @@ -3,7 +3,7 @@
605 <div class="body">
606 <ul>
607 {% for l in lists %}
608- <li><a href="{{ root_url_prefix }}{{ list_path(l.list.pk) }}">{{ l.list.name }}</a></li>
609+ <li><a href="{{ list_path(l.list.id) }}">{{ l.list.name }}</a></li>
610 {% endfor %}
611 </ul>
612 </div>
613 diff --git a/web/src/templates/lists.html b/web/src/templates/lists.html
614index eb47baa..b9582bc 100644
615--- a/web/src/templates/lists.html
616+++ b/web/src/templates/lists.html
617 @@ -4,8 +4,8 @@
618 <div class="entry">
619 <dl class="lists" aria-label="list of mailing lists">
620 {% for l in lists %}
621- <dt aria-label="mailing list name"><a href="{{ root_url_prefix }}{{ list_path(l.list.id) }}">{{ l.list.name }}</a></dt>
622- <dd aria-label="mailing list description"{% if not l.list.description %} class="no-description"{% endif %}>{{ l.list.description if l.list.description else "no description" }}</dd>
623+ <dt aria-label="mailing list name"><a href="{{ list_path(l.list.id) }}">{{ l.list.name }}</a></dt>
624+ <dd><span aria-label="mailing list description"{% if not l.list.description %} class="no-description"{% endif %}>{{ l.list.description if l.list.description else "no description" }}</span> | {{ l.posts|length }} post{{ l.posts|length|pluralize("","s") }}{% if l.newest %} | <time datetime="{{ l.newest }}">{{ l.newest }}</time>{% endif %}</dd>
625 {% endfor %}
626 </dl>
627 </div>
628 diff --git a/web/src/templates/lists/edit.html b/web/src/templates/lists/edit.html
629index f8f28a3..396bee5 100644
630--- a/web/src/templates/lists/edit.html
631+++ b/web/src/templates/lists/edit.html
632 @@ -1,6 +1,6 @@
633 {% include "header.html" %}
634 <div class="body body-grid">
635- <h3>Edit <a href="{{ root_url_prefix }}{{ list_path(list.pk) }}">{{ list.id }}</a>.</h3>
636+ {{ heading(3, "Edit <a href=\"" ~list_path(list.id) ~ "\">"~ list.id ~"</a>","edit") }}
637 <address>
638 {{ list.name }} <a href="mailto:{{ list.address | safe }}"><code>{{ list.address }}</code></a>
639 </address>
640 @@ -10,8 +10,8 @@
641 {% if list.archive_url %}
642 <p><a href="{{ list.archive_url }}">{{ list.archive_url }}</a></p>
643 {% endif %}
644- <p><a href="{{ root_url_prefix }}{{ list_subscribers_path(list.pk) }}">{{ subs_count }} subscription{{ subs_count|pluralize }}.</a></p>
645- <p><a href="{{ root_url_prefix }}{{ list_candidates_path(list.pk) }}">{{ sub_requests_count }} subscription request{{ sub_requests_count|pluralize }}.</a></p>
646+ <p><a href="{{ list_subscribers_path(list.id) }}">{{ subs_count }} subscription{{ subs_count|pluralize }}.</a></p>
647+ <p><a href="{{ list_candidates_path(list.id) }}">{{ sub_requests_count }} subscription request{{ sub_requests_count|pluralize }}.</a></p>
648 <p>{{ post_count }} post{{ post_count|pluralize }}.</p>
649 <form method="post" class="settings-form">
650 <fieldset>
651 @@ -80,7 +80,7 @@
652
653 <input type="submit" name="metadata" value="Update list">
654 </form>
655- <form method="post" action="{{ root_url_prefix }}{{ list_edit_path(list.id) }}" class="settings-form">
656+ <form method="post" action="{{ list_edit_path(list.id) }}" class="settings-form">
657 <fieldset>
658 <input type="hidden" name="type" value="post-policy">
659 <legend>Post Policy <input type="submit" name="delete-post-policy" value="Delete" disabled></legend>
660 @@ -114,7 +114,7 @@
661 </fieldset>
662 <input type="submit" value="{{ "Update" if post_policy else "Create" }} Post Policy">
663 </form>
664- <form method="post" action="{{ root_url_prefix }}{{ list_edit_path(list.id) }}" class="settings-form">
665+ <form method="post" action="{{ list_edit_path(list.id) }}" class="settings-form">
666 <fieldset>
667 <input type="hidden" name="type" value="subscription-policy">
668 <legend>Subscription Policy <input type="submit" name="delete-post-policy" value="Delete" disabled></legend>
669 diff --git a/web/src/templates/lists/entry.html b/web/src/templates/lists/entry.html
670index acaef71..496b006 100644
671--- a/web/src/templates/lists/entry.html
672+++ b/web/src/templates/lists/entry.html
673 @@ -29,22 +29,22 @@
674 {% if in_reply_to %}
675 <tr>
676 <th scope="row">In-Reply-To:</th>
677- <td class="faded message-id"><a href="{{ root_url_prefix }}{{ list_post_path(list.id, in_reply_to) }}">{{ in_reply_to }}</a></td>
678+ <td class="faded message-id"><a href="{{ list_post_path(list.id, in_reply_to) }}">{{ in_reply_to }}</a></td>
679 </tr>
680 {% endif %}
681 {% if references %}
682 <tr>
683 <th scope="row">References:</th>
684- <td>{% for r in references %}<span class="faded message-id"><a href="{{ root_url_prefix }}{{ list_post_path(list.id, r) }}">{{ r }}</a></span>{% endfor %}</td>
685+ <td>{% for r in references %}<span class="faded message-id"><a href="{{ list_post_path(list.id, r) }}">{{ r }}</a></span>{% endfor %}</td>
686 </tr>
687 {% endif %}
688 {% else %}
689 <th scope="row">Message-ID:</th>
690- <td class="faded message-id"><a href="{{ root_url_prefix }}{{ list_post_path(list.id, post.message_id) }}">{{ strip_carets(post.message_id) }}</a></td>
691+ <td class="faded message-id"><a href="{{ list_post_path(list.id, post.message_id) }}">{{ strip_carets(post.message_id) }}</a></td>
692 </tr>
693 {% endif %}
694 <tr>
695- <td colspan="2"><details class="reply-details"><summary>more …</summary><a href="{{ root_url_prefix }}{{ post_raw_path(list.id, post.message_id) }}">View raw</a> <a href="{{ root_url_prefix }}{{ post_eml_path(list.id, post.message_id) }}">Download as <code>eml</code> (RFC 5322 format)</a></details></td>
696+ <td colspan="2"><details class="reply-details"><summary>more …</summary><a href="{{ post_raw_path(list.id, post.message_id) }}">View raw</a> <a href="{{ post_eml_path(list.id, post.message_id) }}">Download as <code>eml</code> (RFC 5322 format)</a></details></td>
697 </tr>
698 </table>
699 <div class="post-body">
700 diff --git a/web/src/templates/lists/list.html b/web/src/templates/lists/list.html
701index 4767525..dcd335d 100644
702--- a/web/src/templates/lists/list.html
703+++ b/web/src/templates/lists/list.html
704 @@ -8,13 +8,13 @@
705 <br aria-hidden="true">
706 {% if current_user and not post_policy.no_subscriptions and subscription_policy.open %}
707 {% if user_context %}
708- <form method="post" action="{{ root_url_prefix }}{{ settings_path() }}" class="settings-form">
709+ <form method="post" action="{{ settings_path() }}" class="settings-form">
710 <input type="hidden" name="type", value="unsubscribe">
711 <input type="hidden" name="list_pk", value="{{ list.pk }}">
712 <input type="submit" name="unsubscribe" value="Unsubscribe as {{ current_user.address }}">
713 </form>
714 {% else %}
715- <form method="post" action="{{ root_url_prefix }}{{ settings_path() }}" class="settings-form">
716+ <form method="post" action="{{ settings_path() }}" class="settings-form">
717 <input type="hidden" name="type", value="subscribe">
718 <input type="hidden" name="list_pk", value="{{ list.pk }}">
719 <input type="submit" name="subscribe" value="Subscribe as {{ current_user.address }}">
720 @@ -27,7 +27,7 @@
721 {{ preamble.custom|safe }}
722 {% else %}
723 {% if not post_policy.no_subscriptions %}
724- <h3 id="subscribe">Subscribe<a class="self-link" href="#subscribe"></a></h3>
725+ {{ heading(3, "Subscribe") }}
726 {% set subscription_mailto=list.subscription_mailto() %}
727 {% if subscription_mailto %}
728 {% if subscription_mailto.subject %}
729 @@ -45,7 +45,7 @@
730
731 {% set unsubscription_mailto=list.unsubscription_mailto() %}
732 {% if unsubscription_mailto %}
733- <h3 id="unsubscribe">Unsubscribe<a class="self-link" href="#unsubscribe"></a></h3>
734+ {{ heading(3, "Unsubscribe") }}
735 {% if unsubscription_mailto.subject %}
736 <p>
737 <a href="mailto:{{ unsubscription_mailto.address|safe }}?subject={{ unsubscription_mailto.subject|safe }}"><code>{{ unsubscription_mailto.address }}</code></a> with the following subject: <code>{{unsubscription_mailto.subject}}</code>
738 @@ -58,7 +58,7 @@
739 {% endif %}
740 {% endif %}
741
742- <h3 id="post">Post<a class="self-link" href="#post"></a></h3>
743+ {{ heading(3, "Post") }}
744 {% if post_policy.announce_only %}
745 <p>List is <em>announce-only</em>, i.e. you can only subscribe to receive announcements.</p>
746 {% elif post_policy.subscription_only %}
747 @@ -78,21 +78,21 @@
748 </section>
749 {% endif %}
750 <section class="list" aria-hidden="true">
751- <h3 id="calendar">Calendar<a class="self-link" href="#calendar"></a></h3>
752+ {{ heading(3, "Calendar") }}
753 <div class="calendar">
754 {%- from "calendar.html" import cal %}
755 {% for date in months %}
756- {{ cal(date, hists, root_url_prefix, list.pk) }}
757+ {{ cal(date, hists) }}
758 {% endfor %}
759 </div>
760 </section>
761 <section aria-label="mailing list posts">
762- <h3 id="posts">Posts<a class="self-link" href="#posts"></a></h3>
763+ {{ heading(3, "Posts") }}
764 <div class="posts entries" role="list" aria-label="list of mailing list posts">
765 <p>{{ posts | length }} post{{ posts|length|pluralize }}</p>
766 {% for post in posts %}
767 <div class="entry" role="listitem" aria-labelledby="post_link_{{ loop.index }}">
768- <span class="subject"><a id="post_link_{{ loop.index }}" href="{{ root_url_prefix }}{{ list_post_path(list.id, post.message_id) }}">{{ post.subject }}</a>&nbsp;<span class="metadata replies" title="reply count">{{ post.replies }} repl{{ post.replies|pluralize("y","ies") }}</span></span>
769+ <span class="subject"><a id="post_link_{{ loop.index }}" href="{{ list_post_path(list.id, post.message_id) }}">{{ post.subject }}</a>&nbsp;<span class="metadata replies" title="reply count">{{ post.replies }} repl{{ post.replies|pluralize("y","ies") }}</span></span>
770 <span class="metadata"><span aria-hidden="true">👤&nbsp;</span><span class="from" title="post author">{{ post.address }}</span><span aria-hidden="true"> 📆&nbsp;</span><span class="date" title="post date">{{ post.datetime }}</span></span>
771 {% if post.replies > 0 %}<span class="metadata"><span aria-hidden="true">&#x1F493;&nbsp;</span><span class="last-active" title="latest thread activity">{{ post.last_active }}</span></span>{% endif %}
772 <span class="metadata"><span aria-hidden="true">🪪 </span><span class="message-id" title="e-mail Message-ID">{{ post.message_id }}</span></span>
773 diff --git a/web/src/templates/menu.html b/web/src/templates/menu.html
774index 646615c..d0e8392 100644
775--- a/web/src/templates/menu.html
776+++ b/web/src/templates/menu.html
777 @@ -1,11 +1,11 @@
778 <nav class="main-nav" aria-label="main menu" role="menu">
779 <ul>
780- <li><a role="menuitem" href="{{ root_url_prefix }}/">Index</a></li>
781- <li><a role="menuitem" href="{{ root_url_prefix }}{{ help_path() }}">Help&nbsp;&amp; Documentation</a></li>
782+ <li><a role="menuitem" href="{{ urlize("") }}/">Index</a></li>
783+ <li><a role="menuitem" href="{{ help_path() }}">Help&nbsp;&amp; Documentation</a></li>
784 {% if current_user %}
785- <li class="push">Settings: <a role="menuitem" href="{{ root_url_prefix }}{{ settings_path() }}" title="User settings">{{ current_user.address }}</a></li>
786+ <li class="push">Settings: <a role="menuitem" href="{{ settings_path() }}" title="User settings">{{ current_user.address }}</a></li>
787 {% else %}
788- <li class="push"><a role="menuitem" href="{{ root_url_prefix }}{{ login_path() }}" title="login with one time password using your SSH key">Login with SSH OTP</a></li>
789+ <li class="push"><a role="menuitem" href="{{ login_path() }}" title="login with one time password using your SSH key">Login with SSH OTP</a></li>
790 {% endif %}
791 </ul>
792 </nav>
793 diff --git a/web/src/templates/settings.html b/web/src/templates/settings.html
794index 0241533..c506345 100644
795--- a/web/src/templates/settings.html
796+++ b/web/src/templates/settings.html
797 @@ -1,6 +1,6 @@
798 {% include "header.html" %}
799 <div class="body body-grid">
800- <h3 id="account">Your account<a class="self-link" href="#account"></a></h3>
801+ {{ heading(3,"Your account","account") }}
802 <div class="entries">
803 <div class="entry">
804 <span>Display name: <span class="value{% if not user.name %} empty{% endif %}">{{ user.name if user.name else "None" }}</span></span>
805 @@ -16,19 +16,19 @@
806 </div>
807 </div>
808
809- <h4 id="list-subscriptions">List Subscriptions<a class="self-link" href="#list-subscriptions"></a></h4>
810+ {{ heading(4,"List Subscriptions") }}
811 <div class="entries">
812 <p>{{ subscriptions | length }} subscription(s)</p>
813 {% for (s, list) in subscriptions %}
814 <div class="entry">
815- <span class="subject"><a href="{{ root_url_prefix }}{{ list_settings_path(list.id) }}">{{ list.name }}</a></span>
816+ <span class="subject"><a href="{{ list_settings_path(list.id) }}">{{ list.name }}</a></span>
817 <!-- span class="metadata">📆&nbsp;<span>{{ s.created }}</span></span -->
818 </div>
819 {% endfor %}
820 </div>
821
822- <h4 id="account-settings">Account Settings<a class="self-link" href="#account-settings"></a></h4>
823- <form method="post" action="{{ root_url_prefix }}{{ settings_path() }}" class="settings-form">
824+ {{ heading(4,"Account Settings") }}
825+ <form method="post" action="{{ settings_path() }}" class="settings-form">
826 <input type="hidden" name="type" value="change-name">
827 <fieldset>
828 <legend>Change display name</legend>
829 @@ -41,7 +41,7 @@
830 <input type="submit" name="change" value="Change">
831 </form>
832
833- <form method="post" action="{{ root_url_prefix }}{{ settings_path() }}" class="settings-form">
834+ <form method="post" action="{{ settings_path() }}" class="settings-form">
835 <input type="hidden" name="type" value="change-password">
836 <fieldset>
837 <legend>Change SSH public key</legend>
838 @@ -54,7 +54,7 @@
839 <input type="submit" name="change" value="Change">
840 </form>
841
842- <form method="post" action="{{ root_url_prefix }}{{ settings_path() }}" class="settings-form">
843+ <form method="post" action="{{ settings_path() }}" class="settings-form">
844 <input type="hidden" name="type" value="change-public-key">
845 <fieldset>
846 <legend>Change PGP public key</legend>
847 @@ -67,7 +67,7 @@
848 <input type="submit" name="change-public-key" value="Change">
849 </form>
850
851- <form method="post" action="{{ root_url_prefix }}{{ settings_path() }}" class="settings-form">
852+ <form method="post" action="{{ settings_path() }}" class="settings-form">
853 <input type="hidden" name="type" value="remove-public-key">
854 <fieldset>
855 <legend>Remove PGP public key</legend>
856 diff --git a/web/src/templates/settings_subscription.html b/web/src/templates/settings_subscription.html
857index 6c21be1..cd2d708 100644
858--- a/web/src/templates/settings_subscription.html
859+++ b/web/src/templates/settings_subscription.html
860 @@ -1,6 +1,6 @@
861 {% include "header.html" %}
862 <div class="body body-grid">
863- <h3>Your subscription to <a href="{{ root_url_prefix }}{{ list_path(list.pk) }}">{{ list.id }}</a>.</h3>
864+ {{ heading(3, "Your subscription to <a href=\"" ~ list_path(list.id) ~ "\">" ~ list.id ~ "</a>.","subscription") }}
865 <address>
866 {{ list.name }} <a href="mailto:{{ list.address | safe }}"><code>{{ list.address }}</code></a>
867 </address>
868 @@ -43,7 +43,7 @@
869 <input type="submit" value="Update settings">
870 <input type="hidden" name="next" value="">
871 </form>
872- <form method="post" action="{{ root_url_prefix }}{{ settings_path() }}" class="settings-form">
873+ <form method="post" action="{{ settings_path() }}" class="settings-form">
874 <fieldset>
875 <input type="hidden" name="type" value="unsubscribe">
876 <input type="hidden" name="list_pk" value="{{ list.pk }}">
877 diff --git a/web/src/typed_paths.rs b/web/src/typed_paths.rs
878index 4db1660..17524cc 100644
879--- a/web/src/typed_paths.rs
880+++ b/web/src/typed_paths.rs
881 @@ -132,8 +132,8 @@ pub struct HelpPath;
882
883 macro_rules! unit_impl {
884 ($ident:ident, $ty:expr) => {
885- pub fn $ident() -> Value {
886- Value::from_safe_string($ty.to_crumb().to_string())
887+ pub fn $ident(state: &minijinja::State) -> std::result::Result<Value, Error> {
888+ urlize(state, Value::from($ty.to_crumb().to_string()))
889 }
890 };
891 }
892 @@ -145,18 +145,20 @@ unit_impl!(help_path, HelpPath);
893
894 macro_rules! list_id_impl {
895 ($ident:ident, $ty:tt) => {
896- pub fn $ident(id: Value) -> std::result::Result<Value, Error> {
897- if let Some(id) = id.as_str() {
898- return Ok(Value::from_safe_string(
899- $ty(ListPathIdentifier::Id(id.to_string()))
900- .to_crumb()
901- .to_string(),
902- ));
903- }
904- let pk = id.try_into()?;
905- Ok(Value::from_safe_string(
906- $ty(ListPathIdentifier::Pk(pk)).to_crumb().to_string(),
907- ))
908+ pub fn $ident(state: &minijinja::State, id: Value) -> std::result::Result<Value, Error> {
909+ urlize(
910+ state,
911+ if let Some(id) = id.as_str() {
912+ Value::from(
913+ $ty(ListPathIdentifier::Id(id.to_string()))
914+ .to_crumb()
915+ .to_string(),
916+ )
917+ } else {
918+ let pk = id.try_into()?;
919+ Value::from($ty(ListPathIdentifier::Pk(pk)).to_crumb().to_string())
920+ },
921+ )
922 }
923 };
924 }
925 @@ -169,29 +171,32 @@ list_id_impl!(list_candidates_path, ListEditCandidatesPath);
926
927 macro_rules! list_post_impl {
928 ($ident:ident, $ty:tt) => {
929- pub fn $ident(id: Value, msg_id: Value) -> std::result::Result<Value, Error> {
930- let Some(msg_id) = msg_id.as_str().map(|s| if s.starts_with('<') && s.ends_with('>') { s.to_string() } else {
931- format!("<{s}>")
932- }) else {
933- return Err(Error::new(
934- minijinja::ErrorKind::UnknownMethod,
935- "Second argument of list_post_path must be a string."
936- ));
937- };
938-
939- if let Some(id) = id.as_str() {
940- return Ok(Value::from_safe_string(
941+ pub fn $ident(state: &minijinja::State, id: Value, msg_id: Value) -> std::result::Result<Value, Error> {
942+ urlize(state, {
943+ let Some(msg_id) = msg_id.as_str().map(|s| if s.starts_with('<') && s.ends_with('>') { s.to_string() } else {
944+ format!("<{s}>")
945+ }) else {
946+ return Err(Error::new(
947+ minijinja::ErrorKind::UnknownMethod,
948+ "Second argument of list_post_path must be a string."
949+ ));
950+ };
951+
952+ if let Some(id) = id.as_str() {
953+ Value::from(
954 $ty(ListPathIdentifier::Id(id.to_string()), msg_id)
955 .to_crumb()
956 .to_string(),
957- ));
958- }
959- let pk = id.try_into()?;
960- Ok(Value::from_safe_string(
961- $ty(ListPathIdentifier::Pk(pk), msg_id)
962- .to_crumb()
963- .to_string(),
964- ))
965+ )
966+ } else {
967+ let pk = id.try_into()?;
968+ Value::from(
969+ $ty(ListPathIdentifier::Pk(pk), msg_id)
970+ .to_crumb()
971+ .to_string(),
972+ )
973+ }
974+ })
975 }
976 };
977 }