Skip to main content

max / makenotwork

33.2 KB · 726 lines History Blame Raw
1 //! The document head, emitted by quasi's renderer rather than by `base.html`.
2 //!
3 //! Wiki note `mnw-server-conversion-plan`, step S1. The dashboard is being
4 //! converted to described screens one at a time, and a converted screen renders
5 //! through [`quasi_webview::Shell`] while everything around it still renders
6 //! through Askama. Two heads written by two hands drift, and the drift is
7 //! silent: a layer statement that moves below a stylesheet, an htmx config that
8 //! is stated below the script that reads it, a viewport that says one thing on
9 //! half the site. So the head is the renderer's on both paths from here,
10 //! before any screen is described.
11 //!
12 //! [`Shell::parts`] is what a host whose templating writes into the head takes:
13 //! Askama renders the per-page `{% block title %}` and `{% block head %}` in
14 //! place, inside `base.html`, and no caller ever holds them as a string.
15 //! `base.html` writes the title, `</head>`, the `<body>` tag and the close;
16 //! everything above is here.
17 //!
18 //! One shell for the process, not one per request. Nothing in it varies by
19 //! viewer: the creator's theme block is per-request and unlayered on purpose,
20 //! and it stays where it is, injected by the three templates that show a
21 //! creator's work through `{% block head %}` so it lands after every sheet and
22 //! outranks every named layer.
23
24 use std::sync::OnceLock;
25
26 use quasi_webview::Shell;
27
28 /// The cache-busting suffix on the site's own assets.
29 ///
30 /// A content hash of every watched static file, computed in `build.rs` and
31 /// handed over as an env var. The head is the renderer's, so the version is all
32 /// that crosses. `_sheet.html` and `_island.html` are generated with the same
33 /// hash, for the per-page sheets and islands this module does not see.
34 const V: &str = env!("STATIC_VERSION");
35
36 /// The vendored htmx release, and the cache-busting suffix on its extensions.
37 ///
38 /// Not the content hash above: an extension is a pinned file that changes only
39 /// when htmx is bumped, so naming the release is both the version and the
40 /// record of which one is on disk. `static/htmx.min.js` itself is under the
41 /// content hash, because `build.rs` already watches it.
42 const HTMX: &str = "4.0.0-beta6";
43
44 fn parts() -> &'static quasi_webview::Parts {
45 static PARTS: OnceLock<quasi_webview::Parts> = OnceLock::new();
46 PARTS.get_or_init(|| described().parts())
47 }
48
49 /// The shell every document on this site is drawn in, described or templated.
50 ///
51 /// `base.html` takes it as [`parts`] and assembles the rest itself; a screen
52 /// that owns its whole document (`crate::quasi::pricing`) takes the `Shell` and
53 /// hands it to a renderer. One builder either way, which is the whole point:
54 /// two heads written by two hands drift in ways nothing catches.
55 #[must_use]
56 pub fn described() -> Shell {
57 Shell::under("/static")
58 // Earlier in the list = lower priority, and `makeover` is prepended
59 // by the renderer. A layer's position is fixed where its name is
60 // FIRST seen, so without the statement the generated sheets would
61 // establish `makeover` simply by loading first and reordering two
62 // links would silently reorder the cascade. `components` is where
63 // the site's own sheets live, including the per-page wizard.css and
64 // media-player.css that arrive later through `{% block head %}` and
65 // that nothing here can order. `base` and `responsive` are still
66 // empty; declaring an empty layer costs nothing and fixes its
67 // position.
68 .layered(["base", "components", "responsive"])
69 // Whole value is being early: a preload discovered after the sheets
70 // it races bought nothing, and htmx reads its config once, when the
71 // script runs, so the meta has to be above it.
72 //
73 // `noSwap` restores htmx 2's rule that a 4xx or 5xx response does
74 // not swap. htmx 4 swaps everything but 204 and 304, and what this
75 // server answers a failed fragment request with is a whole rendered
76 // error page (`error.rs`), so the default would paint that page
77 // inside whatever the request targeted. The error toast in
78 // `htmx-glue.ts` reads the `HX-Error` header on the same response
79 // and is what a user sees instead, unchanged from 2.x.
80 //
81 // The cost, worth knowing before turning a described screen on: a
82 // blanket `noSwap` is checked before `hx-status:4xx`, so an element
83 // cannot opt back in. Decision 9's classified errors (403 `Denied`,
84 // 404 `NotFound`) therefore still render nothing here, which is the
85 // gap `quasi-overview` expected htmx 4 to close for free.
86 .with_head_first(
87 "<meta name=\"htmx-config\" content='{\"noSwap\":[204,304,\"4xx\",\"5xx\"]}'>\
88 <link rel=\"preload\" href=\"/static/fonts/QuasiBody.woff2\" as=\"font\" type=\"font/woff2\" crossorigin>\
89 <link rel=\"preload\" href=\"/static/fonts/ysrf.woff2\" as=\"font\" type=\"font/woff2\" crossorigin>",
90 )
91 // First, and before the sheets that use the tokens it defines. The
92 // `@font-face` rules take no part in the cascade so their position
93 // buys nothing there; what it buys is discovery, since a face the
94 // parser has not reached yet is a face the browser has not started
95 // fetching. `style.css` still wins every contest it won before:
96 // this file defines two tokens and matches no element.
97 .styled(format!("/static/typography.css?v={V}"))
98 .styled(format!("/static/geometry.css?v={V}"))
99 .styled(format!("/static/timing.css?v={V}"))
100 .styled(format!("/static/layout.css?v={V}"))
101 .styled(format!("/static/style.css?v={V}"))
102 // Last in the head, after htmx: the favicon has no order to keep,
103 // and neither of the last two scripts reads htmx at load.
104 // `upload.js` only defines `S3Uploader`, and the core module is
105 // deferred by being a module, so it still runs after the deferred
106 // htmx above it.
107 //
108 // `hx-history-cache` is the exception and is why it carries
109 // `defer`: it calls `htmx.registerExtension` as it loads, so an
110 // ordinary script would run during parsing and reach for an htmx
111 // that has not executed yet. Deferred scripts run in document
112 // order, and htmx's own init waits a tick past that.
113 //
114 // What it restores is htmx 2's history cache, which htmx 4 dropped:
115 // without it every Back is a fresh request for the pushed URL, and
116 // the URLs this site pushes are the wizard's step routes, which
117 // answer a GET with a bare partial rather than a page. So a Back
118 // out of a wizard step painted a chromeless fragment over the
119 // document. The extension is first-party, keyed on sessionStorage
120 // rather than localStorage, and defaults to the same 10 entries
121 // htmx 2 kept.
122 .with_head(format!(
123 "<link rel=\"icon\" href=\"/static/images/favicon.ico\" type=\"image/x-icon\">\
124 <script src=\"/static/hx-history-cache.min.js?v={HTMX}\" defer></script>\
125 <script src=\"/static/upload.js?v={V}\"></script>\
126 <script type=\"module\" src=\"/static/dist-{V}/core/index.js\"></script>"
127 ))
128 }
129
130 /// The link that jumps a keyboard reader past everything to the content.
131 ///
132 /// Takes the id it points at, because a described screen's first region
133 /// publishes its own slot id and `base.html`'s `<main>` is called
134 /// `main-content`. One spelling of the markup either way: this is an a11y
135 /// affordance the site owes on every page, and a second copy of it is a copy
136 /// that goes stale.
137 #[must_use]
138 pub fn skip_link(target: &str) -> String {
139 format!("<a href=\"#{target}\" class=\"skip-to-main\">Skip to main content</a>")
140 }
141
142 /// The wordmark the sessionless pages open with.
143 ///
144 /// Ten templates wrote this same `<h1>` out by hand -- `login`, `two_factor`,
145 /// `forgot_password`, `reset_password`, `confirm_delete`, `acknowledge`,
146 /// `oauth_authorize`, `sandbox`, `purchase` and `index` -- which is ten places
147 /// for the dot to move. Here for the same reason [`site_header`] is: it is one
148 /// element of the assembly layer, called from the templates and handed to
149 /// [`quasi_webview::Shell::with_body_first`] by a screen that owns its own
150 /// document.
151 ///
152 /// Not [`site_header`]'s logo and not a substitute for it. The pages that carry
153 /// this one carry no header at all: they are the screens a reader reaches
154 /// without a session, where the nav would offer a Library and a Dashboard the
155 /// reader cannot open. The dot is `aria-hidden`, matching the logo, so a screen
156 /// reader hears the name rather than the punctuation.
157 #[must_use]
158 pub fn wordmark() -> &'static str {
159 "<h1 class=\"brand-h1\">Makenot<span class=\"dot\" aria-hidden=\"true\">.</span>work</h1>"
160 }
161
162 /// What every page on this site ends with: the toast container and the classic
163 /// script shims the `data-action` dispatcher resolves through.
164 ///
165 /// Called by `base.html` for the templated pages and handed to
166 /// [`quasi_webview::Shell::with_body_last`] by a screen that owns its own
167 /// document, so the tail is written once. Markup no description will ever name
168 /// -- a script tag, and a container another script writes into.
169 #[must_use]
170 pub fn body_last() -> &'static str {
171 concat!(
172 "<div id=\"notifications\" class=\"toast-container\" role=\"alert\" aria-live=\"polite\"></div>",
173 "<script src=\"/static/actions-pages.js?v=0701\"></script>",
174 "<script src=\"/static/actions-partials.js?v=0701\"></script>",
175 "<script src=\"/static/actions-tabs.js?v=0701\"></script>",
176 "<script src=\"/static/actions-dashboards.js?v=0701\"></script>",
177 "<script src=\"/static/synckit-billing.js?v=0620\"></script>",
178 "<script src=\"/static/synckit-tabs.js?v=0623\"></script>",
179 "<script src=\"/static/markdown-editor.js?v=0820\"></script>",
180 )
181 }
182
183 /// What the site offers from every page, as a description.
184 ///
185 /// The header was 48 lines of hand-written markup in this module until
186 /// `c7b0d3c1`, and the reason it could not be a [`Chrome`] was that the nav and
187 /// the mobile menu were emitted at two different points in the document:
188 /// `style.css` opens that menu with `.nav-toggle-checkbox:checked ~ nav`, and
189 /// `~` reaches siblings only. [`quasi_router::Band`] is the member that closed
190 /// it. The band is one element and everything in the header is inside it, so
191 /// the checkbox and the nav are siblings again and the rule matches.
192 ///
193 /// Built per request, unlike the chrome a described app hangs on its router:
194 /// what the nav offers depends on whether there is a session and whether that
195 /// session is an admin, and a chrome built once could not say either.
196 ///
197 /// The shortcuts binding is not here. That is
198 /// [`crate::quasi::shortcuts::chrome`] and it is hung on the described
199 /// document's shell; this is the header, and the two are joined by whatever
200 /// builds a `Shell`.
201 #[must_use]
202 pub fn header_chrome(user: Option<&crate::auth::SessionUser>) -> quasi_router::Chrome {
203 use quasi_router::{Action, Band, Brand, Chrome, Disclose, Field, Place, layout};
204
205 let mut chrome = Chrome::new().banded(
206 Band::new()
207 .branded(Brand::new("Makenot.work", Action::get("/").navigating()).marking("."))
208 .searching({
209 let mut field =
210 Field::new(layout::FieldKind::Text, "q", "Search items and projects");
211 field.placeholder = Some("Search... (Cmd+K)".to_owned());
212 // A box that goes somewhere when it settles, which is what
213 // the hand-written `<form action="/discover" method="get">`
214 // was.
215 field.writes(Action::get("/discover").navigating())
216 })
217 // The narrow-viewport menu, named rather than hand-rolled. The
218 // checkbox and the three bars are the renderer's; what this says is
219 // that the places are worth hiding when there is no room.
220 .disclosing(Disclose::Narrow),
221 );
222
223 chrome = chrome.offering(Place::new(
224 "discover",
225 "Discover",
226 Action::get("/discover").navigating(),
227 ));
228
229 match user {
230 Some(user) => {
231 chrome = chrome
232 .offering(Place::new(
233 "library",
234 "Library",
235 Action::get("/library").navigating(),
236 ))
237 .offering(Place::new(
238 "dashboard",
239 "Dashboard",
240 Action::get("/dashboard").navigating(),
241 ))
242 // Offered always and drawn only when there is something in it.
243 // How many items a cart holds is not known when the document is
244 // rendered -- `static/dist/core/cart-badge.js` asks
245 // `/api/cart/count` -- so the presence is the script's and the
246 // place is the description's. It finds this one by
247 // `data-place="cart"`, which is `Place::key` as the renderer
248 // emits it.
249 .offering(Place::new(
250 "cart",
251 "Cart",
252 Action::get("/cart").navigating(),
253 ));
254 if user.is_admin {
255 // The one thing in the nav that is a permission rather than a
256 // session.
257 chrome = chrome.offering(Place::new(
258 "admin",
259 "Admin",
260 Action::get("/admin/waitlist").navigating(),
261 ));
262 }
263 // A write, so it is emitted as an htmx post rather than an anchor.
264 // The token rides on the `X-CSRF-Token` header that
265 // `frontend/src/core/htmx-glue.ts` attaches to every request from
266 // the `csrf-token` meta, which is why this no longer takes one: the
267 // hidden `_csrf` input existed because the logout was a form, and
268 // it is not a form any more.
269 chrome = chrome.offering(Place::new("logout", "Log Out", Action::post("/logout")));
270 }
271 None => {
272 for (key, label, route) in [
273 ("use-cases", "Use Cases", "/use-cases"),
274 ("docs", "Docs", "/docs"),
275 ("fan-plus", "Fan+", "/fan-plus"),
276 ("login", "Login", "/login"),
277 ("join", "Join", "/join"),
278 ] {
279 chrome = chrome.offering(Place::new(key, label, Action::get(route).navigating()));
280 }
281 }
282 }
283
284 chrome
285 }
286
287 /// The site header, as every page on this site carries it.
288 ///
289 /// Called by `partials/site_header.html` for the templated pages and handed to
290 /// [`quasi_webview::Shell::with_body_first`] by a screen that owns its own
291 /// document, so the header is written once. [`body_last`]'s argument one
292 /// element up.
293 ///
294 /// The markup is the renderer's now: this builds [`header_chrome`] and
295 /// `quasi_webview::chrome::header_html` writes it. That is the whole of
296 /// `c7b0d3c1` -- one description, three renderers, and no markup in the
297 /// assembly layer.
298 ///
299 /// # It no longer takes the CSRF token
300 ///
301 /// It took one while the logout was a `<form>` carrying a hidden `_csrf`. The
302 /// logout is a [`quasi_router::Place`] now, emitted as an htmx post, and
303 /// `frontend/src/core/htmx-glue.ts` attaches the token to every htmx request
304 /// from the `csrf-token` meta the document already carries. A second copy in
305 /// the markup would be a second thing that can go stale against a token that
306 /// rotates mid-session.
307 ///
308 /// Nothing marks a current place. The templated pages do not know which place
309 /// they are, and a described screen says it with
310 /// [`quasi_router::Screen::place`] on the path this function is not on.
311 #[must_use]
312 pub fn site_header(user: Option<&crate::auth::SessionUser>) -> String {
313 quasi_webview::chrome::header_html(
314 &header_chrome(user),
315 None,
316 &makeover_webview::Emit::default(),
317 )
318 }
319
320 /// `<!doctype>` through the head's contents, without `</head>`.
321 ///
322 /// Called from `base.html`, which appends the title and the per-page head and
323 /// closes the element.
324 pub fn head() -> &'static str {
325 &parts().head
326 }
327
328 /// The attributes the shell owns on `<body>`, each one space-prefixed.
329 ///
330 /// `base.html` writes `<body{{ body_attrs() }}{% block body_attrs %}>`, so a
331 /// page's own class attribute composes with these instead of replacing them.
332 pub fn body_attrs() -> &'static str {
333 &parts().body_attrs
334 }
335
336 pub use makeover_layout::Measure;
337
338 /// The body class for a screen's measure.
339 ///
340 /// How wide a page runs is a property of the screen, not a literal in its
341 /// template: name the measure here rather than writing one of these class
342 /// strings into markup.
343 ///
344 /// The class names are what `style.css` matches, so they stay as they are.
345 /// `padded-page` is [`Measure::Wide`]: a padded page is the full width with
346 /// gutters.
347 ///
348 /// The four standalone tokens (`health-page`, `purchase-page`, `buy-page`,
349 /// `stripe-disclaimer-page`) are screen identity rather than measure, and are
350 /// deliberately not here.
351 #[must_use]
352 pub const fn measure(measure: Measure) -> &'static str {
353 match measure {
354 Measure::Contained => "centered-page",
355 Measure::Reading => "article-page",
356 // The default, and the arm a member added upstream lands in. A measure
357 // this server has not learned yet should render at the width every
358 // other page does rather than unstyled.
359 _ => "padded-page",
360 }
361 }
362
363 /// The `<body>` class a screen that owns its document carries: its measure
364 /// first, then whatever else its template said beside it.
365 ///
366 /// [`quasi_router::Document::classed`] replaces rather than appends, so a
367 /// screen with a grouping or identity token of its own hands over one string.
368 /// Composing it here is what stops a screen that meant to add `feed-page` from
369 /// dropping `padded-page` on the way.
370 ///
371 /// The global half is not here. `Shell` carries what is true of every document
372 /// and the renderer writes the two beside each other, the same composition
373 /// `base.html` does with [`body_attrs`].
374 #[must_use]
375 pub fn body_class(page: Measure, own: &[&str]) -> String {
376 let mut class = String::from(measure(page));
377 for token in own {
378 class.push(' ');
379 class.push_str(token);
380 }
381 class
382 }
383
384 #[cfg(test)]
385 mod tests {
386 use super::*;
387
388 #[test]
389 fn the_layer_statement_precedes_every_stylesheet() {
390 // The property the hand-written `<style>@layer ...</style>` existed to
391 // hold, now held by the renderer. It is the one that fails silently:
392 // the CSS stays valid and buttons and badges look subtly wrong.
393 let head = head();
394 let stmt = head
395 .find("@layer makeover, base, components, responsive;")
396 .expect("the order is stated");
397 for sheet in [
398 "typography.css",
399 "geometry.css",
400 "timing.css",
401 "layout.css",
402 "style.css",
403 ] {
404 assert!(stmt < head.find(sheet).expect("the sheet is linked"));
405 }
406 }
407
408 #[test]
409 fn the_head_is_not_closed_and_carries_no_title() {
410 // Both are `base.html`'s, and emitting either here would produce a
411 // second one rather than an error.
412 assert!(!head().contains("</head>"));
413 assert!(!head().contains("<title>"));
414 assert!(head().starts_with("<!doctype html><html lang=\"en\">"));
415 }
416
417 #[test]
418 fn the_fonts_are_preloaded_before_the_sheets_that_race_them() {
419 let head = head();
420 assert!(head.find("QuasiBody.woff2") < head.find("style.css"));
421 // The retired pair, checked by absence: a preload for a face nothing
422 // declares is a download the browser makes and never uses.
423 assert!(!head.contains("Lato"));
424 assert!(!head.contains("IBMPlexMono"));
425 }
426
427 #[test]
428 fn the_body_attributes_start_with_a_space_so_a_page_can_add_its_own() {
429 // `base.html` writes them straight against `<body`, and a page's
430 // `{% block body_attrs %}` straight after. Neither side puts a
431 // separator in, so this one has to carry it.
432 let attrs = body_attrs();
433 assert!(attrs.is_empty() || attrs.starts_with(' '));
434 // No class of its own, or a page's class attribute would be the second
435 // on the tag and the browser would drop it.
436 assert!(!attrs.contains("class="));
437 }
438
439 #[test]
440 fn every_measure_keeps_the_class_the_templates_used_to_write() {
441 // `0eccff0d` moved where the choice is written down and changed no
442 // rule in `style.css`, so the three strings have to come out exactly as
443 // the 69 templates spelled them. A typo here renders 53 pages unstyled.
444 assert_eq!(measure(Measure::Wide), "padded-page");
445 assert_eq!(measure(Measure::Contained), "centered-page");
446 assert_eq!(measure(Measure::Reading), "article-page");
447 }
448
449 #[test]
450 fn a_screen_with_nothing_of_its_own_carries_only_its_measure() {
451 assert_eq!(body_class(Measure::Contained, &[]), "centered-page");
452 }
453
454 #[test]
455 fn a_screens_own_token_lands_beside_its_measure_rather_than_instead_of_it() {
456 // The exact string `templates/pages/feed.html` renders today, which is
457 // the parity the next conversion has to hold.
458 assert_eq!(
459 body_class(Measure::Wide, &["feed-page"]),
460 "padded-page feed-page"
461 );
462 assert_eq!(
463 body_class(Measure::Wide, &["dashboard-page", "dashboard-user-page"]),
464 "padded-page dashboard-page dashboard-user-page"
465 );
466 }
467
468 /// A reader with no session is offered the two ways in and no way out.
469 #[test]
470 fn the_signed_out_header_offers_the_ways_in() {
471 let html = site_header(None);
472
473 assert!(html.contains("href=\"/login\""), "{html}");
474 assert!(html.contains("href=\"/join\""), "{html}");
475 assert!(html.contains("href=\"/discover\""), "{html}");
476 assert!(!html.contains("/logout"), "{html}");
477 assert!(!html.contains("/dashboard"), "{html}");
478 }
479
480 /// A signed-in reader gets the account nav and a way out.
481 ///
482 /// The logout was a `<form>` with a hidden `_csrf` until `c7b0d3c1`. It is
483 /// a `Place` now, so it is an htmx post and the token rides on the header
484 /// `htmx-glue.ts` attaches from the `csrf-token` meta. What this asserts is
485 /// that it is still a write: a logout emitted as an anchor would be a
486 /// logout a link prefetcher can perform.
487 #[test]
488 fn the_signed_in_header_carries_a_logout_that_writes() {
489 let html = site_header(Some(&a_user(false)));
490
491 assert!(html.contains("hx-post=\"/logout\""), "{html}");
492 assert!(
493 !html.contains("href=\"/logout\""),
494 "a prefetch logs out: {html}"
495 );
496 assert!(html.contains("href=\"/library\""), "{html}");
497 assert!(html.contains("href=\"/dashboard\""), "{html}");
498 assert!(!html.contains("href=\"/login\""), "{html}");
499 }
500
501 /// The admin link is the one thing in the nav that is a permission rather
502 /// than a session.
503 #[test]
504 fn only_an_admin_is_offered_the_admin_link() {
505 assert!(site_header(Some(&a_user(true))).contains("/admin/waitlist"));
506 assert!(!site_header(Some(&a_user(false))).contains("/admin/waitlist"));
507 }
508
509 /// The mobile menu is a checkbox styling its siblings, which is the whole
510 /// reason this header is one element. The three have to stay siblings
511 /// inside `<header>`, in this order, and it is the renderer that keeps them
512 /// there now: `Chrome::band` is the member that made saying so possible.
513 #[test]
514 fn the_disclosure_stays_a_sibling_of_what_it_reveals() {
515 let html = site_header(None);
516
517 let toggle = html.find("chrome-disclose-state").expect("the checkbox");
518 let search = html.find("chrome-search").expect("the search box");
519 let nav = html.find("<nav").expect("the nav");
520 assert!(toggle < search && search < nav, "{html}");
521 assert!(html.ends_with("</header>"), "{html}");
522 }
523
524 /// The wordmark comes apart at its dot, and is read whole.
525 ///
526 /// The hand-written header marked the dot `aria-hidden`, which left the
527 /// accessible name as "Makenotwork" -- not something the site is called.
528 /// `Brand` says the name and which run of it is the mark, and the renderer
529 /// draws both.
530 #[test]
531 fn the_header_wordmark_is_marked_and_still_reads_whole() {
532 let html = site_header(None);
533
534 assert!(
535 html.contains(">Makenot<span class=\"chrome-brand-mark\">.</span>work</a>"),
536 "{html}"
537 );
538 }
539
540 fn a_user(is_admin: bool) -> crate::auth::SessionUser {
541 crate::auth::SessionUser {
542 id: crate::db::UserId::new(),
543 username: crate::db::Username::from_trusted("areader".to_string()),
544 email: "areader@example.com".to_string(),
545 display_name: None,
546 can_create_projects: false,
547 suspended: false,
548 is_admin,
549 is_fan_plus: false,
550 creator_tier: None,
551 deactivated: false,
552 is_sandbox: false,
553 settlement_currency: crate::currency::SettlementCurrency::Usd,
554 conversion_preference: crate::currency::ConversionChoice::AtCheckout,
555 }
556 }
557
558 /// One spelling of the wordmark, enforced the way the layout class is.
559 ///
560 /// Ten templates wrote the same `<h1>` before [`wordmark`] existed, so the
561 /// obvious failure is an eleventh pasted from one of them. The three
562 /// `brand-h1` headings that remain say something else -- "Account Deleted",
563 /// "Email preferences", and the email-result title -- and are not the
564 /// wordmark, so the check is for the wordmark's own text.
565 #[test]
566 fn no_template_still_writes_the_wordmark_by_hand() {
567 let mut offenders = Vec::new();
568 for entry in walk("templates", "html") {
569 let source = std::fs::read_to_string(&entry).expect("a template reads");
570 for (at, line) in source.lines().enumerate() {
571 if line.contains("brand-h1") && line.contains("Makenot") {
572 offenders.push(format!("{}:{}", entry.display(), at + 1));
573 }
574 }
575 }
576 assert!(
577 offenders.is_empty(),
578 "call crate::shell::wordmark instead: {offenders:?}"
579 );
580 }
581
582 #[test]
583 fn the_wordmark_hides_the_dot_from_a_screen_reader() {
584 // Matching the header's logo, which has always done this: a reader
585 // hears the name rather than the punctuation inside it.
586 assert!(wordmark().contains(r#"<span class="dot" aria-hidden="true">"#));
587 }
588
589 #[test]
590 fn no_template_still_writes_a_layout_class_by_hand() {
591 // The done-condition, checked rather than remembered: the layout axis
592 // is derived from the described property. A new template pasted from an
593 // old one fails here instead of quietly reintroducing the literal.
594 //
595 // The four standalone tokens are screen identity rather than measure
596 // and are deliberately left alone, so they are not looked for.
597 let mut offenders = Vec::new();
598 for entry in walk("templates", "html") {
599 let source = std::fs::read_to_string(&entry).expect("a template reads");
600 for (at, line) in source.lines().enumerate() {
601 if !line.contains("block body_attrs") {
602 continue;
603 }
604 // The literal, as distinct from the call that produces it: the
605 // rendered class still says `padded-page`, and should.
606 let derived = line.contains("crate::shell::measure(");
607 let literal = ["padded-page", "centered-page", "article-page"]
608 .iter()
609 .any(|name| line.contains(name));
610 if literal && !derived {
611 offenders.push(format!("{}:{}", entry.display(), at + 1));
612 }
613 }
614 }
615 assert!(offenders.is_empty(), "{offenders:?}");
616 }
617
618 #[test]
619 fn no_described_screen_writes_a_layout_class_by_hand() {
620 // The screen-side twin of the template check above: a converted screen
621 // states its measure and reads the class off it, so a literal reaching
622 // `Document::classed` is a screen that will not follow `style.css` when
623 // the mapping moves. The four standalone identity tokens and the embed
624 // classes are screen identity rather than measure and are not looked
625 // for.
626 let mut offenders = Vec::new();
627 for entry in walk("src", "rs") {
628 // This file states the three strings once, which is the point of it.
629 if entry.ends_with("shell.rs") {
630 continue;
631 }
632 let source = std::fs::read_to_string(&entry).expect("a source file reads");
633 for (at, line) in source.lines().enumerate() {
634 let literal = ["padded-page", "centered-page", "article-page"]
635 .iter()
636 .any(|name| line.contains(name));
637 if line.contains("classed(") && literal {
638 offenders.push(format!("{}:{}", entry.display(), at + 1));
639 }
640 }
641 }
642 assert!(offenders.is_empty(), "{offenders:?}");
643 }
644
645 /// Every file with the given extension under a directory.
646 fn walk(root: &str, wanted: &str) -> Vec<std::path::PathBuf> {
647 let mut found = Vec::new();
648 let mut stack = vec![std::path::PathBuf::from(root)];
649 while let Some(at) = stack.pop() {
650 let Ok(entries) = std::fs::read_dir(&at) else {
651 continue;
652 };
653 for entry in entries.flatten() {
654 let path = entry.path();
655 if path.is_dir() {
656 stack.push(path);
657 } else if path.extension().is_some_and(|ext| ext == wanted) {
658 found.push(path);
659 }
660 }
661 }
662 found
663 }
664
665 #[test]
666 fn the_transport_the_head_links_is_served_from_here() {
667 // The shell links one script for the transport. Morphing is a swap
668 // style in htmx 4 rather than something an extension supplies, so the
669 // idiomorph registration and the extension script both left the head
670 // when quasi-webview moved to 4 (`2246072d`, ruled 2026-08-18), and the
671 // vendored file left the tree with `6b87e5ff`.
672 assert!(head().contains("htmx.min.js"));
673 assert!(!head().contains("idiomorph"));
674 let vendored = concat!(env!("CARGO_MANIFEST_DIR"), "/static/htmx.min.js");
675 assert!(std::path::Path::new(vendored).exists());
676 // The served bundle and the markup the templates carry are one version
677 // or the other, never a mix: 4 reads `hx-disable` as "disable while the
678 // request runs" where 2 read it as "skip this subtree".
679 let bundle = std::fs::read_to_string(vendored).expect("the bundle reads");
680 assert!(bundle.contains(HTMX), "the vendored bundle is {HTMX}");
681 }
682
683 #[test]
684 fn every_vendored_extension_is_busted_by_the_htmx_release_it_came_from() {
685 // Two extensions are vendored out of the htmx release: the history
686 // cache the shell links site-wide, and `hx-prompt`, which one admin
687 // template links for itself. Both are pinned files, so the release is
688 // their version, and a bump that leaves a `?v=` behind serves a browser
689 // the old extension against the new core.
690 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
691 let mut linked = 0;
692 let mut sources = vec![head().to_string()];
693 for entry in walk("templates", "html") {
694 sources.push(std::fs::read_to_string(&entry).expect("a template reads"));
695 }
696 for source in &sources {
697 for (at, _) in source.match_indices("/static/hx-") {
698 let tail = &source[at + "/static/".len()..];
699 // The URL alone, cut at whichever quote closes the attribute.
700 let url = tail.split(['"', '\'']).next().expect("a quoted url");
701 let (name, suffix) = url.split_once("?v=").unwrap_or((url, ""));
702 assert!(
703 root.join("static").join(name).exists(),
704 "{name} is linked but not vendored"
705 );
706 assert_eq!(suffix, HTMX, "{name} is busted by the wrong version");
707 linked += 1;
708 }
709 }
710 assert_eq!(linked, 2, "the vendored extension count changed");
711 }
712
713 #[test]
714 fn the_htmx_config_is_stated_above_the_script_that_reads_it() {
715 // htmx reads `meta[name=htmx-config]` once, as the script runs. Below
716 // it the tag is inert and 4xx responses start swapping a rendered error
717 // page into whatever the request targeted.
718 let head = head();
719 let meta = head
720 .find("name=\"htmx-config\"")
721 .expect("the config is stated");
722 assert!(meta < head.find("htmx.min.js").expect("htmx is linked"));
723 assert!(head.contains("\"noSwap\""));
724 }
725 }
726