Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_steel/src/srv/publish/page.rs

136 KiB, 655 runs

created by r1870400018:14358, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The posts as pages: a URL each, HTML in the first response, and the tags a card is built from.
2//!
3//! This is what publishing means here. A reader arrives at a post's own URL and the prose is in the
4//! response that answers it -- no script has to run, nothing has to be fetched, and a crawler, a
5//! reader-mode, a feed reader and a chat window that unfurls a link all see the same thing a person
6//! does.
7//!
8//! # A page names its own look and holds none of it
9//!
10//! The markup here is structural: an article, a heading, a date, a navigation. Every rule about what
11//! those look like comes from the stylesheets the site named in its config. A server that shipped a
12//! font would be deciding something that is not its to decide, and a site that could not restyle its
13//! own prose would not really own it.
14//!
15//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
16//! Anthropic Claude
17
18use crate::srv::cache;
19use crate::srv::publish::{
20 Author,
21 Post,
22 PublishConfig,
23 declare,
24 comment::{
25 DEPTH_MAX,
26 POW_BITS,
27 Thread,
28 },
29 date_text,
30 read_mins,
31};
32
33#[cfg(test)]
34use crate::srv::publish::Source;
35
36use oxedyne_fe2o3_core::prelude::*;
37use oxedyne_fe2o3_net::http::{
38 fields::{
39 HeaderFields,
40 HeaderFieldValue,
41 HeaderName,
42 },
43 msg::HttpMessage,
44 status::HttpStatus,
45};
46use oxedyne_fe2o3_text::doc::html::{
47 escape_attr,
48 escape_text,
49};
50
51
52/// Serves a request that belongs to the published prose.
53///
54/// The caller has already established that the path is this module's, so anything unrecognised under
55/// the prefix is a post that does not exist.
56pub fn handle_get(
57 cfg: &PublishConfig,
58 posts: &[Post],
59 authors: &[Author],
60 path: &str,
61 query: &str,
62 comments: Option<&CommentsView>,
63 id: &str,
64)
65 -> Outcome<HttpMessage>
66{
67 // A trailing slash is the same place. A reader types one, a link carries one, and a directory-shaped
68 // URL is what most of the web looks like -- so `/asides/` answering `404` while `/asides` renders is
69 // a distinction nobody meant to draw, and the reader is told the blog does not exist.
70 //
71 // Sent to the canonical spelling rather than served under both, so the prose has one address: two
72 // URLs for one page split a reader's history, a shared link and a search engine's idea of where the
73 // piece lives.
74 if path.ends_with('/') {
75 let bare = path.trim_end_matches('/');
76 // Never past the prefix itself, so a site whose configured path ends in a slash cannot be sent
77 // round a loop.
78 if bare.len() >= cfg.path.len() {
79 return Ok(moved_to(bare, query));
80 }
81 }
82 if path == cfg.path {
83 return index(cfg, posts, authors, query, id);
84 }
85 if path == cfg.feed_path() {
86 return super::feed::serve(cfg, posts, id);
87 }
88 if path == cfg.json_path() {
89 return super::json::serve(cfg, posts, authors, id);
90 }
91 if path == cfg.comment_js_path() {
92 return Ok(comment_js());
93 }
94 if path == cfg.filter_js_path() {
95 return Ok(filter_js());
96 }
97 // Everything else under the prefix names a post. The slug is what a reader put in a URL, so it is
98 // checked before it is used: a name is letters, digits, a dash or an underscore.
99 let slug = &path[cfg.path.len() + 1..];
100 if !is_slug(slug) {
101 info!("{}: publish: '{}' is not a name a post may wear", id, slug);
102 return Ok(not_found(cfg));
103 }
104 match posts.iter().find(|p| p.slug == slug) {
105 Some(post) => post_page(cfg, post,
106 authors.iter().find(|a| a.username == post.author), comments),
107 None => {
108 info!("{}: publish: no post '{}'", id, slug);
109 Ok(not_found(cfg))
110 }
111 }
112}
113
114/// The post a request path names, where it names one that exists.
115///
116/// The renderers take a slice of posts and touch no database, so the read tally -- which is a write --
117/// cannot be kept here. This is the half of that decision which is pure: given the same path the
118/// renderer was given, it says whether a post was served and which. The caller, which still holds the
119/// database, does the counting.
120///
121/// The index, the feed and the JSON are not posts and answer `None`, so a reader browsing the index
122/// does not add to the tally of everything on it.
123pub fn served_post<'a>(cfg: &PublishConfig, posts: &'a [Post], path: &str) -> Option<&'a Post> {
124 if path == cfg.path || path == cfg.feed_path() || path == cfg.json_path() {
125 return None;
126 }
127 // The same slicing the renderer does, and the same guard: a path that is not under the prefix
128 // with room for a name is not a post.
129 if path.len() < cfg.path.len() + 2 {
130 return None;
131 }
132 let slug = &path[cfg.path.len() + 1..];
133 if !is_slug(slug) {
134 return None;
135 }
136 posts.iter().find(|p| p.slug == slug)
137}
138
139/// The canonical address of a page a reader reached by another spelling of it.
140///
141/// A `301`, since the two spellings name one page and always will: a browser that learns the mapping
142/// stops asking, and a search engine files the prose under one URL rather than two.
143///
144/// The query is carried across, so a link with something in it lands where it meant to -- but only when
145/// every character of it is printable ASCII. It came off the request line and it is going into a
146/// response header, which is the shape a response-splitting attempt takes; anything else and the reader
147/// arrives at the bare path, which is where they were going anyway.
148fn moved_to(path: &str, query: &str) -> HttpMessage {
149 let safe = !query.is_empty()
150 && query.bytes().all(|b| (0x21..=0x7e).contains(&b));
151 let to = if safe { fmt!("{}?{}", path, query) } else { path.to_string() };
152 HttpMessage::new_response(HttpStatus::MovedPermanently)
153 .with_field(HeaderName::Location, HeaderFieldValue::Generic(to))
154}
155
156fn is_slug(s: &str) -> bool {
157 !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
158}
159
160/// The index: every post, newest first, above a filter that narrows them in the reader's browser.
161///
162/// The whole list is rendered, each item carrying its author, tags, categories and reading time as
163/// data attributes; the filter shows and hides items against those. So a reader with no JavaScript
164/// gets every post, and a reader with it gets the filter, over the same markup -- the filter is an
165/// enhancement of the list, never the thing that fetches it.
166///
167/// `authors` are the distinct authors the posts name, resolved to a face, drawn as the filter's
168/// author row. A `?tag=` in the query is read by the script, not here, so a tag link lands on the
169/// index with that tag alone selected; without the script the whole list stands, tag and all.
170fn index(
171 cfg: &PublishConfig,
172 posts: &[Post],
173 authors: &[Author],
174 _query: &str,
175 id: &str,
176)
177 -> Outcome<HttpMessage>
178{
179 let mut body = String::new();
180 body.push_str("<header class=\"aside-index-head\"><h1>");
181 escape_text(&mut body, &cfg.title);
182 body.push_str("</h1></header>\n");
183
184 // The reader is two columns on a wide screen -- the posts on the left, the filter on the right --
185 // and a single column on a narrow one, the filter folded behind a button. The whole is a plain
186 // enhancement: the served script wires the folding and the narrowing, and without it the panel
187 // stands open at desktop widths (the stylesheet's doing) and every post shows, which is the point.
188 body.push_str("<div class=\"aside-layout\">\n");
189
190 // The toggle that unfolds the filter on a narrow screen. The stylesheet hides it where the filter
191 // is a column of its own, so it is only ever seen on a phone; the script gives it its one job.
192 body.push_str("<button type=\"button\" class=\"aside-filter-toggle\" id=\"aside-filter-toggle\" \
193 aria-controls=\"aside-side\" aria-expanded=\"false\">Filter</button>\n");
194
195 // The filter column: the reader's own instrument for narrowing the list, wired by the served
196 // script. It is drawn before the posts in the source so a reader on a phone meets the toggle and
197 // the panel before scrolling into the stream.
198 body.push_str("<aside class=\"aside-side\" id=\"aside-side\">\n");
199 body.push_str(&filter_shell(cfg, posts, authors));
200 body.push_str("</aside>\n");
201
202 // The posts column.
203 body.push_str("<div class=\"aside-main\">\n");
204
205 // What the site is about, in the words of whoever writes it, above the posts.
206 body.push_str(&about_block(authors));
207
208 body.push_str("<div class=\"aside-cards aside-list\" id=\"aside-index-list\">\n");
209 for p in posts {
210 // The card carries what the filter matches on, so the script reads the stream rather than a
211 // second copy of it: the author's public handle, the tags and categories joined, the reading
212 // time, and a lower-cased haystack of the title and opening for the search box.
213 body.push_str("<article class=\"aside-card\" data-author=\"");
214 // The author's public handle, never the username a post stores: that is the SHA-256 of a
215 // passphrase, and a page is read by anyone.
216 escape_attr(&mut body, authors.iter().find(|a| a.username == p.author)
217 .map(|a| a.handle.as_str()).unwrap_or(""));
218 body.push_str("\" data-tags=\"");
219 // Tags are `[a-z0-9-]`, so a space joins them safely. Categories are free config strings that
220 // may hold a space, so they are joined on a comma the config forbids inside a category, and the
221 // script splits on the same.
222 escape_attr(&mut body, &p.tags.join(" "));
223 body.push_str("\" data-categories=\"");
224 escape_attr(&mut body, &p.categories.join(","));
225 body.push_str("\" data-read-mins=\"");
226 body.push_str(&fmt!("{}", read_mins(p.words)));
227 body.push_str("\" data-search=\"");
228 escape_attr(&mut body, &fmt!("{} {}", p.title, p.excerpt).to_lowercase());
229 body.push_str("\">\n");
230
231 // The head: the facts a reader weighs before opening a post, the byline where several write,
232 // and the title that opens the whole piece.
233 body.push_str("<div class=\"aside-card-head\">\n");
234 body.push_str("<div class=\"aside-item-meta\">");
235 if let Some(d) = &p.date {
236 // The attribute is the stored ISO form and the text is the readable one, which is what
237 // `<time>` has two of them for: a post dated to the minute would otherwise show a reader
238 // the `T` in the middle of its own date.
239 body.push_str("<time class=\"aside-date\" datetime=\"");
240 escape_attr(&mut body, d);
241 body.push_str("\">");
242 escape_text(&mut body, &date_text(d));
243 body.push_str("</time>");
244 }
245 body.push_str("<span class=\"aside-read\">");
246 escape_text(&mut body, &read_time(p.words));
247 body.push_str("</span>");
248 // Right of the reading time, as on the post itself: the two surfaces show the same facts in
249 // the same order, and a reader moving between them is not asked to look somewhere new.
250 body.push_str(&post_declaration(cfg, p, "aside-item-declare"));
251 body.push_str("</div>\n");
252 // Who wrote it, where more than one person writes here. On a blog of one it would be the same
253 // name under every title, which tells a reader choosing between them nothing.
254 if authors.len() > 1 {
255 if let Some(a) = authors.iter().find(|a| a.username == p.author) {
256 body.push_str("<div class=\"aside-byline\">");
257 body.push_str(&author_face(a));
258 body.push_str("<span class=\"aside-byline-name\">");
259 escape_text(&mut body, &a.name);
260 body.push_str("</span></div>");
261 }
262 }
263 body.push_str("<h2 class=\"aside-card-title\"><a href=\"");
264 escape_attr(&mut body, &cfg.path_of(&p.slug));
265 body.push_str("\">");
266 escape_text(&mut body, &p.title);
267 body.push_str("</a></h2>\n");
268 body.push_str("</div>\n");
269
270 // The preview: a fixed-height window onto the formatted prose. The head already carries the
271 // title, so a heading the post opens with is dropped here rather than said twice; the whole
272 // read, one click away, keeps it. The stylesheet clips the window and fades its foot, and the
273 // script lifts the fade off a preview that is not actually cut.
274 body.push_str("<div class=\"aside-card-preview\">\n<div class=\"aside aside-card-prose\">");
275 // The post's own rendered prose, trusted as `post_page` trusts it -- it is the site's markup,
276 // escaped where it was rendered, not a value from a reader.
277 body.push_str(strip_leading_heading(&p.html));
278 body.push_str("</div>\n</div>\n");
279
280 // The foot: the way in to the whole piece, a plain link to the post's own page since this is a
281 // multi-page site and the post is a page, and the post's chips beside it.
282 body.push_str("<div class=\"aside-card-foot\">\n");
283 body.push_str("<a class=\"aside-readmore\" href=\"");
284 escape_attr(&mut body, &cfg.path_of(&p.slug));
285 body.push_str("\">Read more</a>");
286 body.push_str(&facets_list(cfg, p));
287 body.push_str("</div>\n");
288
289 body.push_str("</article>\n");
290 }
291 body.push_str("</div>\n");
292
293 // Two states, and they say different things, so they are two lines. A blog with nothing in it is
294 // the server's to know, and it says so. A filter that has excluded every post is the script's to
295 // know, and what it has to say is that there is prose here and none of it matches -- which is not
296 // the same news at all. One element doing both told a reader who had narrowed too far that the blog
297 // was empty, and the way out of that is to widen the filter, which is the one thing the words did
298 // not suggest.
299 body.push_str("<p class=\"aside-empty\" id=\"aside-empty\"");
300 if !posts.is_empty() {
301 body.push_str(" hidden");
302 }
303 body.push_str(">Nothing here yet.</p>\n");
304 body.push_str("<p class=\"aside-empty\" id=\"aside-none\" hidden>Nothing matches that.</p>\n");
305
306 // A newsletter sign-up beneath the list, so a reader subscribes in place rather than hunting for
307 // it. It posts to the same endpoint as the standalone page; where mail is not configured that
308 // endpoint answers "not available", so the form is safe to show unconditionally.
309 body.push_str("<section class=\"aside-subscribe-inline\">\n<h2>Subscribe</h2>\n");
310 body.push_str("<p>New posts by email. Confirm once, unsubscribe from any message.</p>\n");
311 body.push_str("<form class=\"aside-subscribe\" method=\"post\" action=\"");
312 escape_attr(&mut body, &cfg.subscribe_path());
313 body.push_str("\">\n<input type=\"email\" name=\"email\" id=\"aside-subscribe-email\" \
314 placeholder=\"you@example.com\" autocomplete=\"email\" aria-label=\"Email\" required>\n");
315 body.push_str("<button type=\"submit\" class=\"aside-subscribe-btn\">Subscribe</button>\n");
316 body.push_str("</form>\n</section>\n");
317
318 // Close the posts column and the two-column layout.
319 body.push_str("</div>\n</div>\n");
320
321 // The script that wires the filter. Referenced rather than inlined, on the same reasoning as the
322 // comment script: a site may run a Content-Security-Policy that forbids inline script, and the
323 // filter is an enhancement -- `defer`, since it only reads the list already in the page.
324 body.push_str("<script defer src=\"");
325 escape_attr(&mut body, &cfg.filter_js_path());
326 body.push_str("\"></script>\n");
327
328 info!("{}: publish: index, {} posts", id, posts.len());
329
330 let head = Head {
331 title: cfg.title.clone(),
332 description: String::new(),
333 url: cfg.url_of(&cfg.path),
334 kind: "website",
335 date: None,
336 };
337 Ok(html_response(HttpStatus::OK, &page(cfg, &head, &body, None, true)))
338}
339
340/// The filter above the index: the controls a reader narrows the list with.
341///
342/// Rendered whole and static; the served script wires it. Every control starts in the state that
343/// shows every post, so the page a reader lands on is the whole list and the filter only ever takes
344/// away: no author pressed, every category and every tag in its own selected box under `Includes`,
345/// and the reading-time slider spanning the full range. The vocabulary and the range are read from
346/// the posts, so the filter offers exactly what the list holds and nothing it does not.
347///
348/// Categories and tags are the same widget twice, differing only in which chips they hold: the two
349/// are different kinds of thing to a reader, but they are narrowed by the same gesture, and a reader
350/// who has learnt one has learnt both.
351fn filter_shell(cfg: &PublishConfig, posts: &[Post], authors: &[Author]) -> String {
352 // The tag vocabulary: every tag any shown post wears, sorted, deduped. What the two chip boxes are
353 // filled from -- the selected box by default, since the default is to hide nothing.
354 let mut tags: Vec<&str> = Vec::new();
355 for p in posts {
356 for t in &p.tags {
357 if !tags.iter().any(|x| *x == t.as_str()) {
358 tags.push(t.as_str());
359 }
360 }
361 }
362 tags.sort_unstable();
363
364 // The reading-time range across the posts, the slider's bounds. A site whose posts run one to nine
365 // minutes gets a one-to-nine slider, not a dead one-to-sixty. Equal bounds (one post, or all of a
366 // length) leave a slider with nothing to drag, which the script hides.
367 let mins: Vec<usize> = posts.iter().map(|p| read_mins(p.words)).collect();
368 let rt_lo = mins.iter().copied().min().unwrap_or(1);
369 let rt_hi = mins.iter().copied().max().unwrap_or(1);
370
371 let mut s = String::from("<section class=\"aside-filter\" aria-label=\"Filter posts\">\n");
372
373 // Search, dressed as a facet like the two vocabularies below it: a heading, the same
374 // Include / Only / Exclude row they wear, and a box in the whole site's own search look. Include
375 // keeps the posts that carry the words, Only those that carry them as a whole word, Exclude those
376 // that do not. The box searches the title and opening of each post.
377 s.push_str("<div class=\"aside-facet aside-facet-search\" data-facet=\"search\">\n\
378 <div class=\"aside-facet-head\">\n<span class=\"aside-facet-lbl\">Search</span>\n\
379 <div class=\"aside-facet-mode\" role=\"radiogroup\" aria-label=\"Search match\">\n");
380 for (val, name, on) in [("includes", "Include", true), ("only", "Only", false),
381 ("excludes", "Exclude", false)]
382 {
383 s.push_str("<label class=\"aside-mode\"><input type=\"radio\" name=\"aside-search-mode\" value=\"");
384 s.push_str(val);
385 s.push('"');
386 if on {
387 s.push_str(" checked");
388 }
389 s.push('>');
390 s.push_str(name);
391 s.push_str("</label>\n");
392 }
393 s.push_str("</div>\n</div>\n");
394 // The box. The magnifier is drawn inline so no asset must ship for it -- a missing icon file is a
395 // broken square on a live page, and this leaves nothing to forget to deploy.
396 s.push_str("<div class=\"search-box aside-search-box\">\n");
397 s.push_str(SEARCH_ICON);
398 s.push_str("<input type=\"search\" class=\"aside-filter-search\" id=\"aside-filter-search\" \
399 placeholder=\"Search posts\" aria-label=\"Search posts\" autocomplete=\"off\">\n");
400 s.push_str("</div>\n</div>\n");
401
402 // The authors, each a face that narrows the list to that author. Every face starts pressed -- the
403 // default is all of them, shown selected the way each vocabulary starts with every chip in its
404 // Selected box -- and deselecting narrows; deselecting the last imposes nothing, so the stream
405 // opens back up rather than empties. Only those with a post in the list are offered: `authors`
406 // also holds whoever else may write here, for the description above, and a face that narrows the
407 // list to nothing is a control that can only disappoint.
408 let authors: Vec<&Author> = authors.iter()
409 .filter(|a| posts.iter().any(|p| p.author == a.username))
410 .collect();
411 if !authors.is_empty() {
412 s.push_str("<div class=\"aside-filter-authors\" id=\"aside-filter-authors\" \
413 aria-label=\"Filter by author\">\n");
414 for a in authors {
415 s.push_str("<button type=\"button\" class=\"aside-author\" data-author=\"");
416 escape_attr(&mut s, &a.handle);
417 s.push_str("\" title=\"");
418 escape_attr(&mut s, &a.name);
419 s.push_str("\" aria-pressed=\"true\">");
420 s.push_str(&author_face(a));
421 s.push_str("<span class=\"aside-author-name\">");
422 escape_text(&mut s, &a.name);
423 s.push_str("</span></button>\n");
424 }
425 s.push_str("</div>\n");
426 }
427
428 // The categories, then the tags. The coarser vocabulary leads, on the same reasoning the chips
429 // under a post follow: a reader scanning controls wants the section before the specifics. The
430 // categories are the site's configured list, in the order the site wrote it -- an order somebody
431 // chose, which sorting would throw away -- while the tags are gathered from the posts and sorted,
432 // nobody having chosen an order for them.
433 //
434 // Neither block is drawn where no post carries a value in that family. A config's categories are a
435 // vocabulary the site *may* file under, not a claim that anything is filed: offered against posts
436 // that wear none, every chip narrows the list to nothing, which is a row of controls that can only
437 // disappoint. The tags were already suppressed on this reasoning and the categories were not, so a
438 // blog whose posts carry neither drew six category chips and no tag chips over the same nothing.
439 let cats: Vec<&str> = cfg.categories.iter().map(|c| c.as_str()).collect();
440 let filed = posts.iter().any(|p| !p.categories.is_empty());
441 if !cats.is_empty() && filed {
442 s.push_str(&facet_block("cat", "Categories", &cats));
443 }
444 if !tags.is_empty() {
445 s.push_str(&facet_block("tag", "Tags", &tags));
446 }
447
448 // Reading time, the last cut a reader makes, always a slider: two thumbs over the range the posts
449 // span, to keep the short ones, the long ones, or a band between. Where every post reads alike
450 // there is nothing to narrow, so the range is floored at one minute up to the longest -- the
451 // slider is still there and still moves, and becomes the posts' own range the moment a second
452 // length exists. The read-out beside it the script keeps current.
453 if !posts.is_empty() {
454 let (tlo, thi) = if rt_hi > rt_lo { (rt_lo, rt_hi) } else { (1, rt_hi.max(2)) };
455 s.push_str(&fmt!(
456 "<div class=\"aside-filter-time\" id=\"aside-filter-time\" data-lo=\"{lo}\" data-hi=\"{hi}\">\n\
457 <span class=\"aside-time-lbl\">Reading time</span>\n\
458 <div class=\"aside-time-track\">\n\
459 <div class=\"aside-time-rail\"></div>\n\
460 <div class=\"aside-time-fill\" id=\"aside-time-fill\"></div>\n\
461 <input type=\"range\" class=\"aside-time-min\" id=\"aside-time-min\" \
462 min=\"{lo}\" max=\"{hi}\" value=\"{lo}\" step=\"1\" aria-label=\"Least minutes\">\n\
463 <input type=\"range\" class=\"aside-time-max\" id=\"aside-time-max\" \
464 min=\"{lo}\" max=\"{hi}\" value=\"{hi}\" step=\"1\" aria-label=\"Most minutes\">\n\
465 </div>\n\
466 <span class=\"aside-time-out\" id=\"aside-time-out\">{lo}\u{2013}{hi} min</span>\n\
467 </div>\n",
468 lo = tlo, hi = thi));
469 }
470
471 s.push_str("</section>\n");
472 s
473}
474
475// The magnifier beside the filter's search box, drawn inline.
476//
477// Inline rather than an `<img>` to a file, so nothing has to be shipped for it: a search box wants a
478// glyph and a missing icon file is a broken square on a live page. `currentColor` so it takes the
479// box's own ink.
480const SEARCH_ICON: &str = "<svg class=\"aside-search-ico\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" \
481 fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\">\
482 <circle cx=\"11\" cy=\"11\" r=\"7\"></circle><line x1=\"20\" y1=\"20\" x2=\"16.65\" y2=\"16.65\">\
483 </line></svg>";
484
485/// One facet family's controls: a mode row, and a pair of boxes the reader moves chips between.
486///
487/// Categories and tags are drawn by this one function, so the two are the same instrument twice
488/// rather than two instruments a reader has to learn separately. `facet` is the short name the markup
489/// and the script both key on -- `cat` or `tag` -- and `label` is what the reader is shown.
490///
491/// Every value starts in the selected box, since the default state of a filter is to hide nothing,
492/// and `Includes` is the default mode, being the one that with everything selected still shows every
493/// post. A chip carries its closer in either box: which box it is in is what the chip means, and the
494/// stylesheet decides whether the closer is worth drawing there.
495fn facet_block(facet: &str, label: &str, values: &[&str]) -> String {
496 let mut s = String::from("<div class=\"aside-facet\" data-facet=\"");
497 s.push_str(facet);
498 s.push_str("\">\n<div class=\"aside-facet-head\">\n<span class=\"aside-facet-lbl\">");
499 escape_text(&mut s, label);
500 s.push_str("</span>\n<div class=\"aside-facet-mode\" role=\"radiogroup\" aria-label=\"");
501 escape_attr(&mut s, &fmt!("{} match", label));
502 s.push_str("\">\n");
503 for (val, name, on) in [("includes", "Include", true), ("only", "Only", false),
504 ("excludes", "Exclude", false)]
505 {
506 s.push_str("<label class=\"aside-mode\"><input type=\"radio\" name=\"aside-mode-");
507 s.push_str(facet);
508 s.push_str("\" value=\"");
509 s.push_str(val);
510 s.push('"');
511 if on {
512 s.push_str(" checked");
513 }
514 s.push('>');
515 s.push_str(name);
516 s.push_str("</label>\n");
517 }
518 s.push_str("</div>\n</div>\n<div class=\"aside-facet-boxes\">\n");
519
520 // The selected box, holding the whole vocabulary, and the empty source box beside it. The labels
521 // name which is which, since the two look alike.
522 s.push_str("<div class=\"aside-facetbox\">\n<span class=\"aside-facetbox-lbl\">Selected</span>\n\
523 <div class=\"aside-chips aside-chips-selected\" data-box=\"selected\" data-facet=\"");
524 s.push_str(facet);
525 s.push_str("\" role=\"list\">\n");
526 for v in values {
527 // No whitespace inside the chip: it is an inline-flex box, and a text node between the label and
528 // the closer becomes a gap nothing in the stylesheet asked for.
529 s.push_str("<button type=\"button\" class=\"aside-chip aside-chip-");
530 s.push_str(facet);
531 s.push_str("\" draggable=\"true\" data-facet=\"");
532 s.push_str(facet);
533 s.push_str("\" data-value=\"");
534 // The raw value, not a slug of it: a category may hold a space and a capital, and the script
535 // matches what the post itself carries.
536 escape_attr(&mut s, v);
537 s.push_str("\" role=\"listitem\"><span class=\"aside-chip-lbl\">");
538 escape_text(&mut s, v);
539 s.push_str("</span><span class=\"aside-chip-x\" aria-hidden=\"true\">&#215;</span></button>\n");
540 }
541 s.push_str("</div>\n</div>\n");
542 s.push_str("<div class=\"aside-facetbox\">\n<span class=\"aside-facetbox-lbl\">Available</span>\n\
543 <div class=\"aside-chips aside-chips-source\" data-box=\"source\" data-facet=\"");
544 s.push_str(facet);
545 s.push_str("\" role=\"list\"></div>\n</div>\n");
546 s.push_str("</div>\n</div>\n");
547 s
548}
549
550/// An author's picture, or the initial drawn in its place.
551///
552/// One definition, so a byline, the note under a post and the filter's author row all draw the same
553/// face. An avatar the author uploaded is served by this module; one they gave as a URL is fetched
554/// from wherever they said.
555fn author_face(a: &Author) -> String {
556 let mut s = String::new();
557 if a.avatar.is_empty() {
558 s.push_str("<span class=\"aside-author-initial\" aria-hidden=\"true\">");
559 escape_text(&mut s, &a.initial());
560 s.push_str("</span>");
561 } else {
562 s.push_str("<img class=\"aside-author-pic\" alt=\"\" src=\"");
563 escape_attr(&mut s, &a.avatar);
564 s.push_str("\">");
565 }
566 s
567}
568
569/// What the site is about, above the posts: each author's own description of what they write.
570///
571/// The first thing a reader meets, because a stranger landing on a list of titles has no way to tell
572/// what the blog is for. Where one person writes the blog, their description *is* the blog's, which is
573/// why nothing here is configured separately -- an author who writes what they are about has said what
574/// the site is about, and there is no second place for the two to disagree.
575///
576/// Nothing at all where no author has written one, rather than an empty panel.
577fn about_block(authors: &[Author]) -> String {
578 if !authors.iter().any(|a| !a.bio.is_empty()) {
579 return String::new();
580 }
581 let mut s = String::from("<section class=\"aside-about\" aria-label=\"About\">\n");
582 for a in authors.iter().filter(|a| !a.bio.is_empty()) {
583 // The public handle, so the script can narrow the intros to the selected authors the same way
584 // it narrows the posts -- and never the login username, which is the hash of a passphrase.
585 s.push_str("<div class=\"aside-about-who\" data-author=\"");
586 escape_attr(&mut s, &a.handle);
587 s.push_str("\">");
588 s.push_str(&author_face(a));
589 s.push_str("<div class=\"aside-about-body\">");
590 // The name is drawn only where more than one person writes here: on a blog of one, the name
591 // is on every post already, and a heading repeating it says nothing.
592 if authors.len() > 1 {
593 s.push_str("<span class=\"aside-about-name\">");
594 escape_text(&mut s, &a.name);
595 s.push_str("</span>");
596 }
597 s.push_str("<p class=\"aside-about-bio\">");
598 escape_text(&mut s, &a.bio);
599 s.push_str("</p></div></div>\n");
600 }
601 s.push_str("</section>\n");
602 s
603}
604
605/// A post's facets as a list of links: its categories first, then its tags.
606///
607/// The two are different kinds of thing and read as different kinds of chip. A **category** comes
608/// from a fixed vocabulary the site chose, so it says where the piece sits in the whole; a **tag**
609/// is whatever the author reached for, so it says what this piece is about. Drawn alike they would
610/// be one undifferentiated row and the distinction the two exist to make would be invisible --
611/// hence `.post-cat` ahead of `.tag`, the category solid and the tag outlined.
612///
613/// Categories lead because the fixed thing is the coarser one: a reader scanning a row of chips
614/// wants the section before the specifics.
615///
616/// Nothing at all for a post with neither, so the element is never an empty shell. Each link is the
617/// index narrowed to that facet. A tag is `[a-z0-9-]` and needs no encoding; a category is from the
618/// site's own list and may hold a space or a capital, so it is percent-encoded.
619fn facets_list(cfg: &PublishConfig, post: &Post) -> String {
620 if post.categories.is_empty() && post.tags.is_empty() {
621 return String::new();
622 }
623 let mut s = String::from("<ul class=\"post-tags\">");
624 for c in &post.categories {
625 s.push_str("<li><a class=\"post-cat\" href=\"");
626 escape_attr(&mut s, &cfg.path);
627 s.push_str("?cat=");
628 escape_attr(&mut s, &percent_encode(c));
629 s.push_str("\">");
630 escape_text(&mut s, c);
631 s.push_str("</a></li>");
632 }
633 for t in &post.tags {
634 s.push_str("<li><a class=\"tag\" href=\"");
635 escape_attr(&mut s, &cfg.path);
636 s.push_str("?tag=");
637 escape_attr(&mut s, t);
638 s.push_str("\">");
639 escape_text(&mut s, t);
640 s.push_str("</a></li>");
641 }
642 s.push_str("</ul>");
643 s
644}
645
646/// The reading time above a post, as its label. The minutes are [`read_mins`], the site's one
647/// definition, so this badge and the filter's slider count alike.
648fn read_time(words: usize) -> String {
649 fmt!("{} min read", read_mins(words))
650}
651
652/// A post's rendered HTML with a leading heading removed, for a card's clipped preview.
653///
654/// The card draws the post's title in its own head, so a post whose prose opens with that same
655/// heading would show it twice. One leading `<h1>`--`<h6>` element and the whitespace around it are
656/// dropped, matching the reader script's own leading-heading rule so the card the server draws and
657/// the card the app draws agree to the word. Anything that is not a heading at the very start is
658/// left untouched, and a malformed heading with no close is left rather than swallowing the post.
659///
660/// The renderer emits lowercase tags, so the close is matched literally; an unexpected case simply
661/// leaves the heading in place, which shows a title twice but breaks nothing.
662fn strip_leading_heading(html: &str) -> &str {
663 let t = html.trim_start();
664 let b = t.as_bytes();
665 // A leading `<hN` where N is 1..6, the open tag case-folded so `<H1>` is caught too.
666 if b.len() < 4 || b[0] != b'<' || (b[1] | 0x20) != b'h' || !(b'1'..=b'6').contains(&b[2]) {
667 return t;
668 }
669 let close = fmt!("</h{}>", b[2] as char);
670 match t.find(&close) {
671 Some(i) => t[i + close.len()..].trim_start(),
672 None => t,
673 }
674}
675
676fn post_page(
677 cfg: &PublishConfig,
678 post: &Post,
679 author: Option<&Author>,
680 comments: Option<&CommentsView>,
681)
682 -> Outcome<HttpMessage>
683{
684 let mut body = String::new();
685 body.push_str("<article class=\"aside\">\n");
686 // Who wrote it, before the prose: on a blog more than one person writes, the byline is part of
687 // reading the piece rather than a credit to find afterwards.
688 if let Some(a) = author {
689 body.push_str("<div class=\"aside-byline\">");
690 body.push_str(&author_face(a));
691 body.push_str("<span class=\"aside-byline-name\">");
692 escape_text(&mut body, &a.name);
693 body.push_str("</span></div>\n");
694 }
695 // The date, and beside it how long the piece takes to read. A reader deciding whether to start
696 // wants both, and wants them before the prose rather than after it.
697 if post.date.is_some() || post.words > 0 {
698 body.push_str("<div class=\"aside-date\">");
699 if let Some(d) = &post.date {
700 body.push_str("<time datetime=\"");
701 escape_attr(&mut body, d);
702 body.push_str("\">");
703 escape_text(&mut body, &date_text(d));
704 body.push_str("</time>");
705 }
706 if post.words > 0 {
707 body.push_str("<span class=\"aside-read\">");
708 escape_text(&mut body, &read_time(post.words));
709 body.push_str("</span>");
710 }
711 // The declaration sits with the date and the reading time, which is where a reader is already
712 // looking for what this piece is before starting it.
713 body.push_str(&post_declaration(cfg, post, "aside-declare"));
714 body.push_str("</div>\n");
715 }
716 // The prose was escaped where it was rendered.
717 body.push_str(&post.html);
718 // The tags, in the article's footer, each a link back to the index narrowed to that tag. Omitted
719 // entirely for a post with none.
720 body.push_str(&facets_list(cfg, post));
721 body.push_str("</article>\n");
722
723 // Who wrote it, in their own words, for the reader who has just finished and wants to know whose
724 // piece it was. Only where they have written a description: an empty one draws nothing rather
725 // than a box with a name in it.
726 if let Some(a) = author.filter(|a| !a.bio.is_empty()) {
727 body.push_str("<aside class=\"aside-author-note\">");
728 body.push_str(&author_face(a));
729 body.push_str("<div class=\"aside-author-note-body\"><span class=\"aside-author-note-name\">");
730 escape_text(&mut body, &a.name);
731 body.push_str("</span><p>");
732 escape_text(&mut body, &a.bio);
733 body.push_str("</p></div></aside>\n");
734 }
735
736 // Where the post also lives, and where the conversation about it may be. `nofollow`, since these
737 // are the site's own syndicated copies and not endorsements to pass rank to, and a new tab, since a
738 // reader following one has not finished with the page they are on.
739 if !post.also_on.is_empty() {
740 body.push_str("<nav class=\"aside-also\"><span class=\"aside-also-lbl\">Also on</span>");
741 for (dest, url) in &post.also_on {
742 body.push_str(" <a class=\"aside-also-link\" rel=\"nofollow noopener\" target=\"_blank\" href=\"");
743 escape_attr(&mut body, url);
744 body.push_str("\">");
745 escape_text(&mut body, dest.capability().name);
746 body.push_str("</a>");
747 }
748 body.push_str("</nav>\n");
749 }
750
751 // The conversation, where the caller read one. A page rendered without it is a page for a site
752 // that takes no comments, which is a configuration rather than a failure.
753 if let Some(view) = comments {
754 body.push_str(&comments_section(cfg, post, view));
755 }
756
757 let head = Head {
758 title: post.title.clone(),
759 description: post.excerpt.clone(),
760 url: cfg.url_of(&cfg.path_of(&post.slug)),
761 kind: "article",
762 date: post.date.clone(),
763 };
764 Ok(html_response(HttpStatus::OK, &page(cfg, &head, &body, Some(post), false)))
765}
766
767/// A post that is not there.
768///
769/// Served as a page rather than a bare line, because a reader who mistyped a URL, or followed a link
770/// to a post that has been taken down, is still a reader and should land somewhere with a way on.
771fn not_found(cfg: &PublishConfig) -> HttpMessage {
772 let mut body = String::new();
773 body.push_str("<article class=\"aside\"><h1>Not here</h1><p>There is no such piece. <a href=\"");
774 escape_attr(&mut body, &cfg.path);
775 body.push_str("\">");
776 escape_text(&mut body, &cfg.title);
777 body.push_str("</a> has the rest.</p></article>\n");
778
779 let head = Head {
780 title: fmt!("Not here"),
781 description: String::new(),
782 url: cfg.url_of(&cfg.path),
783 kind: "website",
784 date: None,
785 };
786 html_response(HttpStatus::NotFound, &page(cfg, &head, &body, None, false))
787}
788
789
790/// What a page says about itself.
791struct Head {
792 title: String, // before the site's name is added
793 description: String, // a sentence standing in for the page, in a card and in search
794 url: String, // canonical, absolute
795 kind: &'static str, // `article` for a post, `website` for the index
796 date: Option<String>, // for a post that has one
797}
798
799/// The line at the top of every page: the site's mark, and the way back to the posts.
800///
801/// The mark is the site's logo where one is configured and the blog's own title where none is, and it
802/// leads to the site's front page where the configuration names one. A blog is usually one part of a
803/// larger site, and a reader who arrives at a post from elsewhere has otherwise no way into the rest
804/// of it.
805///
806/// Where the mark leads away from the posts, a page that is not the index carries a second link back
807/// to it -- the mark used to be that link, and a reader deep in a post would otherwise lose the list.
808/// On the index itself that link would point at the page it is on, so it is not drawn; and a site
809/// naming no front page keeps the single link it always had, the mark being it.
810fn nav(cfg: &PublishConfig, on_index: bool) -> String {
811 let mut s = String::from("<nav class=\"aside-nav\"><a class=\"aside-home\" href=\"");
812 escape_attr(&mut s, if cfg.home.is_empty() { &cfg.path } else { &cfg.home });
813 s.push_str("\">");
814 if cfg.logo.is_empty() {
815 // What the site is called, not what it calls its posts. The link beside this one already
816 // says the latter, and a site that sets `home` was getting the same word twice, side by
817 // side, going to two different places. The logo's alt text below has always read it this
818 // way round; only the wordmark did not.
819 escape_text(&mut s, if cfg.site_name.is_empty() { &cfg.title } else { &cfg.site_name });
820 } else {
821 // The site's name is what the mark says, so that is what a reader who cannot see it is told;
822 // a site that has not named itself falls back to what it calls its posts.
823 s.push_str("<img class=\"aside-logo\" src=\"");
824 escape_attr(&mut s, &cfg.logo);
825 s.push_str("\" alt=\"");
826 escape_attr(&mut s, if cfg.site_name.is_empty() { &cfg.title } else { &cfg.site_name });
827 s.push_str("\">");
828 }
829 s.push_str("</a>");
830 if !on_index && !cfg.home.is_empty() {
831 s.push_str("<a class=\"aside-back\" href=\"");
832 escape_attr(&mut s, &cfg.path);
833 s.push_str("\">");
834 escape_text(&mut s, &cfg.title);
835 s.push_str("</a>");
836 }
837 s.push_str("</nav>\n");
838 s
839}
840
841fn page(cfg: &PublishConfig, head: &Head, body: &str, post: Option<&Post>, on_index: bool) -> String {
842 let mut s = String::new();
843 s.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
844 s.push_str("<meta charset=\"utf-8\">\n");
845 s.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
846
847 // The tab, and the card's fallback title.
848 s.push_str("<title>");
849 escape_text(&mut s, &head.title);
850 if !cfg.site_name.is_empty() && head.title != cfg.site_name {
851 s.push_str(" — ");
852 escape_text(&mut s, &cfg.site_name);
853 }
854 s.push_str("</title>\n");
855
856 if !head.description.is_empty() {
857 s.push_str("<meta name=\"description\" content=\"");
858 escape_attr(&mut s, &head.description);
859 s.push_str("\">\n");
860 }
861
862 // Canonical, so a post shared with a query string on it is still one post.
863 if !cfg.base_url.is_empty() {
864 s.push_str("<link rel=\"canonical\" href=\"");
865 escape_attr(&mut s, &head.url);
866 s.push_str("\">\n");
867 }
868
869 s.push_str("<link rel=\"alternate\" type=\"application/atom+xml\" title=\"");
870 escape_attr(&mut s, &cfg.title);
871 s.push_str("\" href=\"");
872 escape_attr(&mut s, &cfg.feed_path());
873 s.push_str("\">\n");
874
875 // The card a link makes when it is pasted somewhere.
876 meta_prop(&mut s, "og:type", head.kind);
877 meta_prop(&mut s, "og:title", &head.title);
878 if !head.description.is_empty() {
879 meta_prop(&mut s, "og:description", &head.description);
880 }
881 if !cfg.base_url.is_empty() {
882 meta_prop(&mut s, "og:url", &head.url);
883 }
884 if !cfg.site_name.is_empty() {
885 meta_prop(&mut s, "og:site_name", &cfg.site_name);
886 }
887 if let Some(d) = &head.date {
888 meta_prop(&mut s, "article:published_time", d);
889 }
890 // No image, so a card with no picture is the summary rather than a large empty frame.
891 meta_name(&mut s, "twitter:card", "summary");
892
893 for href in &cfg.css {
894 s.push_str("<link rel=\"stylesheet\" href=\"");
895 escape_attr(&mut s, href);
896 s.push_str("\">\n");
897 }
898
899 if let Some(post) = post {
900 s.push_str(&json_ld(cfg, post));
901 }
902
903 s.push_str("</head>\n<body class=\"aside-body\">\n<main class=\"aside-page\">\n");
904 s.push_str(&nav(cfg, on_index));
905 s.push_str(body);
906 s.push_str("</main>\n</body>\n</html>\n");
907 s
908}
909
910/// A post's own declaration, where its author made one.
911///
912/// Written prose, so the mark is the one for a document whatever else the site is. Empty where the
913/// author declared nothing: a post with no declaration is a post the site says nothing about, and an
914/// undeclared work must never be drawn as one declaring the bottom rung.
915///
916/// Drawn alone, at one size, wherever a post says how long it takes to read -- the mark belongs with
917/// the other things a reader is told before deciding to read. No words: they would repeat on every
918/// card in the list, and the mark is drawn large enough to be read without them.
919fn post_declaration(cfg: &PublishConfig, post: &Post, class: &str) -> String {
920 match post.ai_level {
921 Some(level) => declare::mark_html(
922 &cfg.declare,
923 declare::Declaration::new(level, declare::Medium::Doc),
924 declare::Size::alone(declare::MARK_SIZE_PX),
925 class,
926 ),
927 None => String::new(),
928 }
929}
930
931/// What a search engine reads instead of guessing.
932fn json_ld(cfg: &PublishConfig, post: &Post) -> String {
933 // Built by hand rather than through an encoder, because the values are escaped for a script
934 // element rather than for JSON alone: a title containing `</script>` would otherwise end the
935 // block and everything after it would be markup.
936 let mut s = String::new();
937 s.push_str("<script type=\"application/ld+json\">\n{\n");
938 s.push_str(" \"@context\": \"https://schema.org\",\n \"@type\": \"BlogPosting\",\n");
939 s.push_str(" \"headline\": ");
940 json_str(&mut s, &post.title);
941 s.push_str(",\n");
942 if !post.excerpt.is_empty() {
943 s.push_str(" \"description\": ");
944 json_str(&mut s, &post.excerpt);
945 s.push_str(",\n");
946 }
947 if let Some(d) = &post.date {
948 s.push_str(" \"datePublished\": ");
949 json_str(&mut s, d);
950 s.push_str(",\n");
951 }
952 if !cfg.base_url.is_empty() {
953 s.push_str(" \"url\": ");
954 json_str(&mut s, &cfg.url_of(&cfg.path_of(&post.slug)));
955 s.push_str(",\n");
956 }
957 s.push_str(" \"mainEntityOfPage\": true\n}\n</script>\n");
958 s
959}
960
961/// Writes a JSON string that is also safe inside a `script` element.
962///
963/// `<` is escaped as `<`, which JSON reads as `<` and an HTML parser cannot read as the start of
964/// a tag. That is what stops a title containing `</script>` from closing the block it sits in.
965fn json_str(out: &mut String, s: &str) {
966 out.push('"');
967 for c in s.chars() {
968 match c {
969 '"' => out.push_str("\\\""),
970 '\\' => out.push_str("\\\\"),
971 '\n' => out.push_str("\\n"),
972 '\r' => out.push_str("\\r"),
973 '\t' => out.push_str("\\t"),
974 '<' => out.push_str("\\u003c"),
975 '>' => out.push_str("\\u003e"),
976 '&' => out.push_str("\\u0026"),
977 c if (c as u32) < 0x20 => {
978 out.push_str("\\u00");
979 let b = c as u8;
980 out.push(char::from_digit((b >> 4) as u32, 16).unwrap_or('0'));
981 out.push(char::from_digit((b & 0xf) as u32, 16).unwrap_or('0'));
982 }
983 c => out.push(c),
984 }
985 }
986 out.push('"');
987}
988
989/// A `<meta property=...>`, as Open Graph wants.
990fn meta_prop(out: &mut String, prop: &str, content: &str) {
991 out.push_str("<meta property=\"");
992 out.push_str(prop);
993 out.push_str("\" content=\"");
994 escape_attr(out, content);
995 out.push_str("\">\n");
996}
997
998/// A `<meta name=...>`, as everything else wants.
999fn meta_name(out: &mut String, name: &str, content: &str) {
1000 out.push_str("<meta name=\"");
1001 out.push_str(name);
1002 out.push_str("\" content=\"");
1003 escape_attr(out, content);
1004 out.push_str("\">\n");
1005}
1006
1007// ┌───────────────────────────────────────────────────────────────────────────┐
1008// │ THE NEWSLETTER'S PUBLIC PAGES │
1009// └───────────────────────────────────────────────────────────────────────────┘
1010
1011/// The themed sign-up form, served at `GET {path}/subscribe`.
1012///
1013/// A working, script-free form the site can link to directly, and the shape the site's own inline form
1014/// should mirror: a `POST` to the same path with one field, `email`. The classes and ids below are the
1015/// contract the front-end is built against.
1016pub fn subscribe_form_page(cfg: &PublishConfig) -> HttpMessage {
1017 let mut body = String::new();
1018 body.push_str("<article class=\"aside aside-subscribe-page\">\n<h1>Subscribe</h1>\n");
1019 body.push_str("<p>Get new posts by email. Confirm once, and unsubscribe from any message.</p>\n");
1020 body.push_str("<form class=\"aside-subscribe\" id=\"aside-subscribe-form\" method=\"post\" action=\"");
1021 escape_attr(&mut body, &cfg.subscribe_path());
1022 body.push_str("\">\n<label for=\"aside-subscribe-email\">Email</label>\n");
1023 body.push_str("<input type=\"email\" name=\"email\" id=\"aside-subscribe-email\" \
1024 placeholder=\"you@example.com\" autocomplete=\"email\" required>\n");
1025 body.push_str("<button type=\"submit\" class=\"aside-subscribe-btn\">Subscribe</button>\n");
1026 body.push_str("</form>\n</article>\n");
1027 subscribe_page(cfg, "Subscribe", &body, HttpStatus::OK)
1028}
1029
1030/// The "check your inbox" answer to a sign-up, served whether the address was new, pending or already
1031/// confirmed -- so the form is never an oracle for whether an address is on the list.
1032pub fn subscribe_sent_page(cfg: &PublishConfig) -> HttpMessage {
1033 let body = subscribe_result(
1034 "Check your inbox",
1035 "If that address can receive mail, a confirmation link is on its way. Follow it to start \
1036 receiving posts. Nothing arrives until you do.",
1037 );
1038 subscribe_page(cfg, "Check your inbox", &body, HttpStatus::OK)
1039}
1040
1041/// The answer to a confirmation link followed: the address is now on the list.
1042///
1043/// The same page whether the link was fresh or followed a second time, so a double-click is not an
1044/// error to a reader who did nothing wrong.
1045pub fn subscribe_confirmed_page(cfg: &PublishConfig) -> HttpMessage {
1046 let who = if cfg.site_name.trim().is_empty() {
1047 fmt!("this site")
1048 } else {
1049 cfg.site_name.clone()
1050 };
1051 let body = subscribe_result_home(
1052 "You are subscribed",
1053 &fmt!("That is it -- your address is confirmed, and {} will write to it now and then. \
1054 Every message carries a link that takes you off the list in one click, and the \
1055 address is not given to anyone else.", who),
1056 &cfg.home_or_index(),
1057 );
1058 subscribe_page(cfg, "Subscribed", &body, HttpStatus::OK)
1059}
1060
1061/// The answer to an unsubscribe link followed: no more mail reaches this address.
1062pub fn subscribe_unsubscribed_page(cfg: &PublishConfig) -> HttpMessage {
1063 let body = subscribe_result_home(
1064 "Unsubscribed",
1065 "You will receive no further posts at this address. You are welcome back any time from the \
1066 subscribe page.",
1067 &cfg.home_or_index(),
1068 );
1069 subscribe_page(cfg, "Unsubscribed", &body, HttpStatus::OK)
1070}
1071
1072/// The answer to a token that names nobody: malformed, already spent by a re-subscribe, or never real.
1073pub fn subscribe_bad_token_page(cfg: &PublishConfig) -> HttpMessage {
1074 let body = subscribe_result_home(
1075 "This link did not work",
1076 "That link is not one we recognise -- it may be old, or already used. Try subscribing again \
1077 if you meant to.",
1078 &cfg.home_or_index(),
1079 );
1080 subscribe_page(cfg, "Link not recognised", &body, HttpStatus::NotFound)
1081}
1082
1083/// The answer to an address the form will not take: it is not a shape an address wears.
1084pub fn subscribe_invalid_page(cfg: &PublishConfig) -> HttpMessage {
1085 let body = subscribe_result(
1086 "That does not look like an email",
1087 "Check the address and try again. It should look like you@example.com.",
1088 );
1089 subscribe_page(cfg, "Check the address", &body, HttpStatus::OK)
1090}
1091
1092/// The honest answer where mail is not configured on this host, or the site has no origin to build a
1093/// confirmation link from: signup is not available, rather than a pending row that can never confirm.
1094pub fn subscribe_unavailable_page(cfg: &PublishConfig) -> HttpMessage {
1095 let body = subscribe_result(
1096 "Signups are not available yet",
1097 "Email subscriptions are not set up on this site at the moment. Nothing has been recorded.",
1098 );
1099 subscribe_page(cfg, "Not available", &body, HttpStatus::OK)
1100}
1101
1102/// A titled paragraph, the body every subscription-result page shares.
1103fn subscribe_result(heading: &str, para: &str) -> String {
1104 subscribe_result_home(heading, para, "")
1105}
1106
1107/// As [`subscribe_result`], ending with a way back to the site.
1108///
1109/// A page reached from an email is the end of a road: the reader followed a link out of their
1110/// inbox, and without somewhere to go next they are left on a page with a sentence on it. Where the
1111/// site says where its front door is, the page offers it.
1112fn subscribe_result_home(heading: &str, para: &str, home: &str) -> String {
1113 let mut s = String::from("<article class=\"aside aside-subscribe-result\">\n<h1>");
1114 escape_text(&mut s, heading);
1115 s.push_str("</h1>\n<p>");
1116 escape_text(&mut s, para);
1117 s.push_str("</p>\n");
1118 if !home.is_empty() {
1119 s.push_str("<p class=\"aside-subscribe-home\"><a href=\"");
1120 escape_attr(&mut s, home);
1121 s.push_str("\">Back to the site</a></p>\n");
1122 }
1123 s.push_str("</article>\n");
1124 s
1125}
1126
1127/// Wraps a subscription page's body in the reader's own chrome, so the site's skin applies.
1128///
1129/// The same [`page`] wrapper the posts use, so a subscribe page is styled by the site's stylesheets
1130/// exactly as a post is, with no card metadata -- these are not shareable articles.
1131fn subscribe_page(cfg: &PublishConfig, title: &str, body: &str, status: HttpStatus) -> HttpMessage {
1132 let head = Head {
1133 title: title.to_string(),
1134 description: String::new(),
1135 url: cfg.url_of(&cfg.subscribe_path()),
1136 kind: "website",
1137 date: None,
1138 };
1139 html_response(status, &page(cfg, &head, body, None, false))
1140}
1141
1142/// An HTML response with the type and status a browser expects.
1143///
1144/// Never held: an index is stale the moment a post is published, and a post page
1145/// the moment it is edited or commented on.
1146fn html_response(status: HttpStatus, body: &str) -> HttpMessage {
1147 let mut resp = HttpMessage::respond_with_text(status, body);
1148 resp = resp.with_field(
1149 HeaderName::ContentType,
1150 HeaderFieldValue::Generic(fmt!("text/html; charset=utf-8")),
1151 );
1152 cache::generated(resp)
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157 use super::*;
1158
1159 use oxedyne_fe2o3_net::http::header::HttpHeadline;
1160
1161 fn status_of(resp: &HttpMessage) -> Option<HttpStatus> {
1162 match &resp.header.headline {
1163 HttpHeadline::Response { status } => Some(status.clone()),
1164 _ => None,
1165 }
1166 }
1167
1168 fn cfg() -> PublishConfig {
1169 PublishConfig {
1170 path: fmt!("/asides"),
1171 dir: fmt!("/nonexistent"),
1172 source: Source::Dir,
1173 title: fmt!("Asides"),
1174 site_name: fmt!("Elearnity"),
1175 base_url: fmt!("https://example.com"),
1176 css: vec![fmt!("/css/a.css")],
1177 creds: Default::default(),
1178 comments: true,
1179 comment_rate_secs: 0,
1180 comment_rate_hourly: 0,
1181 subscribe_rate_secs: 0,
1182 subscribe_rate_hourly: 0,
1183 newsletter_from: String::new(),
1184 categories: vec![fmt!("Personal"), fmt!("Technical")],
1185 default_author: String::new(),
1186 logo: String::new(),
1187 home: String::new(),
1188 // A site in a declaration scheme, so the marks the tests below look for can be drawn at all.
1189 declare: declare::DeclareConfig {
1190 url: fmt!("https://example.org"),
1191 marks: fmt!("/assets/marks"),
1192 site: Some(declare::Declaration::new(declare::Level::With, declare::Medium::Code)),
1193 items: Vec::new(),
1194 },
1195 }
1196 }
1197
1198 fn post() -> Post {
1199 Post {
1200 slug: fmt!("on-rent"),
1201 title: fmt!("On rent"),
1202 author: fmt!("jason"),
1203 categories: vec![fmt!("Personal")],
1204 date: Some(fmt!("2026-07-17")),
1205 words: 420,
1206 excerpt: fmt!("An opening sentence."),
1207 html: fmt!("<h1>On rent</h1>\n<p>An opening sentence.</p>\n"),
1208 also_on: Vec::new(),
1209 tags: Vec::new(),
1210 ai_level: None,
1211 }
1212 }
1213
1214 /// A post's page carries the tags a card is built from, an absolute canonical URL, and the prose
1215 /// itself in the response rather than a promise of it.
1216 #[test]
1217 fn test_a_post_page_carries_its_card_00() -> Outcome<()> {
1218 let resp = res!(post_page(&cfg(), &post(), None, None));
1219 let body = String::from_utf8_lossy(&resp.body).to_string();
1220 assert!(body.contains("<title>On rent — Elearnity</title>"), "got: {}", body);
1221 assert!(body.contains(r#"<meta property="og:type" content="article">"#), "got: {}", body);
1222 assert!(body.contains(r#"<meta property="og:title" content="On rent">"#), "got: {}", body);
1223 assert!(body.contains(r#"<meta property="og:url" content="https://example.com/asides/on-rent">"#),
1224 "got: {}", body);
1225 assert!(body.contains(r#"<link rel="canonical" href="https://example.com/asides/on-rent">"#),
1226 "got: {}", body);
1227 assert!(body.contains(r#"<link rel="stylesheet" href="/css/a.css">"#), "got: {}", body);
1228 assert!(body.contains("<p>An opening sentence.</p>"), "the prose is not in the page: {}", body);
1229 assert!(body.contains(r#"<time datetime="2026-07-17">"#), "got: {}", body);
1230 // A post sent nowhere carries no "also on" nav.
1231 assert!(!body.contains("aside-also"), "an unsent post should have no backfeed: {}", body);
1232 Ok(())
1233 }
1234
1235 /// A post that has been syndicated carries an "also on" backlink to each remote it reached, as a
1236 /// nofollow link that opens away from the page.
1237 #[test]
1238 fn test_a_syndicated_post_backlinks_02() -> Outcome<()> {
1239 use crate::srv::publish::dest::Destination;
1240 let mut p = post();
1241 p.also_on = vec![
1242 (Destination::Mastodon, fmt!("https://mastodon.social/@me/1")),
1243 (Destination::Bluesky, fmt!("https://bsky.app/profile/did:plc:x/post/3k")),
1244 ];
1245 let resp = res!(post_page(&cfg(), &p, None, None));
1246 let body = String::from_utf8_lossy(&resp.body).to_string();
1247 assert!(body.contains("Also on"), "no backfeed label: {}", body);
1248 assert!(body.contains(r#"href="https://mastodon.social/@me/1""#), "no Mastodon link: {}", body);
1249 assert!(body.contains(">Mastodon</a>"), "no Mastodon name: {}", body);
1250 assert!(body.contains(">Bluesky</a>"), "no Bluesky name: {}", body);
1251 assert!(body.contains(r#"rel="nofollow noopener""#), "backlinks should be nofollow: {}", body);
1252 Ok(())
1253 }
1254
1255 /// A title that would close the block it sits in does not close it. `</script>` in prose is a
1256 /// title an author may plausibly write, and the escape must survive the trip into JSON-LD.
1257 #[test]
1258 fn test_a_hostile_title_cannot_break_out_01() -> Outcome<()> {
1259 let mut p = post();
1260 p.title = fmt!(r#"</script><img src=x onerror=alert(1)>"#);
1261 p.excerpt = fmt!(r#"a " quote and an <b>"#);
1262 let resp = res!(post_page(&cfg(), &p, None, None));
1263 let body = String::from_utf8_lossy(&resp.body).to_string();
1264 // The JSON-LD block ends exactly once, where it should.
1265 assert_eq!(body.matches("</script>").count(), 1, "script block broken out of: {}", body);
1266 assert!(body.contains(r#"</script>"#), "title not escaped for JSON: {}", body);
1267 // And nothing reached an attribute unescaped.
1268 assert!(!body.contains(r#"content="a " quote"#), "attribute broken out of: {}", body);
1269 assert!(body.contains("&quot;"), "got: {}", body);
1270 Ok(())
1271 }
1272
1273 /// The index is a stream of cards, each a post: its title a link to the post's own page, its date
1274 /// and reading time, a clipped preview of the formatted prose with the opening heading dropped so
1275 /// the title is not said twice, and a "Read more" that is a plain link to the post -- this is a
1276 /// multi-page site, so the whole read is a page, not an overlay.
1277 #[test]
1278 fn test_the_index_draws_a_card_per_post_02() -> Outcome<()> {
1279 let posts = vec![post()];
1280 let resp = res!(index(&cfg(), &posts, &[], "", "test"));
1281 let body = String::from_utf8_lossy(&resp.body).to_string();
1282 assert_eq!(status_of(&resp), Some(HttpStatus::OK));
1283 // Each post is a card whose title links to its own page.
1284 assert!(body.contains(r#"<article class="aside-card" data-author=""#), "no post card: {}", body);
1285 assert!(body.contains(r#"<h2 class="aside-card-title"><a href="/asides/on-rent">On rent</a></h2>"#),
1286 "no title link: {}", body);
1287 // The reading time is shown, not just carried as a datum for the filter.
1288 assert!(body.contains(r#"<span class="aside-read">3 min read</span>"#), "no reading time: {}", body);
1289 // The card carries a clipped preview of the prose, its opening heading dropped so the card's
1290 // own title is not doubled.
1291 assert!(body.contains(r#"<div class="aside-card-preview">"#), "no preview window: {}", body);
1292 assert!(body.contains("<p>An opening sentence.</p>"), "the preview did not carry the prose: {}", body);
1293 assert!(!body.contains("<h1>On rent</h1>"), "the leading heading was not stripped: {}", body);
1294 // Read more is a plain link to the post's own page, not a button into an overlay.
1295 assert!(body.contains(r#"<a class="aside-readmore" href="/asides/on-rent">Read more</a>"#),
1296 "no Read more link: {}", body);
1297 // The card carries the facts the filter matches on.
1298 assert!(body.contains(r#"data-read-mins="3""#), "no reading-time datum: {}", body);
1299 assert!(body.contains(r#"data-categories="Personal""#), "no category datum: {}", body);
1300 // And the filter's own script is linked, once.
1301 assert_eq!(body.matches("/asides/filter.js").count(), 1, "filter script not linked once: {}", body);
1302 Ok(())
1303 }
1304
1305 /// The reader is two columns -- the posts on the left, the filter on the right -- with a toggle
1306 /// that folds the filter away on a narrow screen. The whole is one enhancement: without the
1307 /// script the panel is a plain column and every post shows.
1308 #[test]
1309 fn test_the_index_is_two_columns_21() -> Outcome<()> {
1310 let resp = res!(index(&cfg(), &[post()], &[], "", "test"));
1311 let body = String::from_utf8_lossy(&resp.body).to_string();
1312 assert!(body.contains(r#"<div class="aside-layout">"#), "no two-column layout: {}", body);
1313 assert!(body.contains(r#"<aside class="aside-side" id="aside-side">"#), "no filter column: {}", body);
1314 assert!(body.contains(r#"<div class="aside-main">"#), "no posts column: {}", body);
1315 // The toggle that unfolds the filter on a phone, wired to the panel it opens.
1316 assert!(body.contains(concat!(
1317 r#"<button type="button" class="aside-filter-toggle" id="aside-filter-toggle" "#,
1318 r#"aria-controls="aside-side" aria-expanded="false">Filter</button>"#)),
1319 "no filter toggle: {}", body);
1320 Ok(())
1321 }
1322
1323 /// The reading-time slider is always present and always drawable: over the posts' own range where
1324 /// they vary, and floored at one minute up to the longest where they read alike, so the row is
1325 /// never a slider that cannot move.
1326 #[test]
1327 fn test_the_time_filter_is_always_a_slider_20() -> Outcome<()> {
1328 // One post at two minutes: a slider from one to two, not a dead figure.
1329 let one = res!(index(&cfg(), &[post()], &[], "", "test"));
1330 let body = String::from_utf8_lossy(&one.body).to_string();
1331 assert!(body.contains(r#"id="aside-time-min""#), "no slider for one post: {}", body);
1332 assert!(!body.contains("aside-filter-time-single"), "the single-figure fallback returned: {}", body);
1333 assert!(body.contains(r#"data-lo="1" data-hi="3""#), "the one-post range was not floored: {}", body);
1334
1335 // Two posts of different lengths: the slider spans their own range.
1336 let mut short = post();
1337 short.slug = fmt!("short");
1338 short.words = 100;
1339 let mut long = post();
1340 long.slug = fmt!("long");
1341 long.words = 2000;
1342 let two = res!(index(&cfg(), &[short, long], &[], "", "test"));
1343 let body = String::from_utf8_lossy(&two.body).to_string();
1344 assert!(body.contains(r#"data-lo="1" data-hi="10""#), "the range is not the posts' own: {}", body);
1345 Ok(())
1346 }
1347
1348 /// An index with nothing in it says so, rather than being a blank page that looks broken.
1349 ///
1350 /// And it says the right one of two things. A blog with no posts is empty; a filter that has
1351 /// excluded every post is not, and a reader who has narrowed too far needs to be told to widen
1352 /// rather than that the blog has nothing in it. So there are two lines: the server draws the first
1353 /// and decides whether it shows, the script shows the second and never touches the first.
1354 #[test]
1355 fn test_an_empty_index_says_so_04() -> Outcome<()> {
1356 let resp = res!(index(&cfg(), &[], &[], "", "test"));
1357 let body = String::from_utf8_lossy(&resp.body).to_string();
1358 assert!(body.contains(r#"<p class="aside-empty" id="aside-empty">Nothing here yet.</p>"#),
1359 "an empty blog does not say it is empty: {}", body);
1360 // The filter's line is there and hidden, whether or not there is anything to filter.
1361 assert!(body.contains(
1362 r#"<p class="aside-empty" id="aside-none" hidden>Nothing matches that.</p>"#),
1363 "no line for a filter that matches nothing: {}", body);
1364
1365 // With posts, the blog-is-empty line is hidden and the other still waits.
1366 let resp = res!(index(&cfg(), &[post()], &[], "", "test"));
1367 let body = String::from_utf8_lossy(&resp.body).to_string();
1368 assert!(body.contains(r#"<p class="aside-empty" id="aside-empty" hidden>Nothing here yet.</p>"#),
1369 "a blog with a post claims to be empty: {}", body);
1370 assert!(body.contains(r#"id="aside-none" hidden>Nothing matches that.</p>"#),
1371 "no line for a filter that matches nothing: {}", body);
1372
1373 // And the script shows the second without ever touching the first, which is the whole of the
1374 // distinction: it reveals `aside-none` only where there were posts to exclude.
1375 let js = String::from_utf8_lossy(&filter_js().body).to_string();
1376 assert!(js.contains(r#"getElementById("aside-none")"#), "the script does not find the line: {}", js);
1377 assert!(js.contains("none.hidden = !(rows.length && shown === 0)"),
1378 "the script does not reveal it on an empty result: {}", js);
1379 assert!(!js.contains("aside-empty"),
1380 "the script still moves the blog-is-empty line, so a filter reads as an empty blog: {}", js);
1381 Ok(())
1382 }
1383
1384 /// A trailing slash is the same place, and is sent to the canonical spelling of it.
1385 ///
1386 /// `/asides/` answering `404` while `/asides` rendered told a reader who typed the slash -- or
1387 /// followed a link that carried one -- that the blog did not exist.
1388 #[test]
1389 fn test_a_trailing_slash_is_the_same_place_29() -> Outcome<()> {
1390 let c = cfg();
1391 let posts = vec![post()];
1392 let at = |path: &str, query: &str| -> Outcome<HttpMessage> {
1393 handle_get(&c, &posts, &[], path, query, None, "test")
1394 };
1395 let to = |resp: &HttpMessage| -> Option<String> {
1396 resp.header.fields.get_one(&HeaderName::Location).map(|v| fmt!("{}", v))
1397 };
1398
1399 // The index, and the post, each by the spelling with the slash on it.
1400 let resp = res!(at("/asides/", ""));
1401 assert_eq!(status_of(&resp), Some(HttpStatus::MovedPermanently));
1402 assert_eq!(to(&resp).as_deref(), Some("/asides"));
1403 let resp = res!(at("/asides/on-rent/", ""));
1404 assert_eq!(status_of(&resp), Some(HttpStatus::MovedPermanently));
1405 assert_eq!(to(&resp).as_deref(), Some("/asides/on-rent"));
1406 // More than one slash is still the one place, and does not bounce twice.
1407 assert_eq!(to(&res!(at("/asides///", ""))).as_deref(), Some("/asides"));
1408
1409 // A query is carried across, so a chip link with a slash on the path still lands narrowed.
1410 assert_eq!(to(&res!(at("/asides/", "cat=Big+Ideas"))).as_deref(),
1411 Some("/asides?cat=Big+Ideas"));
1412 // But nothing that could break the header out of its line: this value came off the request
1413 // line and is going into a `Location`.
1414 assert_eq!(to(&res!(at("/asides/", "a=b\r\nX-Evil: 1"))).as_deref(), Some("/asides"),
1415 "a query with a line break in it reached a response header");
1416
1417 // And the canonical spellings are untouched: still the page, not a redirect.
1418 let resp = res!(at("/asides", ""));
1419 assert_eq!(status_of(&resp), Some(HttpStatus::OK));
1420 assert!(to(&resp).is_none(), "the index became a redirect");
1421 let resp = res!(at("/asides/on-rent", ""));
1422 assert_eq!(status_of(&resp), Some(HttpStatus::OK));
1423 assert!(to(&resp).is_none(), "a post became a redirect");
1424 Ok(())
1425 }
1426
1427 /// A slug that could climb out of a directory never reaches one: it is not a name a post may wear,
1428 /// so the lookup refuses it before anything else looks at it.
1429 #[test]
1430 fn test_a_hostile_slug_is_refused_05() -> Outcome<()> {
1431 let posts = vec![post()];
1432 for bad in ["../../etc/passwd", "..", "a.b", "a%2Fb"] {
1433 let path = fmt!("/asides/{}", bad);
1434 let resp = res!(handle_get(&cfg(), &posts, &[], &path, "", None, "test"));
1435 assert_eq!(status_of(&resp), Some(HttpStatus::NotFound), "'{}' was not refused", bad);
1436 }
1437 Ok(())
1438 }
1439
1440 /// A post carries its facets: categories first as `.post-cat`, then tags as `.tag`, each a link
1441 /// narrowing the index. A post with neither carries no `.post-tags` element at all.
1442 #[test]
1443 fn test_a_post_page_carries_its_tags_06() -> Outcome<()> {
1444 let mut p = post();
1445 p.tags = vec![fmt!("rust"), fmt!("web")];
1446 let resp = res!(post_page(&cfg(), &p, None, None));
1447 let body = String::from_utf8_lossy(&resp.body).to_string();
1448 assert!(body.contains(r#"<ul class="post-tags">"#), "no tag list: {}", body);
1449 assert!(body.contains(r#"<a class="tag" href="/asides?tag=rust">rust</a>"#), "got: {}", body);
1450 assert!(body.contains(r#"<a class="tag" href="/asides?tag=web">web</a>"#), "got: {}", body);
1451 // The category leads, and wears the other class -- the two are different kinds of thing
1452 // and a reader must be able to see which is which.
1453 assert!(body.contains(r#"<a class="post-cat" href="/asides?cat=Personal">Personal</a>"#),
1454 "no category chip: {}", body);
1455 let cat_at = res!(body.find("post-cat").ok_or_else(|| err!("no category chip"; Missing)));
1456 let tag_at = res!(body.find(r#"class="tag""#).ok_or_else(|| err!("no tag chip"; Missing)));
1457 assert!(cat_at < tag_at, "the tags came before the categories: {}", body);
1458
1459 // A category holding a space or a capital survives the query it is put into.
1460 let mut spaced = post();
1461 spaced.categories = vec![fmt!("Big Ideas")];
1462 spaced.tags = Vec::new();
1463 let resp = res!(post_page(&cfg(), &spaced, None, None));
1464 let body = String::from_utf8_lossy(&resp.body).to_string();
1465 assert!(body.contains(r#"href="/asides?cat=Big+Ideas""#), "unencoded category: {}", body);
1466 assert!(body.contains(">Big Ideas</a>"), "the chip does not read as written: {}", body);
1467
1468 // A post with neither has no empty shell.
1469 let mut bare = post();
1470 bare.categories = Vec::new();
1471 bare.tags = Vec::new();
1472 let resp = res!(post_page(&cfg(), &bare, None, None));
1473 let body = String::from_utf8_lossy(&resp.body).to_string();
1474 assert!(!body.contains("post-tags"),
1475 "a post with no categories and no tags drew the element: {}", body);
1476 Ok(())
1477 }
1478
1479 /// The index renders the whole list and the filter above it: every post is present, whatever its
1480 /// tags, and the filter offers the vocabulary the posts hold. Narrowing is the reader's, in the
1481 /// browser, so the server draws the tools and all the posts and never a slice.
1482 #[test]
1483 fn test_the_index_renders_the_filter_07() -> Outcome<()> {
1484 let mut a = post();
1485 a.slug = fmt!("tagged");
1486 a.title = fmt!("Tagged");
1487 a.tags = vec![fmt!("rust")];
1488 let mut b = post();
1489 b.slug = fmt!("untagged");
1490 b.title = fmt!("Untagged");
1491 b.tags = Vec::new();
1492 let posts = vec![a, b];
1493
1494 let author = Author {
1495 username: fmt!("jason"),
1496 handle: fmt!("h-jason"),
1497 name: fmt!("Jason"),
1498 avatar: String::new(),
1499 bio: fmt!("Notes on rent, housing and what follows."),
1500 };
1501 let resp = res!(index(&cfg(), &posts, &[author], "", "test"));
1502 let body = String::from_utf8_lossy(&resp.body).to_string();
1503
1504 // Both posts are on the page: the server narrows nothing.
1505 assert!(body.contains(">Tagged</a>"), "the tagged post is missing: {}", body);
1506 assert!(body.contains(">Untagged</a>"), "the untagged post is missing: {}", body);
1507 // The filter is there: the search box, the mode radios, the two chip boxes, and the tag from the
1508 // posts sits in the selected box by default.
1509 assert!(body.contains(r#"id="aside-filter-search""#), "no search box: {}", body);
1510 assert!(body.contains(r#"value="includes" checked"#), "Includes is not the default mode: {}", body);
1511 assert!(body.contains(r#"<div class="aside-chips aside-chips-selected" data-box="selected" data-facet="tag" role="list">"#),
1512 "no selected tag box: {}", body);
1513 assert!(body.contains(r#"data-facet="tag" data-value="rust""#), "the tag is not a chip: {}", body);
1514 // A multi-word category rides in data-categories comma-joined, so the filter's comma-split keeps
1515 // it whole rather than tearing "Big Ideas" into "Big" and "Ideas".
1516 let mut c = post();
1517 c.slug = fmt!("multi");
1518 c.categories = vec![fmt!("Big Ideas"), fmt!("Personal")];
1519 let resp = res!(index(&cfg(), &[c], &[], "", "test"));
1520 let cbody = String::from_utf8_lossy(&resp.body).to_string();
1521 assert!(cbody.contains(r#"data-categories="Big Ideas,Personal""#),
1522 "multi-word category not comma-joined: {}", cbody);
1523
1524 // The author drew a face with an initial, since the fixture set no avatar.
1525 // The face carries the author's public handle; the login username is nowhere on the page.
1526 assert!(body.contains(r#"data-author="h-jason""#), "no author face: {}", body);
1527 assert!(!body.contains(r#"data-author="jason""#), "the login username reached the page: {}", body);
1528 assert!(body.contains("aside-author-initial"), "no drawn initial for an avatarless author: {}", body);
1529 // The categories are chips of the same make, drawn from the config.
1530 assert!(body.contains(r#"data-facet="cat" data-value="Personal""#),
1531 "no category chip: {}", body);
1532 Ok(())
1533 }
1534
1535 /// Search is a facet like the vocabularies: a heading, an Include / Only / Exclude row named for
1536 /// its own group, and the box in the site's search look with an inline magnifier -- no icon file
1537 /// to ship, so no broken square where one was forgotten. The mode labels read in the singular.
1538 #[test]
1539 fn test_the_search_is_a_facet_22() -> Outcome<()> {
1540 let resp = res!(index(&cfg(), &[post()], &[], "", "test"));
1541 let body = String::from_utf8_lossy(&resp.body).to_string();
1542 assert!(body.contains(r#"<div class="aside-facet aside-facet-search" data-facet="search">"#),
1543 "no search facet: {}", body);
1544 assert!(body.contains(r#"name="aside-search-mode" value="includes" checked"#),
1545 "search has no default mode: {}", body);
1546 assert!(body.contains(r#"name="aside-search-mode" value="only""#), "no Only mode: {}", body);
1547 assert!(body.contains(r#"name="aside-search-mode" value="excludes""#), "no Exclude mode: {}", body);
1548 // The labels are singular throughout: the search row and the vocabularies' rows.
1549 assert!(body.contains(">Include</label>"), "the mode label is not singular: {}", body);
1550 assert!(body.contains(">Only</label>"), "no Only label: {}", body);
1551 assert!(body.contains(">Exclude</label>"), "the mode label is not singular: {}", body);
1552 assert!(!body.contains(">Includes</label>"), "a plural mode label survived: {}", body);
1553 assert!(!body.contains(">Excludes</label>"), "a plural mode label survived: {}", body);
1554 // The box, with an inline magnifier rather than an <img> to a file.
1555 assert!(body.contains(r#"<div class="search-box aside-search-box">"#), "no search box: {}", body);
1556 assert!(body.contains("<svg class=\"aside-search-ico\""), "no inline magnifier: {}", body);
1557 assert!(!body.contains("search.svg"), "the search box referenced an asset file: {}", body);
1558 assert!(body.contains(r#"id="aside-filter-search""#), "no search input: {}", body);
1559 Ok(())
1560 }
1561
1562 /// The reading-time slider carries a rail and a filled span the script paints between the thumbs,
1563 /// so the chosen band reads at a glance rather than being inferred from two dots.
1564 #[test]
1565 fn test_the_slider_carries_a_fill_23() -> Outcome<()> {
1566 let resp = res!(index(&cfg(), &[post()], &[], "", "test"));
1567 let body = String::from_utf8_lossy(&resp.body).to_string();
1568 assert!(body.contains(r#"<div class="aside-time-rail"></div>"#), "no slider rail: {}", body);
1569 assert!(body.contains(r#"<div class="aside-time-fill" id="aside-time-fill"></div>"#),
1570 "no slider fill: {}", body);
1571 Ok(())
1572 }
1573
1574 /// Every author face starts pressed -- the default is all of them, shown selected the way each
1575 /// vocabulary starts with every chip in Selected -- and the intro boxes carry the author's public
1576 /// handle, so the script can narrow the intros to the selected authors and never a login username.
1577 #[test]
1578 fn test_the_authors_start_selected_24() -> Outcome<()> {
1579 let one = Author {
1580 username: fmt!("jason"),
1581 handle: fmt!("h-jason"),
1582 name: fmt!("Jason"),
1583 avatar: String::new(),
1584 bio: fmt!("Notes on rent and what follows."),
1585 };
1586 let resp = res!(index(&cfg(), &[post()], std::slice::from_ref(&one), "", "test"));
1587 let body = String::from_utf8_lossy(&resp.body).to_string();
1588 assert!(body.contains(r#"class="aside-author" data-author="h-jason" title="Jason" aria-pressed="true""#),
1589 "the author face does not start pressed: {}", body);
1590 // The intro box carries the handle for the script, never the login username.
1591 assert!(body.contains(r#"<div class="aside-about-who" data-author="h-jason">"#),
1592 "the intro box carries no handle: {}", body);
1593 assert!(!body.contains(r#"data-author="jason""#), "the login username reached the page: {}", body);
1594 Ok(())
1595 }
1596
1597 /// A site that configures a mark and a front page gets its logo at the top of the index, leading to
1598 /// the front page rather than to the page the reader is already on.
1599 #[test]
1600 fn test_the_mark_is_the_site_logo_25() -> Outcome<()> {
1601 let mut c = cfg();
1602 c.logo = fmt!("/assets/logo.svg");
1603 c.home = fmt!("https://example.com");
1604 let resp = res!(index(&c, &[post()], &[], "", "test"));
1605 let body = String::from_utf8_lossy(&resp.body).to_string();
1606 assert!(body.contains(r#"<a class="aside-home" href="https://example.com">"#),
1607 "the mark does not lead to the front page: {}", body);
1608 assert!(body.contains(r#"<img class="aside-logo" src="/assets/logo.svg" alt="Elearnity">"#),
1609 "the mark is not the logo: {}", body);
1610 // The index is the page it would lead to, so it draws no second link back to itself.
1611 assert!(!body.contains("aside-back"), "the index links back to itself: {}", body);
1612 Ok(())
1613 }
1614
1615 /// A post keeps its way back to the list. The mark now leads off to the site's front page, so the
1616 /// link the mark used to be is drawn beside it rather than lost.
1617 #[test]
1618 fn test_a_post_keeps_its_way_back_to_the_list_26() -> Outcome<()> {
1619 let mut c = cfg();
1620 c.logo = fmt!("/assets/logo.svg");
1621 c.home = fmt!("https://example.com");
1622 let resp = res!(post_page(&c, &post(), None, None));
1623 let body = String::from_utf8_lossy(&resp.body).to_string();
1624 assert!(body.contains(r#"<a class="aside-home" href="https://example.com">"#),
1625 "the post has no mark: {}", body);
1626 assert!(body.contains(r#"<a class="aside-back" href="/asides">Asides</a>"#),
1627 "the post has no way back to the list: {}", body);
1628 Ok(())
1629 }
1630
1631 /// Writes the reader's own screens to a directory, for looking at.
1632 ///
1633 /// The counterpart of the console's `ui_dump`, and it exists for the same reason: two invisible
1634 /// defects in this module were found by rendering it and none by reading it. Off unless
1635 /// `STEEL_UI_DUMP` names a directory, so it costs an ordinary test run nothing.
1636 ///
1637 /// The pages reference the site's own stylesheets by URL, so open the dump from a directory that
1638 /// serves them, or point a harness's `css` at where they really are.
1639 #[test]
1640 fn ui_dump_the_reader_screens() -> Outcome<()> {
1641 let dir = match std::env::var("STEEL_UI_DUMP") {
1642 Ok(d) if !d.is_empty() => d,
1643 _ => return Ok(()), // Not asked for; costs nothing.
1644 };
1645 res!(std::fs::create_dir_all(&dir), IO, File);
1646
1647 let put = |name: &str, resp: &HttpMessage| -> Outcome<()> {
1648 let path = fmt!("{}/{}.html", dir, name);
1649 res!(std::fs::write(&path, &resp.body), IO, File);
1650 println!("ui-dump: {}", path);
1651 Ok(())
1652 };
1653
1654 // The stylesheets a site really links, so the dump is the site's own look rather than
1655 // unstyled markup. A harness serves these paths from wherever it keeps them.
1656 let cfg = PublishConfig {
1657 css: vec![
1658 fmt!("/css/variables.css"),
1659 fmt!("/css/blog.css"),
1660 fmt!("/css/marks.css"),
1661 ],
1662 ..cfg()
1663 };
1664
1665 // One post per rung, so every mark in the set is on one page and the five can be told apart
1666 // by eye -- which is the only test of a mark that means anything.
1667 let mut posts = Vec::new();
1668 for (i, level) in declare::Level::ALL.iter().enumerate() {
1669 let mut p = post();
1670 p.slug = fmt!("post-{}", i);
1671 p.title = fmt!("{}", level.words());
1672 p.ai_level = Some(*level);
1673 posts.push(p);
1674 }
1675 // And one that declares nothing, which must draw no mark at all.
1676 let mut bare = post();
1677 bare.slug = fmt!("post-undeclared");
1678 bare.title = fmt!("Undeclared");
1679 posts.push(bare);
1680
1681 res!(put("reader-index", &res!(index(&cfg, &posts, &[], "", "ui-dump"))));
1682 res!(put("reader-post", &res!(post_page(&cfg, &posts[2], None, None))));
1683 res!(put("reader-post-undeclared", &res!(post_page(&cfg, &posts[5], None, None))));
1684 Ok(())
1685 }
1686
1687 /// A declared post wears its mark where a reader is already being told what the piece is: in the
1688 /// meta line, immediately after the reading time. Alone, at one size, on the card and on the post
1689 /// itself -- the words would say the same thing on every card in the list, and the mark is drawn
1690 /// large enough to be read without them.
1691 #[test]
1692 fn test_a_declared_post_wears_its_mark_by_the_reading_time_28() -> Outcome<()> {
1693 let mut p = post();
1694 p.ai_level = Some(declare::Level::Some);
1695
1696 for (what, body) in [
1697 ("the post", String::from_utf8_lossy(
1698 &res!(post_page(&cfg(), &p, None, None)).body).to_string()),
1699 ("the card", String::from_utf8_lossy(
1700 &res!(index(&cfg(), &[p.clone()], &[], "", "test")).body).to_string()),
1701 ] {
1702 assert!(body.contains("doc-some-ai.svg"), "{}: wrong artwork: {}", what, body);
1703 assert!(body.contains(&fmt!("--ai-mark-size:{}px", declare::MARK_SIZE_PX)),
1704 "{}: the mark is not drawn at the one size: {}", what, body);
1705 // No words anywhere near it: the mark carries the level by itself at this size.
1706 assert!(!body.contains("ai-mark-words"), "{}: the mark carried words: {}", what, body);
1707 }
1708
1709 // Placement, said as an order rather than as a count of elements. On a post the mark follows
1710 // the reading time in the line they share; on a card it follows the way in to the piece,
1711 // because at a readable size it towers over a line of small meta text.
1712 let post_body = String::from_utf8_lossy(
1713 &res!(post_page(&cfg(), &p, None, None)).body).to_string();
1714 let read_at = res!(post_body.find("aside-read")
1715 .ok_or_else(|| err!("the post has no reading time to sit beside"; Missing)));
1716 let mark_at = res!(post_body.find("ai-mark")
1717 .ok_or_else(|| err!("the post drew no mark"; Missing)));
1718 assert!(read_at < mark_at, "the post's mark came before its reading time: {}", post_body);
1719 let line_end = res!(post_body[read_at..].find("</div>")
1720 .ok_or_else(|| err!("the post's meta line never closed"; Missing)));
1721 assert!(mark_at - read_at < line_end,
1722 "the post's mark fell outside the line its reading time is on: {}", post_body);
1723
1724 let card_body = String::from_utf8_lossy(
1725 &res!(index(&cfg(), &[p], &[], "", "test")).body).to_string();
1726 let card_read_at = res!(card_body.find("aside-read")
1727 .ok_or_else(|| err!("the card has no reading time to sit beside"; Missing)));
1728 let card_mark_at = res!(card_body.find("ai-mark")
1729 .ok_or_else(|| err!("the card drew no mark"; Missing)));
1730 assert!(card_read_at < card_mark_at,
1731 "the card's mark came before its reading time: {}", card_body);
1732 // And in the head, not down in the foot: the same place the post keeps it.
1733 let head_end = res!(card_body.find("aside-card-preview")
1734 .ok_or_else(|| err!("the card has no preview to mark the end of its head"; Missing)));
1735 assert!(card_mark_at < head_end,
1736 "the card's mark fell out of its head: {}", card_body);
1737 Ok(())
1738 }
1739
1740 /// A post whose author declared nothing wears no mark at all. Nothing else would do: the bottom
1741 /// rung is a claim, and putting it on somebody's prose unasked is the one output this must never
1742 /// produce.
1743 #[test]
1744 fn test_an_undeclared_post_wears_no_mark_29() -> Outcome<()> {
1745 let p = post();
1746 assert!(p.ai_level.is_none(), "the fixture was declared, so this proves nothing");
1747 let resp = res!(post_page(&cfg(), &p, None, None));
1748 let body = String::from_utf8_lossy(&resp.body).to_string();
1749 assert!(!body.contains("ai-mark"), "an undeclared post was given a mark: {}", body);
1750 assert!(!body.contains("doc-no-ai.svg"), "an undeclared post was drawn as declaring none: {}", body);
1751
1752 let resp = res!(index(&cfg(), &[p], &[], "", "test"));
1753 let body = String::from_utf8_lossy(&resp.body).to_string();
1754 assert!(!body.contains("ai-mark"), "an undeclared post's card drew a mark: {}", body);
1755 Ok(())
1756 }
1757
1758 /// The site's own declaration is not the blog's business. It says something about the whole site,
1759 /// belongs in the site's own furniture, and repeating it under every post said the same sentence
1760 /// on every page a reader opened. The configured site declaration is still read -- it reaches the
1761 /// front page through `declare.json` -- it is simply not drawn here.
1762 #[test]
1763 fn test_the_blog_does_not_carry_the_sites_own_declaration_30() -> Outcome<()> {
1764 let c = cfg();
1765 assert!(c.declare.site.is_some(), "the fixture declares nothing, so this proves nothing");
1766 let resp = res!(post_page(&c, &post(), None, None));
1767 let body = String::from_utf8_lossy(&resp.body).to_string();
1768 assert!(!body.contains("aside-foot"), "a post carried the site's own declaration: {}", body);
1769 assert!(!body.contains("code-with-ai.svg"), "a post carried the site's own mark: {}", body);
1770 Ok(())
1771 }
1772
1773 /// A site naming neither a mark nor a front page keeps the single line it always had, leading to
1774 /// the index, with no second link duplicating it. What it says is **the site's name**, not what
1775 /// the site calls its posts -- the link beside it already says the latter, and a site setting
1776 /// `home` was printing the same word twice, side by side, going to two different places.
1777 #[test]
1778 fn test_an_unconfigured_mark_is_the_site_name_27() -> Outcome<()> {
1779 let resp = res!(post_page(&cfg(), &post(), None, None));
1780 let body = String::from_utf8_lossy(&resp.body).to_string();
1781 assert!(body.contains(r#"<nav class="aside-nav"><a class="aside-home" href="/asides">Elearnity</a></nav>"#),
1782 "the unconfigured mark is not the site's name: {}", body);
1783 assert!(!body.contains("aside-logo"), "a logo was drawn for a site with none: {}", body);
1784 Ok(())
1785 }
1786
1787 /// Categories and tags are the same instrument twice: one block each, categories first, each with
1788 /// its own mode row and its own pair of boxes, every value starting in Selected.
1789 #[test]
1790 fn test_the_filter_draws_a_block_per_facet_17() -> Outcome<()> {
1791 let mut p = post();
1792 p.tags = vec![fmt!("web"), fmt!("rust")];
1793 let resp = res!(index(&cfg(), &[p], &[], "", "test"));
1794 let body = String::from_utf8_lossy(&resp.body).to_string();
1795
1796 // Both blocks, and the coarser vocabulary leads.
1797 let cat_at = res!(body.find(r#"<div class="aside-facet" data-facet="cat">"#)
1798 .ok_or_else(|| err!("no category block"; Missing)));
1799 let tag_at = res!(body.find(r#"<div class="aside-facet" data-facet="tag">"#)
1800 .ok_or_else(|| err!("no tag block"; Missing)));
1801 assert!(cat_at < tag_at, "the tags came before the categories: {}", body);
1802
1803 // A mode row each, named apart so the two do not share a selection.
1804 assert!(body.contains(r#"name="aside-mode-cat" value="includes" checked"#),
1805 "no category mode row: {}", body);
1806 assert!(body.contains(r#"name="aside-mode-tag" value="includes" checked"#),
1807 "no tag mode row: {}", body);
1808 assert!(body.contains(r#"aria-label="Categories match""#), "the mode row is unlabelled: {}", body);
1809
1810 // A source box each, empty: everything starts selected, since a filter's default is to hide
1811 // nothing.
1812 assert_eq!(body.matches(r#"data-box="source""#).count(), 2, "not two source boxes: {}", body);
1813 assert!(body.contains(r#"data-facet="cat" role="list"></div>"#), "the category source box is not empty: {}", body);
1814
1815 // The chips themselves: the label and the closer with nothing between them, since a text node
1816 // there is a gap the stylesheet never asked for.
1817 assert!(body.contains(concat!(
1818 r#"<button type="button" class="aside-chip aside-chip-tag" draggable="true" "#,
1819 r#"data-facet="tag" data-value="rust" role="listitem">"#,
1820 r#"<span class="aside-chip-lbl">rust</span>"#,
1821 r#"<span class="aside-chip-x" aria-hidden="true">&#215;</span></button>"#)),
1822 "the tag chip is not as the contract draws it: {}", body);
1823
1824 // The tags are sorted, nobody having chosen an order for them; the categories keep the order the
1825 // site wrote them in, which somebody did choose.
1826 let rust_at = res!(body.find(r#"data-value="rust""#).ok_or_else(|| err!("no rust chip"; Missing)));
1827 let web_at = res!(body.find(r#"data-value="web""#).ok_or_else(|| err!("no web chip"; Missing)));
1828 assert!(rust_at < web_at, "the tags are not sorted: {}", body);
1829 let pers_at = res!(body.find(r#"data-value="Personal""#)
1830 .ok_or_else(|| err!("no Personal chip"; Missing)));
1831 let tech_at = res!(body.find(r#"data-value="Technical""#)
1832 .ok_or_else(|| err!("no Technical chip"; Missing)));
1833 assert!(pers_at < tech_at, "the configured order of the categories was not kept: {}", body);
1834 Ok(())
1835 }
1836
1837 /// A category is a free string the site chose, so a space and a capital reach the chip whole -- in
1838 /// the value the script matches on and in the words the reader sees.
1839 #[test]
1840 fn test_a_spaced_category_survives_into_a_chip_18() -> Outcome<()> {
1841 let mut c = cfg();
1842 c.categories = vec![fmt!("Big Ideas")];
1843 let resp = res!(index(&c, &[post()], &[], "", "test"));
1844 let body = String::from_utf8_lossy(&resp.body).to_string();
1845 assert!(body.contains(r#"data-facet="cat" data-value="Big Ideas""#),
1846 "the category was mangled into its chip: {}", body);
1847 assert!(body.contains(r#"<span class="aside-chip-lbl">Big Ideas</span>"#),
1848 "the chip does not read as written: {}", body);
1849 Ok(())
1850 }
1851
1852 /// A facet block is drawn only where a post carries a value in that family -- both families, on the
1853 /// same rule.
1854 ///
1855 /// A config's categories are a vocabulary the site may file under, not a claim that anything is
1856 /// filed. Offered against posts wearing none, every chip narrows the list to nothing, which is a
1857 /// row of controls that can only disappoint. The tags were suppressed on that reasoning and the
1858 /// categories were not, so a blog whose posts carried neither drew the whole configured taxonomy
1859 /// over the same nothing the tags declined to draw anything over.
1860 #[test]
1861 fn test_a_facet_block_needs_a_post_that_wears_one_19() -> Outcome<()> {
1862 // A post wearing neither: neither block.
1863 let mut bare = post();
1864 bare.categories = Vec::new();
1865 bare.tags = Vec::new();
1866 let resp = res!(index(&cfg(), &[bare.clone()], &[], "", "test"));
1867 let body = String::from_utf8_lossy(&resp.body).to_string();
1868 assert!(!body.contains(r#"data-facet="cat""#),
1869 "the configured taxonomy was offered over posts that wear none of it: {}", body);
1870 assert!(!body.contains(r#"data-facet="tag""#),
1871 "a tag block was drawn for a site with no tags: {}", body);
1872
1873 // One post filed under a category: the block, and the whole configured vocabulary in it. What
1874 // the site offers is what a reader may narrow to, not only what this one post happens to wear.
1875 let filed = post();
1876 assert!(!filed.categories.is_empty(), "the fixture stopped carrying a category");
1877 let resp = res!(index(&cfg(), &[filed], &[], "", "test"));
1878 let body = String::from_utf8_lossy(&resp.body).to_string();
1879 assert!(body.contains(r#"data-facet="cat" data-value="Personal""#), "no category block: {}", body);
1880 assert!(body.contains(r#"data-facet="cat" data-value="Technical""#),
1881 "an unworn category was dropped from the vocabulary: {}", body);
1882
1883 // One post carrying a tag: the tag block, and no category block, the same rule the other way.
1884 let mut c = cfg();
1885 c.categories = Vec::new();
1886 let mut tagged = bare;
1887 tagged.tags = vec![fmt!("rust")];
1888 let resp = res!(index(&c, &[tagged], &[], "", "test"));
1889 let body = String::from_utf8_lossy(&resp.body).to_string();
1890 assert!(!body.contains(r#"data-facet="cat""#),
1891 "a category block was drawn for a site with no categories: {}", body);
1892 assert!(body.contains(r#"data-facet="tag" data-value="rust""#), "no tag block: {}", body);
1893 Ok(())
1894 }
1895
1896 /// What the site is about stands above the posts, in the words of whoever writes it. On a blog of
1897 /// one, the description carries no name over it, since the name is on every post already; where
1898 /// more than one person writes, each description is attributed.
1899 #[test]
1900 fn test_the_index_says_what_the_site_is_about_13() -> Outcome<()> {
1901 let one = Author {
1902 username: fmt!("jason"),
1903 handle: fmt!("h-jason"),
1904 name: fmt!("Jason"),
1905 avatar: String::new(),
1906 bio: fmt!("Notes on rent and what follows."),
1907 };
1908 let resp = res!(index(&cfg(), &[post()], std::slice::from_ref(&one), "", "test"));
1909 let body = String::from_utf8_lossy(&resp.body).to_string();
1910 assert!(body.contains("aside-about"), "no description above the posts: {}", body);
1911 assert!(body.contains("Notes on rent and what follows."), "the description is not shown: {}", body);
1912 assert!(!body.contains("aside-about-name"), "a lone author was named over their own line: {}", body);
1913 // The description leads the posts column: whatever the filter does off to the side, what the
1914 // site is about is met above the stream. (The filter is its own column now, drawn first in the
1915 // source so a phone meets its toggle before scrolling in; ordering it against the about would
1916 // assert the column layout, not the reading order that matters here.)
1917 let about = body.find("aside-about").unwrap_or(usize::MAX);
1918 let list = body.find("aside-index-list").unwrap_or(0);
1919 assert!(about < list, "the description does not lead the posts: {}", body);
1920
1921 // A second author, and each description carries a name.
1922 let two = Author {
1923 username: fmt!("ada"),
1924 handle: fmt!("h-ada"),
1925 name: fmt!("Ada"),
1926 avatar: fmt!("/asides/avatar/ada"),
1927 bio: fmt!("Writes about machines."),
1928 };
1929 let resp = res!(index(&cfg(), &[post()], &[one, two], "", "test"));
1930 let body = String::from_utf8_lossy(&resp.body).to_string();
1931 assert!(body.contains("aside-about-name"), "two authors and no names: {}", body);
1932 assert!(body.contains("Writes about machines."), "the second description is missing: {}", body);
1933 assert!(body.contains(r#"<img class="aside-author-pic" alt="" src="/asides/avatar/ada">"#),
1934 "an uploaded picture is not drawn: {}", body);
1935
1936 // A blog whose first post is not written yet still says what it will be about: the description
1937 // stands on an empty index, and the filter offers no face, there being nothing to narrow.
1938 let one = Author {
1939 username: fmt!("jason"),
1940 handle: fmt!("h-jason"),
1941 name: fmt!("Jason"),
1942 avatar: String::new(),
1943 bio: fmt!("Notes on rent and what follows."),
1944 };
1945 let resp = res!(index(&cfg(), &[], std::slice::from_ref(&one), "", "test"));
1946 let body = String::from_utf8_lossy(&resp.body).to_string();
1947 assert!(body.contains("Notes on rent and what follows."),
1948 "an empty blog said nothing about itself: {}", body);
1949 assert!(!body.contains("aside-filter-authors"),
1950 "a face was offered for an author with no posts: {}", body);
1951
1952 // An author who has written no description draws no panel at all, rather than an empty one.
1953 let bare = Author {
1954 username: fmt!("mel"),
1955 handle: fmt!("h-mel"),
1956 name: fmt!("Mel"),
1957 avatar: String::new(),
1958 bio: String::new(),
1959 };
1960 let resp = res!(index(&cfg(), &[post()], &[bare], "", "test"));
1961 let body = String::from_utf8_lossy(&resp.body).to_string();
1962 assert!(!body.contains("aside-about"), "an empty description drew a panel: {}", body);
1963 Ok(())
1964 }
1965
1966 /// A post carries a byline above the prose and, where its author has written a description, a note
1967 /// about them beneath it. A post whose author is unknown carries neither rather than a blank.
1968 #[test]
1969 fn test_a_post_says_who_wrote_it_14() -> Outcome<()> {
1970 let a = Author {
1971 username: fmt!("jason"),
1972 handle: fmt!("h-jason"),
1973 name: fmt!("Jason Hoogland"),
1974 avatar: String::new(),
1975 bio: fmt!("Notes on rent."),
1976 };
1977 let resp = res!(post_page(&cfg(), &post(), Some(&a), None));
1978 let body = String::from_utf8_lossy(&resp.body).to_string();
1979 assert!(body.contains("aside-byline"), "no byline: {}", body);
1980 assert!(body.contains(r#"<span class="aside-byline-name">Jason Hoogland</span>"#),
1981 "the byline names nobody: {}", body);
1982 assert!(body.contains("aside-author-note"), "no note about the author: {}", body);
1983 assert!(body.contains("Notes on rent."), "the description is not under the post: {}", body);
1984 // The byline is above the prose and the note below it.
1985 let byline = body.find("aside-byline").unwrap_or(usize::MAX);
1986 let prose = body.find("<h1>On rent</h1>").unwrap_or(usize::MAX);
1987 let note = body.find("aside-author-note").unwrap_or(usize::MAX);
1988 assert!(byline < prose && prose < note, "the post is out of order: {}", body);
1989
1990 // An author with nothing written about them keeps the byline and drops the note.
1991 let quiet = Author { bio: String::new(), ..a.clone() };
1992 let resp = res!(post_page(&cfg(), &post(), Some(&quiet), None));
1993 let body = String::from_utf8_lossy(&resp.body).to_string();
1994 assert!(body.contains("aside-byline"), "the byline went with the description: {}", body);
1995 assert!(!body.contains("aside-author-note"), "an empty description drew a note: {}", body);
1996
1997 // A post whose author could not be resolved carries neither.
1998 let resp = res!(post_page(&cfg(), &post(), None, None));
1999 let body = String::from_utf8_lossy(&resp.body).to_string();
2000 assert!(!body.contains("aside-byline"), "an unattributed post drew a byline: {}", body);
2001 Ok(())
2002 }
2003
2004 /// A name a reader could put in a byline is escaped everywhere it is drawn, since a display name is
2005 /// whatever a member typed into their own profile.
2006 #[test]
2007 fn test_a_hostile_profile_cannot_break_out_15() -> Outcome<()> {
2008 let a = Author {
2009 username: fmt!("jason"),
2010 handle: fmt!("h-jason"),
2011 name: fmt!(r#"<img src=x onerror=alert(1)>"#),
2012 avatar: fmt!(r#""onload="alert(1)"#),
2013 bio: fmt!(r#"</p><script>alert(1)</script>"#),
2014 };
2015 let resp = res!(index(&cfg(), &[post()], std::slice::from_ref(&a), "", "test"));
2016 let body = String::from_utf8_lossy(&resp.body).to_string();
2017 assert!(!body.contains("<img src=x"), "a name reached the page as markup: {}", body);
2018 assert!(!body.contains("<script>alert(1)</script>"), "a description reached the page as markup: {}",
2019 body);
2020 assert!(!body.contains(r#"src=""onload="#), "an avatar broke out of its attribute: {}", body);
2021
2022 let resp = res!(post_page(&cfg(), &post(), Some(&a), None));
2023 let body = String::from_utf8_lossy(&resp.body).to_string();
2024 assert!(!body.contains("<img src=x"), "a name reached the post as markup: {}", body);
2025 assert!(!body.contains("<script>alert(1)</script>"), "a description reached the post as markup: {}",
2026 body);
2027 Ok(())
2028 }
2029
2030 /// A member's uploaded picture is served from this module, under the site's own prefix.
2031 #[test]
2032 fn test_a_picture_has_a_path_of_its_own_16() -> Outcome<()> {
2033 let c = cfg();
2034 assert_eq!(c.avatar_path("abc123"), "/asides/avatar/abc123");
2035 assert_eq!(c.avatar_prefix(), "/asides/avatar/");
2036 // It is not a post, so a request for one is never counted as a read.
2037 assert!(served_post(&c, &[post()], &c.avatar_path("abc123")).is_none(),
2038 "a picture counted as a post");
2039 Ok(())
2040 }
2041
2042
2043 /// A post says how long it takes to read, beside its date and above the prose, so a reader deciding
2044 /// whether to start is told before rather than after.
2045 #[test]
2046 fn test_a_post_says_how_long_it_takes_to_read_09() -> Outcome<()> {
2047 let resp = res!(post_page(&cfg(), &post(), None, None));
2048 let body = String::from_utf8_lossy(&resp.body).to_string();
2049 // 420 words at 200 a minute rounds up to 3.
2050 assert!(body.contains(r#"<span class="aside-read">3 min read</span>"#), "got: {}", body);
2051
2052 // A piece shorter than a minute is still a minute, since "0 min read" tells a reader nothing.
2053 let mut p = post();
2054 p.words = 12;
2055 let resp = res!(post_page(&cfg(), &p, None, None));
2056 let body = String::from_utf8_lossy(&resp.body).to_string();
2057 assert!(body.contains("1 min read"), "a short post lost its minute: {}", body);
2058
2059 // A post whose words were never counted says nothing rather than "0 min read".
2060 p.words = 0;
2061 let resp = res!(post_page(&cfg(), &p, None, None));
2062 let body = String::from_utf8_lossy(&resp.body).to_string();
2063 assert!(!body.contains("aside-read"), "an uncounted post claimed a reading time: {}", body);
2064 assert!(body.contains(r#"<time datetime="2026-07-17">"#), "the date went with it: {}", body);
2065 Ok(())
2066 }
2067
2068 /// A post that is not there is still a page, with a way back to the ones that are.
2069 #[test]
2070 fn test_a_missing_post_is_a_page_03() -> Outcome<()> {
2071 let resp = not_found(&cfg());
2072 let body = String::from_utf8_lossy(&resp.body).to_string();
2073 assert_eq!(status_of(&resp), Some(HttpStatus::NotFound));
2074 assert!(body.contains(r#"<a href="/asides">Asides</a>"#), "no way back: {}", body);
2075 Ok(())
2076 }
2077
2078 /// Only a post that was actually served is a post that was read.
2079 ///
2080 /// The index, the feed and the JSON are not posts: counting a browse of the index as a read of
2081 /// everything listed on it would make the tally meaningless in exactly the direction that
2082 /// flatters.
2083 #[test]
2084 fn test_only_a_served_post_is_counted_08() -> Outcome<()> {
2085 let mut a = post();
2086 a.slug = fmt!("here");
2087 let posts = vec![a];
2088 let c = cfg();
2089
2090 assert_eq!(served_post(&c, &posts, "/asides/here").map(|p| p.slug.as_str()), Some("here"));
2091
2092 // Not posts.
2093 assert!(served_post(&c, &posts, "/asides").is_none(), "the index counted as a read");
2094 assert!(served_post(&c, &posts, &c.feed_path()).is_none(), "the feed counted as a read");
2095 assert!(served_post(&c, &posts, &c.json_path()).is_none(), "the JSON counted as a read");
2096
2097 // A post that does not exist was not served, and a path that is not a name never reaches a
2098 // lookup -- the same guard the renderer applies.
2099 assert!(served_post(&c, &posts, "/asides/nonesuch").is_none());
2100 assert!(served_post(&c, &posts, "/asides/../../etc/passwd").is_none());
2101 assert!(served_post(&c, &posts, "/asides/").is_none());
2102 Ok(())
2103 }
2104
2105 /// Nothing this module renders may be served from a store unasked.
2106 ///
2107 /// A page describes the site at the instant it was asked for, and the next thing an author writes
2108 /// changes it. Carrying no directive it is not merely uncached: RFC 9111 4.2.2 lets a store invent
2109 /// a lifetime for it, and the author then has to force a refresh to see their own post -- which is
2110 /// not something a reader would ever think to do. The scripts are the exception and say so: the
2111 /// same bytes for every site, changing only when this server does.
2112 #[test]
2113 fn test_nothing_rendered_here_is_held_28() -> Outcome<()> {
2114 let c = cfg();
2115 cache::assert_not_held(&res!(index(&c, &[post()], &[], "", "test")), "the index");
2116 cache::assert_not_held(&res!(index(&c, &[], &[], "", "test")), "an empty index");
2117 cache::assert_not_held(&res!(post_page(&c, &post(), None, None)), "a post");
2118 cache::assert_not_held(&not_found(&c), "a post that does not exist");
2119
2120 // The scripts are held on purpose, which is the other half of the rule.
2121 for (what, resp) in [("the filter script", filter_js()), ("the comment script", comment_js())] {
2122 let held = res!(resp.header.fields.get_one(&HeaderName::CacheControl).ok_or_else(||
2123 err!("{} carried no cache directive.", what; Missing)));
2124 assert!(fmt!("{}", held).contains("max-age="), "{} is not held: {}", what, held);
2125 }
2126 Ok(())
2127 }
2128}
2129
2130
2131// ┌───────────────────────────────────────────────────────────────────────────┐
2132// │ COMMENTS │
2133// └───────────────────────────────────────────────────────────────────────────┘
2134
2135/// What a post's page needs to draw its conversation.
2136///
2137/// Assembled by the caller, which holds the database; the rendering here takes data and touches
2138/// nothing, on the same terms as the rest of this module.
2139pub struct CommentsView {
2140 pub threads: Vec<Thread>, // approved, threaded, in the ranker's order
2141 pub count: usize, // how many comments, at every depth
2142 pub page: usize, // which page of the conversation, from one
2143 pub pages: usize,
2144 pub order: &'static str, // which order the reader asked for
2145 pub path: String, // the post's own path, for the pager's and the ordering's links
2146 pub challenge: String, // what a sender's proof must answer
2147 pub said: Option<String>, // what the last attempt said, where one has just been made
2148 pub open: bool, // whether the site is taking comments at all
2149 pub editable: Option<(String, String)>, // the comment this reader may correct, and its token
2150}
2151
2152/// The conversation below a post, and the form to join it.
2153pub fn comments_section(cfg: &PublishConfig, post: &Post, view: &CommentsView) -> String {
2154 let mut s = String::new();
2155 s.push_str("<section class=\"comments\" id=\"comments\">\n");
2156 s.push_str(&fmt!("<h2 class=\"comments-h\">{}</h2>\n", match view.count {
2157 0 => fmt!("No comments yet"),
2158 1 => fmt!("One comment"),
2159 n => fmt!("{} comments", n),
2160 }));
2161
2162 // What the last attempt said, where there was one. Shown at the top, because it is the answer to
2163 // something the reader just did and they should not have to hunt for it.
2164 if let Some(said) = &view.said {
2165 s.push_str("<p class=\"comments-said\">");
2166 escape_text(&mut s, said);
2167 s.push_str("</p>\n");
2168 }
2169
2170 if !view.threads.is_empty() {
2171 // The ordering, where there is more than one comment to order.
2172 if view.count > 1 {
2173 s.push_str(&comments_order(view));
2174 }
2175 s.push_str("<ol class=\"comment-list\">\n");
2176 for t in &view.threads {
2177 s.push_str(&thread_item(t, 0, &view.path, &view.editable));
2178 }
2179 s.push_str("</ol>\n");
2180 s.push_str(&comments_pager(view));
2181 }
2182
2183 if view.open {
2184 s.push_str(&comment_form(cfg, post, view, None));
2185 } else {
2186 // Said once, whether or not there is a conversation above it: a reader looking for the form
2187 // should learn why it is not there rather than assume the page is broken.
2188 s.push_str("<p class=\"comments-shut\">Comments are closed on this post.</p>\n");
2189 }
2190
2191 s.push_str("</section>\n");
2192 s
2193}
2194
2195/// The order the conversation is read in.
2196///
2197/// Two links rather than a form, so it works with nothing running and a reader can share the URL of
2198/// the view they are looking at.
2199fn comments_order(view: &CommentsView) -> String {
2200 let mut s = String::from("<nav class=\"comment-order\">");
2201 for (i, (key, label)) in [("oldest", "Oldest first"), ("newest", "Newest first")]
2202 .iter().enumerate()
2203 {
2204 // A separator in the markup, not only in a stylesheet. This module leaves the look to the
2205 // site, but a site that has not styled this yet should still read as two choices rather than
2206 // as one run-together word -- which is what it did.
2207 if i > 0 {
2208 s.push_str(" <span class=\"comment-order-sep\">&middot;</span> ");
2209 }
2210 if view.order == *key {
2211 s.push_str(&fmt!("<span class=\"comment-order-on\">{}</span>", label));
2212 } else {
2213 s.push_str("<a href=\"");
2214 escape_attr(&mut s, &fmt!("{}?order={}#comments", view.path, key));
2215 s.push_str("\">");
2216 s.push_str(label);
2217 s.push_str("</a>");
2218 }
2219 }
2220 s.push_str("</nav>\n");
2221 s
2222}
2223
2224fn comments_pager(view: &CommentsView) -> String {
2225 if view.pages <= 1 {
2226 return String::new();
2227 }
2228 let link = |p: usize, label: &str, s: &mut String| {
2229 s.push_str("<a href=\"");
2230 escape_attr(s, &fmt!("{}?order={}&cpage={}#comments", view.path, view.order, p));
2231 s.push_str("\">");
2232 s.push_str(label);
2233 s.push_str("</a>");
2234 };
2235 let mut s = String::from("<nav class=\"comment-pager\">");
2236 if view.page > 1 {
2237 link(view.page - 1, "Earlier comments", &mut s);
2238 }
2239 s.push_str(&fmt!("<span class=\"comment-pager-at\">Page {} of {}</span>", view.page, view.pages));
2240 if view.page < view.pages {
2241 link(view.page + 1, "More comments", &mut s);
2242 }
2243 s.push_str("</nav>\n");
2244 s
2245}
2246
2247fn thread_item(
2248 t: &Thread,
2249 depth: usize,
2250 path: &str,
2251 editable: &Option<(String, String)>,
2252)
2253 -> String
2254{
2255 let mut s = String::new();
2256 s.push_str(&fmt!("<li class=\"comment\" id=\"c-{}\">\n", esc_id(&t.comment.id)));
2257
2258 s.push_str("<div class=\"comment-by\">");
2259 s.push_str("<span class=\"comment-who\">");
2260 escape_text(&mut s, t.comment.author.display_name());
2261 s.push_str("</span>");
2262 // A commenter may call themselves anything, including the name of the person whose site this
2263 // is. Nothing can stop them typing it, so the site says which comments it wrote instead: an
2264 // absent mark is the claim, not the name. Only an approved comment can carry it, and only where
2265 // the site's own admin wrote it.
2266 if t.comment.by_site_author {
2267 s.push_str(" <span class=\"comment-author-mark\" title=\"Written by the author of this site\">author</span>");
2268 }
2269 if !t.comment.created.is_empty() {
2270 s.push_str(" <time class=\"comment-when\" datetime=\"");
2271 escape_attr(&mut s, &t.comment.created);
2272 s.push_str("\">");
2273 escape_text(&mut s, &t.comment.created[..10.min(t.comment.created.len())]);
2274 s.push_str("</time>");
2275 }
2276 s.push_str("</div>\n");
2277
2278 // The prose, already brought within the policy by `Comment::render`. A comment whose source will
2279 // not parse shows as its own words rather than vanishing: it is still what somebody said.
2280 s.push_str("<div class=\"comment-body\">");
2281 match t.comment.render() {
2282 Ok(html) => s.push_str(&html),
2283 Err(_) => {
2284 s.push_str("<p>");
2285 escape_text(&mut s, &t.comment.body);
2286 s.push_str("</p>");
2287 }
2288 }
2289 s.push_str("</div>\n");
2290
2291 // A reply link, down to the depth the module threads. Below that a reader replies to the parent,
2292 // which is where the flattened comment already sits.
2293 if depth + 1 < DEPTH_MAX {
2294 s.push_str(&fmt!(
2295 "<a class=\"comment-reply\" href=\"#comment-form\" data-reply-to=\"{id}\" \
2296 data-reply-name=\"{who}\">Reply</a>\n",
2297 id = esc_id(&t.comment.id),
2298 who = {
2299 let mut a = String::new();
2300 escape_attr(&mut a, t.comment.author.display_name());
2301 a
2302 },
2303 ));
2304 }
2305
2306 // The author's own way to correct what they just wrote, shown only to whoever holds the token
2307 // for this comment and only while the window stands.
2308 if let Some((cid, token)) = editable {
2309 if *cid == t.comment.id {
2310 s.push_str(&edit_form(path, &t.comment, token));
2311 }
2312 }
2313
2314 if !t.replies.is_empty() {
2315 s.push_str("<ol class=\"comment-replies\">\n");
2316 for r in &t.replies {
2317 s.push_str(&thread_item(r, depth + 1, path, editable));
2318 }
2319 s.push_str("</ol>\n");
2320 }
2321 s.push_str("</li>\n");
2322 s
2323}
2324
2325fn edit_form(path: &str, c: &crate::srv::publish::comment::Comment, token: &str) -> String {
2326 let mut s = String::new();
2327 s.push_str("<details class=\"comment-edit\"><summary>Correct this</summary>\n");
2328 s.push_str("<form method=\"POST\" action=\"");
2329 escape_attr(&mut s, &fmt!("{}/comment/edit", path));
2330 s.push_str("\">\n");
2331 s.push_str("<input type=\"hidden\" name=\"id\" value=\"");
2332 escape_attr(&mut s, &c.id);
2333 s.push_str("\">\n<input type=\"hidden\" name=\"token\" value=\"");
2334 escape_attr(&mut s, token);
2335 s.push_str("\">\n<textarea name=\"body\" rows=\"5\">");
2336 escape_text(&mut s, &c.body);
2337 s.push_str("</textarea>\n");
2338 s.push_str("<p class=\"comment-note\">A comment that has already been published goes back to \
2339 the author to look at again.</p>\n");
2340 s.push_str("<button type=\"submit\">Save the correction</button>\n</form>\n</details>\n");
2341 s
2342}
2343
2344/// The form for writing one.
2345///
2346/// Three things a reader does not see and one they do. The honeypot is a field a person cannot fill
2347/// because it is not shown, so anything in it was put there by something filling every field it
2348/// found. The challenge and the nonce are the proof: the browser spends about a second finding a
2349/// nonce, which costs a reader nothing they notice and costs a machine posting ten thousand comments
2350/// ten thousand seconds. The parent is which comment is being answered.
2351fn comment_form(cfg: &PublishConfig, post: &Post, view: &CommentsView, parent: Option<&str>) -> String {
2352 let mut s = String::new();
2353 s.push_str("<form class=\"comment-form\" id=\"comment-form\" method=\"POST\" action=\"");
2354 escape_attr(&mut s, &cfg.comment_path(&post.slug));
2355 s.push_str("\">\n");
2356 s.push_str("<h3 class=\"comment-form-h\">Leave a comment</h3>\n");
2357
2358 // Where a reply is being written, said plainly, with a way out of it.
2359 s.push_str("<p class=\"comment-replying\" id=\"comment-replying\" hidden>Replying to \
2360 <span id=\"comment-replying-who\"></span> \
2361 <a href=\"#comment-form\" id=\"comment-reply-cancel\">(cancel)</a></p>\n");
2362 // Escaped like every other value here. The sole caller passes None today, which is exactly why
2363 // this was missed -- and `parent` is attacker-supplied, so the moment somebody wires it up an
2364 // unescaped value is an attribute breakout.
2365 s.push_str("<input type=\"hidden\" name=\"parent\" id=\"comment-parent\" value=\"");
2366 escape_attr(&mut s, parent.unwrap_or(""));
2367 s.push_str("\">\n");
2368 s.push_str("<input type=\"hidden\" name=\"challenge\" id=\"comment-challenge\" value=\"");
2369 escape_attr(&mut s, &view.challenge);
2370 s.push_str("\">\n");
2371 s.push_str("<input type=\"hidden\" name=\"nonce\" id=\"comment-nonce\" value=\"\">\n");
2372 s.push_str(&fmt!("<input type=\"hidden\" name=\"bits\" value=\"{}\">\n", POW_BITS));
2373
2374 // The honeypot. Hidden from a person by every means at once -- off-screen, no tab stop, told to
2375 // assistive technology that it is not for them -- and left in the markup for anything that reads
2376 // the markup rather than the page.
2377 // The hiding is an inline style and not a class, deliberately. This module does not own the site's
2378 // stylesheet -- a site brings its own -- so a class here is a rule that may never exist, and a
2379 // honeypot a reader can see is a field they will fill in and have their comment silently refused
2380 // for. Measured in a browser: with only a class, it rendered as an ordinary visible input.
2381 s.push_str("<div class=\"comment-hp\" aria-hidden=\"true\" \
2382 style=\"position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden\">\
2383 <label for=\"comment-website\">Website</label>\
2384 <input type=\"text\" id=\"comment-website\" name=\"website\" tabindex=\"-1\" \
2385 autocomplete=\"off\"></div>\n");
2386
2387 s.push_str("<div class=\"comment-fields\">\n");
2388 s.push_str("<label class=\"comment-lbl\" for=\"comment-name\">Name\
2389 <input type=\"text\" id=\"comment-name\" name=\"name\" maxlength=\"64\" required></label>\n");
2390 // The address is optional, and what it is for is said where it is asked for rather than in a
2391 // policy page nobody opens.
2392 s.push_str("<label class=\"comment-lbl\" for=\"comment-email\">Email \
2393 <span class=\"comment-hint\">optional, never shown or shared</span>\
2394 <input type=\"email\" id=\"comment-email\" name=\"email\" autocomplete=\"email\"></label>\n");
2395 s.push_str("</div>\n");
2396
2397 s.push_str(&fmt!("<label class=\"comment-lbl\" for=\"comment-body\">Comment\
2398 <textarea id=\"comment-body\" name=\"body\" rows=\"6\" maxlength=\"{}\" required></textarea>\
2399 </label>\n", crate::srv::publish::comment::BODY_MAX));
2400 s.push_str("<p class=\"comment-note\">Markdown works. Links are kept, images and scripts are not. \
2401 A first comment waits for the author to see it.</p>\n");
2402
2403 s.push_str("<button type=\"button\" class=\"comment-preview-btn\" id=\"comment-preview-btn\" \
2404 data-to=\"");
2405 escape_attr(&mut s, &cfg.comment_preview_path(&post.slug));
2406 s.push_str("\">Preview</button>\n");
2407 s.push_str("<div class=\"comment-preview\" id=\"comment-preview\" hidden></div>\n");
2408 s.push_str("<button type=\"submit\" class=\"comment-send\" id=\"comment-send\">Post comment</button>\n");
2409 s.push_str("<span class=\"comment-working\" id=\"comment-working\" hidden>Working…</span>\n");
2410 s.push_str("</form>\n");
2411 // Referenced, not inlined: see `comment_js_path`. `defer` because it only wires handlers, and a
2412 // form that works without it is the point -- a reader with no scripting posts a comment with no
2413 // proof, and the server holds it rather than refusing it.
2414 s.push_str("<script defer src=\"");
2415 escape_attr(&mut s, &cfg.comment_js_path());
2416 s.push_str("\"></script>\n");
2417 s
2418}
2419
2420/// A rendered preview, or the reason there is none.
2421///
2422/// HTML rather than JSON: what comes back is dropped straight into the page, and wrapping a fragment
2423/// in JSON only to unwrap it is a step that buys nothing.
2424pub fn comment_preview(html: Option<String>) -> HttpMessage {
2425 let body = html.unwrap_or_else(|| fmt!(
2426 "<p class=\"comment-preview-none\">Nothing to preview yet, or you have previewed very \
2427 recently.</p>"));
2428 let mut resp = HttpMessage::ok_respond_with_text(body);
2429 resp = resp.with_field(
2430 HeaderName::ContentType,
2431 HeaderFieldValue::Generic(fmt!("text/html; charset=utf-8")),
2432 );
2433 cache::generated(resp)
2434}
2435
2436/// Serves the comment form's script.
2437///
2438/// Cached hard: it is the same bytes for every post on every site, and it changes only when this
2439/// server does.
2440pub fn comment_js() -> HttpMessage {
2441 let mut resp = HttpMessage::ok_respond_with_text(COMMENT_JS.to_string());
2442 resp = resp.with_field(
2443 HeaderName::ContentType,
2444 HeaderFieldValue::Generic(fmt!("text/javascript; charset=utf-8")),
2445 );
2446 resp = resp.with_field(
2447 HeaderName::CacheControl,
2448 HeaderFieldValue::Generic(fmt!("public, max-age=86400")),
2449 );
2450 resp
2451}
2452
2453/// The index filter's script, served as a file. Static, cacheable, and CSP-friendly.
2454pub fn filter_js() -> HttpMessage {
2455 let mut resp = HttpMessage::ok_respond_with_text(FILTER_JS.to_string());
2456 resp = resp.with_field(
2457 HeaderName::ContentType,
2458 HeaderFieldValue::Generic(fmt!("text/javascript; charset=utf-8")),
2459 );
2460 resp = resp.with_field(
2461 HeaderName::CacheControl,
2462 HeaderFieldValue::Generic(fmt!("public, max-age=86400")),
2463 );
2464 resp
2465}
2466
2467/// An id, reduced to what may sit in one.
2468///
2469/// A comment's name is minted from a small alphabet so this changes nothing in practice; it is here
2470/// so that a record written by hand, or by a later version with a wider alphabet, cannot put anything
2471/// into an `id` attribute or a fragment that does not belong there.
2472fn esc_id(s: &str) -> String {
2473 s.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_').collect()
2474}
2475
2476// The script the form needs: the proof, and the reply wiring.
2477//
2478// Vanilla, inline, and small enough to read. It uses the browser's own SHA-256 -- the one strong
2479// digest every browser implements natively -- rather than carrying an implementation of its own,
2480// which is why the server verifies the proof with the same.
2481//
2482// **The form works without it**, which is the point of doing the proof on submit rather than
2483// gating the fields: a reader with no scripting posts a comment with no nonce, and the server holds
2484// it for a person instead of refusing it. The proof buys a queue that is not full of machines; it is
2485// not a condition of being heard.
2486const COMMENT_JS: &str = r#"(function () {
2487 var form = document.getElementById('comment-form');
2488 if (!form || !window.crypto || !window.crypto.subtle) return;
2489
2490 /* Replying: which comment, said plainly, and a way back out. */
2491 var parent = document.getElementById('comment-parent');
2492 var banner = document.getElementById('comment-replying');
2493 var who = document.getElementById('comment-replying-who');
2494 document.querySelectorAll('.comment-reply').forEach(function (a) {
2495 a.addEventListener('click', function () {
2496 parent.value = a.getAttribute('data-reply-to') || '';
2497 who.textContent = a.getAttribute('data-reply-name') || '';
2498 banner.hidden = false;
2499 });
2500 });
2501 var cancel = document.getElementById('comment-reply-cancel');
2502 if (cancel) cancel.addEventListener('click', function (ev) {
2503 ev.preventDefault();
2504 parent.value = '';
2505 banner.hidden = true;
2506 });
2507
2508 /* Preview: ask the server for the same rendering a reader would get, since the
2509 parser that matters is the one in Rust and there is not a second one here. */
2510 var pv = document.getElementById('comment-preview-btn');
2511 var pvOut = document.getElementById('comment-preview');
2512 if (pv && pvOut) pv.addEventListener('click', function () {
2513 var src = document.getElementById('comment-body').value;
2514 if (!src.trim()) return;
2515 var data = new URLSearchParams();
2516 data.set('body', src);
2517 pv.disabled = true;
2518 fetch(pv.getAttribute('data-to'), {
2519 method: 'POST',
2520 credentials: 'same-origin',
2521 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
2522 body: data.toString(),
2523 }).then(function (r) { return r.text(); })
2524 .then(function (html) { pvOut.innerHTML = html; pvOut.hidden = false; })
2525 .catch(function () { /* a preview that will not come is not an error worth a dialog */ })
2526 .then(function () { pv.disabled = false; });
2527 });
2528
2529 /* The proof. Count leading zero bits of SHA-256(challenge + nonce) until the
2530 width is met. Done on submit so a reader who never comments never pays. */
2531 function zeros(buf) {
2532 var b = new Uint8Array(buf), n = 0;
2533 for (var i = 0; i < b.length; i++) {
2534 if (b[i] === 0) { n += 8; continue; }
2535 var v = b[i], c = 0;
2536 while ((v & 0x80) === 0) { c++; v = (v << 1) & 0xff; }
2537 return n + c;
2538 }
2539 return n;
2540 }
2541
2542 var enc = new TextEncoder();
2543 form.addEventListener('submit', function (ev) {
2544 if (form.dataset.proved === '1') return; /* already done; let it go */
2545 ev.preventDefault();
2546 var challenge = document.getElementById('comment-challenge').value;
2547 var bits = parseInt(form.querySelector('input[name=bits]').value, 10) || 0;
2548 var send = document.getElementById('comment-send');
2549 var working = document.getElementById('comment-working');
2550 send.disabled = true;
2551 if (working) working.hidden = false;
2552
2553 var n = 0;
2554 function attempt() {
2555 /* A slice at a time, yielding between, so the page never locks up. */
2556 var deadline = Date.now() + 60;
2557 function step() {
2558 if (Date.now() > deadline) { setTimeout(attempt, 0); return; }
2559 crypto.subtle.digest('SHA-256', enc.encode(challenge + n)).then(function (d) {
2560 if (zeros(d) >= bits) {
2561 document.getElementById('comment-nonce').value = String(n);
2562 form.dataset.proved = '1';
2563 form.submit();
2564 return;
2565 }
2566 n++;
2567 step();
2568 });
2569 }
2570 step();
2571 }
2572 attempt();
2573 });
2574})();
2575"#;
2576
2577// The index filter. Reads the post list already in the page and shows or hides each item against the
2578// controls above it: a search box, the author faces, a facet block for each of categories and tags,
2579// and the reading-time slider. It renders nothing and fetches nothing -- every post is in the
2580// markup, and this only ever narrows what is seen.
2581//
2582// The two facet families run through one rule set rather than two. What differs between a category
2583// and a tag is which attribute an item carries its values in and what those values are joined on;
2584// everything after that -- the modes, the boxes, the dragging, the deep link -- is the same code
2585// twice over, which is the only way the two stay in step as either changes.
2586const FILTER_JS: &str = r##"(function () {
2587 "use strict";
2588 var list = document.getElementById("aside-index-list");
2589 if (!list) { return; }
2590 var items = Array.prototype.slice.call(list.querySelectorAll(".aside-card"));
2591 // The line for a filter that has narrowed to nothing. Not the one beside it: that one says the blog
2592 // is empty, which the server settled and this cannot change -- a reader who filtered too far has
2593 // prose in front of them, and telling them the blog is empty sends them away from the widening that
2594 // would bring it back.
2595 var none = document.getElementById("aside-none");
2596
2597 // The two facet families, and what tells them apart: which attribute a card carries its values in,
2598 // and what those values are joined on. A tag is [a-z0-9-] so a space separates them safely; a
2599 // category is a free string that may hold a space, so it is joined on a comma the config forbids
2600 // inside one. Nothing else below knows which family it is working on.
2601 var FAMILY = [
2602 { name: "cat", attr: "data-categories", sep: "," },
2603 { name: "tag", attr: "data-tags", sep: " " }
2604 ];
2605
2606 // Each card's filterable facts, read once from its data attributes.
2607 var rows = items.map(function (li) {
2608 var vals = {};
2609 FAMILY.forEach(function (f) {
2610 vals[f.name] = (li.getAttribute(f.attr) || "").split(f.sep).filter(Boolean);
2611 });
2612 return {
2613 el: li,
2614 author: li.getAttribute("data-author") || "",
2615 vals: vals,
2616 mins: parseInt(li.getAttribute("data-read-mins") || "0", 10),
2617 text: li.getAttribute("data-search") || ""
2618 };
2619 });
2620
2621 // The filter's state. Every field starts in the value that hides nothing.
2622 var search = "";
2623 var searchMode = "includes"; // include / only / exclude, over the typed query
2624 var authors = {}; // which authors are selected (all, by default)
2625 var authorOffered = {}; // which authors the filter offers a face for
2626 var facets = []; // one entry per family the page drew
2627 var tmin = -Infinity, tmax = Infinity;
2628
2629 function any(o) { for (var x in o) { if (o[x]) { return true; } } return false; }
2630
2631 // A query escaped for use inside a word-boundary regex, for the Only mode.
2632 function escRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
2633
2634 // Whether a card answers the search, in the mode the radios name: Include keeps the posts that
2635 // carry the words, Only those that carry them as a whole word, Exclude those that do not. An empty
2636 // box asks nothing.
2637 function passesSearch(r) {
2638 if (!search) { return true; }
2639 if (searchMode === "excludes") { return r.text.indexOf(search) === -1; }
2640 if (searchMode === "only") { return new RegExp("\\b" + escRe(search) + "\\b").test(r.text); }
2641 return r.text.indexOf(search) !== -1;
2642 }
2643
2644 // The blocks the page drew, one per family it had a vocabulary for. A site with no categories has
2645 // no category block, and this simply finds nothing.
2646 FAMILY.forEach(function (f) {
2647 var block = document.querySelector('.aside-facet[data-facet="' + f.name + '"]');
2648 if (!block) { return; }
2649 var st = {
2650 name: f.name,
2651 block: block,
2652 sel: block.querySelector('.aside-chips[data-box="selected"]'),
2653 src: block.querySelector('.aside-chips[data-box="source"]'),
2654 mode: "includes",
2655 chosen: {}, // values in the selected box
2656 offered: {} // every value the block holds, in either box
2657 };
2658 block.querySelectorAll(".aside-chip").forEach(function (c) {
2659 st.offered[c.getAttribute("data-value")] = true;
2660 });
2661 facets.push(st);
2662 });
2663
2664 // Which values of a family sit in its selected box, re-read after every move.
2665 function refresh(st) {
2666 st.chosen = {};
2667 if (!st.sel) { return; }
2668 st.sel.querySelectorAll(".aside-chip").forEach(function (c) {
2669 st.chosen[c.getAttribute("data-value")] = true;
2670 });
2671 }
2672 facets.forEach(refresh);
2673
2674 // Whether one card passes one family. Three rules hold whichever family it is: a post with no
2675 // values in it is not participating and always passes; an empty selected box imposes nothing; and
2676 // a value the block never offered -- a category the site has since dropped, still worn by an old
2677 // post -- cannot hide anything, which is why the comparison is against the offered set alone.
2678 function passesFacet(r, st) {
2679 var mine = [], v = r.vals[st.name] || [];
2680 for (var i = 0; i < v.length; i++) {
2681 if (st.offered[v[i]]) { mine.push(v[i]); }
2682 }
2683 if (!mine.length) { return true; }
2684 if (!any(st.chosen)) { return true; }
2685 var inter = 0, outside = 0;
2686 for (var j = 0; j < mine.length; j++) {
2687 if (st.chosen[mine[j]]) { inter++; } else { outside++; }
2688 }
2689 if (st.mode === "includes" && inter === 0) { return false; }
2690 if (st.mode === "only" && outside > 0) { return false; }
2691 if (st.mode === "excludes" && inter > 0) { return false; }
2692 return true;
2693 }
2694
2695 // Whether one card passes every control at once.
2696 function passes(r) {
2697 if (!passesSearch(r)) { return false; }
2698 // An author the filter offers but the reader has switched off hides that author's posts,
2699 // unless every author is off, which imposes nothing (an empty selection, like an empty chip
2700 // box). A post by an author with no face is never hidden by this cut.
2701 if (any(authors) && authorOffered[r.author] && !authors[r.author]) { return false; }
2702 for (var i = 0; i < facets.length; i++) {
2703 if (!passesFacet(r, facets[i])) { return false; }
2704 }
2705 if (r.mins < tmin || r.mins > tmax) { return false; }
2706 return true;
2707 }
2708
2709 // The author intro boxes above the posts follow the author selection: with every face pressed
2710 // every author is shown (the default), and releasing faces narrows the intros to just those
2711 // authors, the same set the posts are narrowed to. An intro exists only for an author who wrote a
2712 // description, so where a selection leaves none the section folds away rather than sit empty.
2713 var aboutSection = document.querySelector(".aside-about");
2714 var aboutBoxes = aboutSection ? aboutSection.querySelectorAll(".aside-about-who") : [];
2715 function applyAbout() {
2716 if (!aboutSection) { return; }
2717 var narrowed = any(authors), visible = 0;
2718 Array.prototype.forEach.call(aboutBoxes, function (box) {
2719 var h = box.getAttribute("data-author");
2720 // A bio-only author has no face to switch off, so their intro always stands; a faced
2721 // author's intro drops the moment their face is released, and returns when it is pressed
2722 // again or the filter is cleared.
2723 var show = !authorOffered[h] || !narrowed || authors[h];
2724 box.style.display = show ? "" : "none";
2725 if (show) { visible++; }
2726 });
2727 aboutSection.style.display = visible ? "" : "none";
2728 }
2729
2730 function apply() {
2731 applyAbout();
2732 var shown = 0;
2733 for (var i = 0; i < rows.length; i++) {
2734 var ok = passes(rows[i]);
2735 rows[i].el.hidden = !ok;
2736 if (ok) { shown++; }
2737 }
2738 // Only where there were posts to exclude: a blog with none says so already, in the line the
2739 // server drew, and saying both would be two answers to one question.
2740 if (none) { none.hidden = !(rows.length && shown === 0); }
2741 }
2742
2743 // The search box, and its match mode -- the twin of the vocabularies' mode rows. The facet holds
2744 // no chips, so the mode is wired here rather than in the chip-box loop below.
2745 var box = document.getElementById("aside-filter-search");
2746 if (box) {
2747 box.addEventListener("input", function () {
2748 search = box.value.trim().toLowerCase();
2749 apply();
2750 });
2751 }
2752 Array.prototype.forEach.call(document.querySelectorAll('input[name="aside-search-mode"]'),
2753 function (el) {
2754 el.addEventListener("change", function () {
2755 if (el.checked) { searchMode = el.value; apply(); }
2756 });
2757 });
2758
2759 // The author faces. Every one starts selected -- the default is all of them, shown pressed the
2760 // way each vocabulary starts with every chip in its Selected box -- and releasing one narrows;
2761 // releasing the last imposes nothing, so the stream opens back up rather than empties.
2762 var authorRow = document.getElementById("aside-filter-authors");
2763 if (authorRow) {
2764 Array.prototype.forEach.call(authorRow.querySelectorAll(".aside-author"), function (b) {
2765 var h = b.getAttribute("data-author");
2766 authors[h] = true;
2767 authorOffered[h] = true;
2768 });
2769 // A lone author cannot be switched off: there is nothing to narrow to, and a filter that could
2770 // hide the only writer would just empty the page. The face stays selected, inert, until a
2771 // second author exists.
2772 var lone = Object.keys(authorOffered).length < 2;
2773 if (lone) { authorRow.classList.add("aside-authors-lone"); }
2774 authorRow.addEventListener("click", function (e) {
2775 if (lone) { return; }
2776 var b = e.target.closest(".aside-author");
2777 if (!b) { return; }
2778 var u = b.getAttribute("data-author");
2779 authors[u] = !authors[u];
2780 b.setAttribute("aria-pressed", authors[u] ? "true" : "false");
2781 apply();
2782 });
2783 }
2784
2785 // Each family's mode row, and its pair of boxes. A chip lives in one box; clicking it, or dragging
2786 // it, sends it to the other, and which box it is in is the whole of what it means.
2787 facets.forEach(function (st) {
2788 st.block.addEventListener("change", function (e) {
2789 if (e.target.name === "aside-mode-" + st.name) { st.mode = e.target.value; apply(); }
2790 });
2791
2792 function wireBox(boxEl, other) {
2793 if (!boxEl || !other) { return; }
2794 boxEl.addEventListener("click", function (e) {
2795 var chip = e.target.closest(".aside-chip");
2796 if (chip && chip.parentNode === boxEl) {
2797 other.appendChild(chip);
2798 refresh(st);
2799 apply();
2800 }
2801 });
2802 boxEl.addEventListener("dragover", function (e) {
2803 e.preventDefault();
2804 boxEl.classList.add("aside-drop");
2805 });
2806 boxEl.addEventListener("dragleave", function () { boxEl.classList.remove("aside-drop"); });
2807 boxEl.addEventListener("drop", function (e) {
2808 e.preventDefault();
2809 boxEl.classList.remove("aside-drop");
2810 var chip = chipOf(e.dataTransfer.getData("text/plain"));
2811 // A chip may only land in a box of its own family: a tag dragged onto the categories is
2812 // a gesture with no meaning, and honouring it would put a chip where nothing can ever
2813 // match it.
2814 if (!chip || chip.getAttribute("data-facet") !== st.name) { return; }
2815 if (chip.parentNode !== boxEl) {
2816 boxEl.appendChild(chip);
2817 refresh(st);
2818 apply();
2819 }
2820 });
2821 }
2822 wireBox(st.sel, st.src);
2823 wireBox(st.src, st.sel);
2824 });
2825
2826 // A dragged chip is carried as its family and its value, since a value alone does not say which
2827 // box may accept it -- and a category and a tag may read the same.
2828 function esc(v) { return window.CSS && CSS.escape ? CSS.escape(v) : v; }
2829 function chipOf(payload) {
2830 var at = payload.indexOf(":");
2831 if (at < 0) { return null; }
2832 return document.querySelector('.aside-chip[data-facet="' + esc(payload.slice(0, at)) +
2833 '"][data-value="' + esc(payload.slice(at + 1)) + '"]');
2834 }
2835 document.addEventListener("dragstart", function (e) {
2836 var chip = e.target.closest && e.target.closest(".aside-chip");
2837 if (chip && e.dataTransfer) {
2838 e.dataTransfer.setData("text/plain",
2839 chip.getAttribute("data-facet") + ":" + chip.getAttribute("data-value"));
2840 e.dataTransfer.effectAllowed = "move";
2841 }
2842 });
2843
2844 // A chip under a post links back here as `?cat=x` or `?tag=x`. Honour it by leaving only that value
2845 // in its family's selected box and sending the rest across, so the reader arrives on the thing the
2846 // chip named. Decoded, because a category may hold a space or a capital, unlike a tag; a value the
2847 // block does not offer changes nothing, so a stale link shows the whole list rather than none of
2848 // it. The two may arrive together and are read one family at a time.
2849 facets.forEach(function (st) {
2850 var m = new RegExp("[?&]" + st.name + "=([^&]*)").exec(location.search);
2851 if (!m || !st.sel || !st.src) { return; }
2852 var want;
2853 try { want = decodeURIComponent(m[1].replace(/\+/g, " ")); } catch (e) { return; }
2854 if (!st.offered[want]) { return; }
2855 Array.prototype.slice.call(st.sel.querySelectorAll(".aside-chip")).forEach(function (chip) {
2856 if (chip.getAttribute("data-value") !== want) { st.src.appendChild(chip); }
2857 });
2858 refresh(st);
2859 });
2860
2861 // The reading-time slider: two thumbs that may not cross, the span between them painted so the
2862 // chosen band reads at a glance rather than being left for the eye to infer from two dots.
2863 var timeWrap = document.getElementById("aside-filter-time");
2864 var tminEl = document.getElementById("aside-time-min");
2865 var tmaxEl = document.getElementById("aside-time-max");
2866 var tout = document.getElementById("aside-time-out");
2867 var tfill = document.getElementById("aside-time-fill");
2868 if (timeWrap && tminEl && tmaxEl) {
2869 tmin = parseInt(tminEl.value, 10);
2870 tmax = parseInt(tmaxEl.value, 10);
2871 var paintTime = function () {
2872 if (!tfill) { return; }
2873 var mn = parseInt(tminEl.min, 10), mx = parseInt(tminEl.max, 10);
2874 var span = (mx - mn) || 1;
2875 tfill.style.left = ((tmin - mn) / span * 100) + "%";
2876 tfill.style.right = ((mx - tmax) / span * 100) + "%";
2877 };
2878 var syncTime = function () {
2879 var lo = parseInt(tminEl.value, 10);
2880 var hi = parseInt(tmaxEl.value, 10);
2881 if (lo > hi) {
2882 // Whichever thumb crossed the other is pushed back to meet it.
2883 if (this === tminEl) { hi = lo; tmaxEl.value = hi; }
2884 else { lo = hi; tminEl.value = lo; }
2885 }
2886 tmin = lo; tmax = hi;
2887 if (tout) { tout.textContent = lo + "–" + hi + " min"; }
2888 paintTime();
2889 apply();
2890 };
2891 tminEl.addEventListener("input", syncTime);
2892 tmaxEl.addEventListener("input", syncTime);
2893 paintTime();
2894 }
2895
2896 // Drop the fade from a preview whose prose is not cut -- the fade promises more, and a post that
2897 // fits has none to promise. A height read while the panel is hidden reads 0, so a preview with no
2898 // laid-out box is left alone; the fade, the safe default, stands until a run when it is visible.
2899 function markAsideFits() {
2900 Array.prototype.forEach.call(document.querySelectorAll(".aside-card-preview"), function (pv) {
2901 if (!pv.offsetParent && pv.offsetHeight === 0) { return; }
2902 pv.classList.toggle("aside-card-fit", pv.scrollHeight <= pv.clientHeight + 2);
2903 });
2904 }
2905 markAsideFits();
2906 if (document.fonts && document.fonts.ready) { document.fonts.ready.then(markAsideFits); }
2907 window.addEventListener("load", markAsideFits);
2908
2909 // On a narrow screen the filter is folded behind a button; toggling a class rather than redrawing
2910 // keeps every selection the reader has made while it was open.
2911 var toggle = document.getElementById("aside-filter-toggle");
2912 var side = document.getElementById("aside-side");
2913 if (toggle && side) {
2914 toggle.addEventListener("click", function () {
2915 var open = side.classList.toggle("open");
2916 toggle.setAttribute("aria-expanded", open ? "true" : "false");
2917 });
2918 }
2919
2920 apply();
2921})();
2922"##;
2923
2924/// A query value made of the few characters this module's own links use.
2925///
2926/// No percent-decoding: every value read with this is one the page itself wrote, from a small
2927/// alphabet, so anything needing decoding is something else and reads as absent -- which is the
2928/// right answer to a value no link of ours produces.
2929pub fn query_word(query: &str, key: &str) -> Option<String> {
2930 for pair in query.split('&') {
2931 let mut kv = pair.splitn(2, '=');
2932 if kv.next() == Some(key) {
2933 let v = kv.next().unwrap_or("");
2934 if v.is_empty() || !v.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
2935 return None;
2936 }
2937 return Some(v.to_string());
2938 }
2939 }
2940 None
2941}
2942
2943/// What the last comment attempt said, read out of the query a redirect landed with.
2944///
2945/// **A code, not a sentence.** The query is a thing anybody can put in a link and send to somebody
2946/// else, so carrying the message text in it would let a stranger make this site say whatever they
2947/// liked above its own comment form -- "your payment failed", say, over a plausible-looking URL. The
2948/// redirect carries a word this function knows, and the words themselves live here. A code this does
2949/// not know says nothing at all.
2950pub fn said_of(query: &str) -> Option<String> {
2951 for pair in query.split('&') {
2952 let mut kv = pair.splitn(2, '=');
2953 if kv.next() == Some("said") {
2954 return match kv.next().unwrap_or("") {
2955 "published" => Some(fmt!("Thank you — your comment is below.")),
2956 "held" => Some(fmt!(
2957 "Thank you — your comment has been sent to the author for review.")),
2958 "shut" => Some(fmt!("Comments are not open on this site.")),
2959 "edited" => Some(fmt!(
2960 "Your comment has been changed. A comment that was already published goes \
2961 back to the author to look at again.")),
2962 "noedit" => Some(fmt!(
2963 "That comment could not be changed. The few minutes for correcting it may \
2964 have passed.")),
2965 _ => None,
2966 };
2967 }
2968 }
2969 None
2970}
2971
2972/// The answer to a posted comment: back to the post, carrying what to tell the reader.
2973///
2974/// A redirect rather than a rendered page, so a reload does not post the comment again -- the same
2975/// reasoning the console's writes take. The fragment puts the reader at the conversation rather than
2976/// at the top of prose they have just read.
2977pub fn comment_posted(cfg: &PublishConfig, slug: &str, said: &str) -> HttpMessage {
2978 // `said` is one of the codes `said_of` knows, never a sentence. See its documentation.
2979 let to = fmt!("{}?said={}#comments", cfg.path_of(slug), percent_encode(said));
2980 let mut resp = HttpMessage::new_response(HttpStatus::SeeOther);
2981 resp = resp.with_field(HeaderName::Location, HeaderFieldValue::Generic(to));
2982 resp
2983}
2984
2985/// The comment a request's cookie claims to have written, and the token it offers.
2986///
2987/// Read only; whether the token is any good is the caller's to check, since only the caller holds
2988/// the site's secret.
2989pub fn edit_claim(headers: &std::sync::Arc<HeaderFields>) -> Option<(String, String)> {
2990 use oxedyne_fe2o3_net::http::fields::HeaderFieldValue as V;
2991 if let Some(V::Cookie(cookies)) = headers.get_one(&HeaderName::Cookie) {
2992 for c in cookies {
2993 if c.key == "comment_edit" {
2994 let (id, token) = c.val.split_once('.')?;
2995 if id.is_empty() || token.is_empty() {
2996 return None;
2997 }
2998 return Some((id.to_string(), token.to_string()));
2999 }
3000 }
3001 }
3002 None
3003}
3004
3005/// Attaches the token that lets a comment's author correct it.
3006///
3007/// A cookie, because it is the only thing a browser will carry back on its own and the alternative
3008/// is a token in a URL that lands in history, in a referrer and in anything the reader pastes. It
3009/// expires with the window, is `HttpOnly` so no script reads it, and names one comment.
3010pub fn with_edit_cookie(resp: HttpMessage, id: &str, token: &str) -> HttpMessage {
3011 let value = fmt!(
3012 "comment_edit={}.{}; Path=/; Max-Age={}; HttpOnly; SameSite=Lax",
3013 id, token, crate::srv::publish::comment::EDIT_WINDOW_SECS,
3014 );
3015 resp.with_field(HeaderName::SetCookie, HeaderFieldValue::Generic(value))
3016}
3017
3018/// Percent-encodes a value going into a query string.
3019///
3020/// Only what has to be: a query value's own delimiters, and the characters a browser would otherwise
3021/// treat as structure. Everything else is left legible, since this lands in a URL a reader may see --
3022/// which is also why a space becomes `+` rather than `%20`. Used by a comment redirect and by a
3023/// category chip's link, a category being the one facet that may hold a space and a capital.
3024fn percent_encode(s: &str) -> String {
3025 let mut out = String::with_capacity(s.len());
3026 for b in s.bytes() {
3027 match b {
3028 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~'
3029 => out.push(b as char),
3030 b' ' => out.push('+'),
3031 _ => out.push_str(&fmt!("%{:02X}", b)),
3032 }
3033 }
3034 out
3035}
3036