Skip to main content

max / makenotwork

27.0 KB · 583 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 /// What every page on this site ends with: the toast container and the classic
143 /// script shims the `data-action` dispatcher resolves through.
144 ///
145 /// Called by `base.html` for the templated pages and handed to
146 /// [`quasi_webview::Shell::with_body_last`] by a screen that owns its own
147 /// document, so the tail is written once. Markup no description will ever name
148 /// -- a script tag, and a container another script writes into.
149 #[must_use]
150 pub fn body_last() -> &'static str {
151 concat!(
152 "<div id=\"notifications\" class=\"toast-container\" role=\"alert\" aria-live=\"polite\"></div>",
153 "<script src=\"/static/actions-pages.js?v=0701\"></script>",
154 "<script src=\"/static/actions-partials.js?v=0701\"></script>",
155 "<script src=\"/static/actions-tabs.js?v=0701\"></script>",
156 "<script src=\"/static/actions-dashboards.js?v=0701\"></script>",
157 "<script src=\"/static/synckit-billing.js?v=0620\"></script>",
158 "<script src=\"/static/synckit-tabs.js?v=0623\"></script>",
159 "<script src=\"/static/markdown-editor.js?v=0820\"></script>",
160 )
161 }
162
163 /// The site header, as every page on this site carries it.
164 ///
165 /// Called by `partials/site_header.html` for the templated pages and handed to
166 /// [`quasi_webview::Shell::with_body_first`] by a screen that owns its own
167 /// document, so the nav is written once. [`body_last`]'s argument one element
168 /// up.
169 ///
170 /// Not a `Chrome`. `Chrome::nav` is emitted before `<main>` and a
171 /// `Chrome::panel` after `</main>`, and this header's mobile menu is a checkbox
172 /// styling its siblings (`style.css`, `.nav-toggle-checkbox:checked ~ .header-search`
173 /// and `~ nav`), so the two halves have to be one element. Wiki note
174 /// `mnw-server-conversion-plan`, the 2026-08-31 ruling.
175 ///
176 /// Nothing a reader wrote is emitted here: the only per-request values are
177 /// `user.is_admin` and the session's CSRF token, and the token is escaped
178 /// anyway rather than trusted for being ours.
179 #[must_use]
180 pub fn site_header(user: Option<&crate::auth::SessionUser>, csrf: Option<&str>) -> String {
181 let mut out = String::with_capacity(1600);
182 out.push_str(concat!(
183 "<header class=\"site-header\" role=\"banner\">",
184 "<a href=\"/\" class=\"site-logo\" aria-label=\"Makenotwork - Go to homepage\">",
185 "Makenot<span class=\"dot\" aria-hidden=\"true\">.</span>work</a>",
186 "<input type=\"checkbox\" id=\"nav-toggle\" class=\"nav-toggle-checkbox\" aria-hidden=\"true\">",
187 "<label for=\"nav-toggle\" class=\"nav-toggle-label\" aria-label=\"Toggle navigation menu\">",
188 "<span></span><span></span><span></span></label>",
189 "<form action=\"/discover\" method=\"get\" class=\"header-search\" role=\"search\">",
190 "<input type=\"search\" name=\"q\" id=\"header-search-input\" ",
191 "placeholder=\"Search... (Cmd+K)\" autocomplete=\"off\" ",
192 "aria-label=\"Search items and projects\"></form>",
193 "<nav aria-label=\"Main navigation\"><div class=\"nav-links\">",
194 "<a href=\"/discover\">Discover</a>",
195 ));
196 if let Some(user) = user {
197 out.push_str(concat!(
198 "<a href=\"/library\">Library</a>",
199 "<a href=\"/dashboard\">Dashboard</a>",
200 "<a href=\"/cart\" id=\"nav-cart-link\" class=\"hidden\">Cart",
201 "<span id=\"cart-badge\" class=\"cart-badge-count\"></span></a>",
202 ));
203 if user.is_admin {
204 out.push_str("<a href=\"/admin/waitlist\">Admin</a>");
205 }
206 out.push_str("<form action=\"/logout\" method=\"post\" class=\"nav-form\">");
207 if let Some(token) = csrf {
208 out.push_str("<input type=\"hidden\" name=\"_csrf\" value=\"");
209 out.push_str(&crate::helpers::escape_html(token));
210 out.push_str("\">");
211 }
212 out.push_str(concat!(
213 "<button type=\"submit\" class=\"btn--link\" aria-label=\"Log out\">Log Out</button>",
214 "</form>",
215 ));
216 } else {
217 out.push_str(concat!(
218 "<a href=\"/use-cases\">Use Cases</a>",
219 "<a href=\"/docs\">Docs</a>",
220 "<a href=\"/fan-plus\">Fan+</a>",
221 "<a href=\"/login\">Login</a>",
222 "<a href=\"/join\">Join</a>",
223 ));
224 }
225 out.push_str("</div></nav></header>");
226 out
227 }
228
229 /// `<!doctype>` through the head's contents, without `</head>`.
230 ///
231 /// Called from `base.html`, which appends the title and the per-page head and
232 /// closes the element.
233 pub fn head() -> &'static str {
234 &parts().head
235 }
236
237 /// The attributes the shell owns on `<body>`, each one space-prefixed.
238 ///
239 /// `base.html` writes `<body{{ body_attrs() }}{% block body_attrs %}>`, so a
240 /// page's own class attribute composes with these instead of replacing them.
241 pub fn body_attrs() -> &'static str {
242 &parts().body_attrs
243 }
244
245 pub use makeover_layout::Measure;
246
247 /// The body class for a screen's measure.
248 ///
249 /// How wide a page runs is a property of the screen, not a literal in its
250 /// template: name the measure here rather than writing one of these class
251 /// strings into markup.
252 ///
253 /// The class names are what `style.css` matches, so they stay as they are.
254 /// `padded-page` is [`Measure::Wide`]: a padded page is the full width with
255 /// gutters.
256 ///
257 /// The four standalone tokens (`health-page`, `purchase-page`, `buy-page`,
258 /// `stripe-disclaimer-page`) are screen identity rather than measure, and are
259 /// deliberately not here.
260 #[must_use]
261 pub const fn measure(measure: Measure) -> &'static str {
262 match measure {
263 Measure::Contained => "centered-page",
264 Measure::Reading => "article-page",
265 // The default, and the arm a member added upstream lands in. A measure
266 // this server has not learned yet should render at the width every
267 // other page does rather than unstyled.
268 _ => "padded-page",
269 }
270 }
271
272 /// The `<body>` class a screen that owns its document carries: its measure
273 /// first, then whatever else its template said beside it.
274 ///
275 /// [`quasi_router::Document::classed`] replaces rather than appends, so a
276 /// screen with a grouping or identity token of its own hands over one string.
277 /// Composing it here is what stops a screen that meant to add `feed-page` from
278 /// dropping `padded-page` on the way.
279 ///
280 /// The global half is not here. `Shell` carries what is true of every document
281 /// and the renderer writes the two beside each other, the same composition
282 /// `base.html` does with [`body_attrs`].
283 #[must_use]
284 pub fn body_class(page: Measure, own: &[&str]) -> String {
285 let mut class = String::from(measure(page));
286 for token in own {
287 class.push(' ');
288 class.push_str(token);
289 }
290 class
291 }
292
293 #[cfg(test)]
294 mod tests {
295 use super::*;
296
297 #[test]
298 fn the_layer_statement_precedes_every_stylesheet() {
299 // The property the hand-written `<style>@layer ...</style>` existed to
300 // hold, now held by the renderer. It is the one that fails silently:
301 // the CSS stays valid and buttons and badges look subtly wrong.
302 let head = head();
303 let stmt = head
304 .find("@layer makeover, base, components, responsive;")
305 .expect("the order is stated");
306 for sheet in [
307 "typography.css",
308 "geometry.css",
309 "timing.css",
310 "layout.css",
311 "style.css",
312 ] {
313 assert!(stmt < head.find(sheet).expect("the sheet is linked"));
314 }
315 }
316
317 #[test]
318 fn the_head_is_not_closed_and_carries_no_title() {
319 // Both are `base.html`'s, and emitting either here would produce a
320 // second one rather than an error.
321 assert!(!head().contains("</head>"));
322 assert!(!head().contains("<title>"));
323 assert!(head().starts_with("<!doctype html><html lang=\"en\">"));
324 }
325
326 #[test]
327 fn the_fonts_are_preloaded_before_the_sheets_that_race_them() {
328 let head = head();
329 assert!(head.find("QuasiBody.woff2") < head.find("style.css"));
330 // The retired pair, checked by absence: a preload for a face nothing
331 // declares is a download the browser makes and never uses.
332 assert!(!head.contains("Lato"));
333 assert!(!head.contains("IBMPlexMono"));
334 }
335
336 #[test]
337 fn the_body_attributes_start_with_a_space_so_a_page_can_add_its_own() {
338 // `base.html` writes them straight against `<body`, and a page's
339 // `{% block body_attrs %}` straight after. Neither side puts a
340 // separator in, so this one has to carry it.
341 let attrs = body_attrs();
342 assert!(attrs.is_empty() || attrs.starts_with(' '));
343 // No class of its own, or a page's class attribute would be the second
344 // on the tag and the browser would drop it.
345 assert!(!attrs.contains("class="));
346 }
347
348 #[test]
349 fn every_measure_keeps_the_class_the_templates_used_to_write() {
350 // `0eccff0d` moved where the choice is written down and changed no
351 // rule in `style.css`, so the three strings have to come out exactly as
352 // the 69 templates spelled them. A typo here renders 53 pages unstyled.
353 assert_eq!(measure(Measure::Wide), "padded-page");
354 assert_eq!(measure(Measure::Contained), "centered-page");
355 assert_eq!(measure(Measure::Reading), "article-page");
356 }
357
358 #[test]
359 fn a_screen_with_nothing_of_its_own_carries_only_its_measure() {
360 assert_eq!(body_class(Measure::Contained, &[]), "centered-page");
361 }
362
363 #[test]
364 fn a_screens_own_token_lands_beside_its_measure_rather_than_instead_of_it() {
365 // The exact string `templates/pages/feed.html` renders today, which is
366 // the parity the next conversion has to hold.
367 assert_eq!(
368 body_class(Measure::Wide, &["feed-page"]),
369 "padded-page feed-page"
370 );
371 assert_eq!(
372 body_class(Measure::Wide, &["dashboard-page", "dashboard-user-page"]),
373 "padded-page dashboard-page dashboard-user-page"
374 );
375 }
376
377 /// A reader with no session is offered the two ways in and no way out.
378 #[test]
379 fn the_signed_out_header_offers_the_ways_in() {
380 let html = site_header(None, None);
381
382 assert!(html.contains("href=\"/login\""), "{html}");
383 assert!(html.contains("href=\"/join\""), "{html}");
384 assert!(html.contains("href=\"/discover\""), "{html}");
385 assert!(!html.contains("/logout"), "{html}");
386 assert!(!html.contains("/dashboard"), "{html}");
387 }
388
389 /// A signed-in reader gets the account nav, the logout form and the token
390 /// that form needs. A logout with no `_csrf` is a logout the envelope
391 /// refuses.
392 #[test]
393 fn the_signed_in_header_carries_the_logout_form_and_its_token() {
394 let html = site_header(Some(&a_user(false)), Some("tok&en"));
395
396 assert!(html.contains("action=\"/logout\""), "{html}");
397 assert!(
398 html.contains("name=\"_csrf\" value=\"tok&amp;en\""),
399 "{html}"
400 );
401 assert!(html.contains("href=\"/library\""), "{html}");
402 assert!(html.contains("href=\"/dashboard\""), "{html}");
403 assert!(!html.contains("href=\"/login\""), "{html}");
404 }
405
406 /// The admin link is the one thing in the nav that is a permission rather
407 /// than a session.
408 #[test]
409 fn only_an_admin_is_offered_the_admin_link() {
410 assert!(site_header(Some(&a_user(true)), Some("t")).contains("/admin/waitlist"));
411 assert!(!site_header(Some(&a_user(false)), Some("t")).contains("/admin/waitlist"));
412 }
413
414 /// The mobile menu is a checkbox styling its siblings, which is the whole
415 /// reason this header is one element rather than a `Chrome`. The three have
416 /// to stay siblings inside `<header>`, in this order.
417 #[test]
418 fn the_disclosure_stays_a_sibling_of_what_it_reveals() {
419 let html = site_header(None, None);
420
421 let toggle = html.find("nav-toggle-checkbox").expect("the checkbox");
422 let search = html.find("header-search").expect("the search form");
423 let nav = html.find("<nav").expect("the nav");
424 assert!(toggle < search && search < nav, "{html}");
425 assert!(html.ends_with("</header>"), "{html}");
426 }
427
428 fn a_user(is_admin: bool) -> crate::auth::SessionUser {
429 crate::auth::SessionUser {
430 id: crate::db::UserId::new(),
431 username: crate::db::Username::from_trusted("areader".to_string()),
432 email: "areader@example.com".to_string(),
433 display_name: None,
434 can_create_projects: false,
435 suspended: false,
436 is_admin,
437 is_fan_plus: false,
438 creator_tier: None,
439 deactivated: false,
440 is_sandbox: false,
441 settlement_currency: crate::currency::SettlementCurrency::Usd,
442 conversion_preference: crate::currency::ConversionChoice::AtCheckout,
443 }
444 }
445
446 #[test]
447 fn no_template_still_writes_a_layout_class_by_hand() {
448 // The done-condition, checked rather than remembered: the layout axis
449 // is derived from the described property. A new template pasted from an
450 // old one fails here instead of quietly reintroducing the literal.
451 //
452 // The four standalone tokens are screen identity rather than measure
453 // and are deliberately left alone, so they are not looked for.
454 let mut offenders = Vec::new();
455 for entry in walk("templates", "html") {
456 let source = std::fs::read_to_string(&entry).expect("a template reads");
457 for (at, line) in source.lines().enumerate() {
458 if !line.contains("block body_attrs") {
459 continue;
460 }
461 // The literal, as distinct from the call that produces it: the
462 // rendered class still says `padded-page`, and should.
463 let derived = line.contains("crate::shell::measure(");
464 let literal = ["padded-page", "centered-page", "article-page"]
465 .iter()
466 .any(|name| line.contains(name));
467 if literal && !derived {
468 offenders.push(format!("{}:{}", entry.display(), at + 1));
469 }
470 }
471 }
472 assert!(offenders.is_empty(), "{offenders:?}");
473 }
474
475 #[test]
476 fn no_described_screen_writes_a_layout_class_by_hand() {
477 // The screen-side twin of the template check above: a converted screen
478 // states its measure and reads the class off it, so a literal reaching
479 // `Document::classed` is a screen that will not follow `style.css` when
480 // the mapping moves. The four standalone identity tokens and the embed
481 // classes are screen identity rather than measure and are not looked
482 // for.
483 let mut offenders = Vec::new();
484 for entry in walk("src", "rs") {
485 // This file states the three strings once, which is the point of it.
486 if entry.ends_with("shell.rs") {
487 continue;
488 }
489 let source = std::fs::read_to_string(&entry).expect("a source file reads");
490 for (at, line) in source.lines().enumerate() {
491 let literal = ["padded-page", "centered-page", "article-page"]
492 .iter()
493 .any(|name| line.contains(name));
494 if line.contains("classed(") && literal {
495 offenders.push(format!("{}:{}", entry.display(), at + 1));
496 }
497 }
498 }
499 assert!(offenders.is_empty(), "{offenders:?}");
500 }
501
502 /// Every file with the given extension under a directory.
503 fn walk(root: &str, wanted: &str) -> Vec<std::path::PathBuf> {
504 let mut found = Vec::new();
505 let mut stack = vec![std::path::PathBuf::from(root)];
506 while let Some(at) = stack.pop() {
507 let Ok(entries) = std::fs::read_dir(&at) else {
508 continue;
509 };
510 for entry in entries.flatten() {
511 let path = entry.path();
512 if path.is_dir() {
513 stack.push(path);
514 } else if path.extension().is_some_and(|ext| ext == wanted) {
515 found.push(path);
516 }
517 }
518 }
519 found
520 }
521
522 #[test]
523 fn the_transport_the_head_links_is_served_from_here() {
524 // The shell links one script for the transport. Morphing is a swap
525 // style in htmx 4 rather than something an extension supplies, so the
526 // idiomorph registration and the extension script both left the head
527 // when quasi-webview moved to 4 (`2246072d`, ruled 2026-08-18), and the
528 // vendored file left the tree with `6b87e5ff`.
529 assert!(head().contains("htmx.min.js"));
530 assert!(!head().contains("idiomorph"));
531 let vendored = concat!(env!("CARGO_MANIFEST_DIR"), "/static/htmx.min.js");
532 assert!(std::path::Path::new(vendored).exists());
533 // The served bundle and the markup the templates carry are one version
534 // or the other, never a mix: 4 reads `hx-disable` as "disable while the
535 // request runs" where 2 read it as "skip this subtree".
536 let bundle = std::fs::read_to_string(vendored).expect("the bundle reads");
537 assert!(bundle.contains(HTMX), "the vendored bundle is {HTMX}");
538 }
539
540 #[test]
541 fn every_vendored_extension_is_busted_by_the_htmx_release_it_came_from() {
542 // Two extensions are vendored out of the htmx release: the history
543 // cache the shell links site-wide, and `hx-prompt`, which one admin
544 // template links for itself. Both are pinned files, so the release is
545 // their version, and a bump that leaves a `?v=` behind serves a browser
546 // the old extension against the new core.
547 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
548 let mut linked = 0;
549 let mut sources = vec![head().to_string()];
550 for entry in walk("templates", "html") {
551 sources.push(std::fs::read_to_string(&entry).expect("a template reads"));
552 }
553 for source in &sources {
554 for (at, _) in source.match_indices("/static/hx-") {
555 let tail = &source[at + "/static/".len()..];
556 // The URL alone, cut at whichever quote closes the attribute.
557 let url = tail.split(['"', '\'']).next().expect("a quoted url");
558 let (name, suffix) = url.split_once("?v=").unwrap_or((url, ""));
559 assert!(
560 root.join("static").join(name).exists(),
561 "{name} is linked but not vendored"
562 );
563 assert_eq!(suffix, HTMX, "{name} is busted by the wrong version");
564 linked += 1;
565 }
566 }
567 assert_eq!(linked, 2, "the vendored extension count changed");
568 }
569
570 #[test]
571 fn the_htmx_config_is_stated_above_the_script_that_reads_it() {
572 // htmx reads `meta[name=htmx-config]` once, as the script runs. Below
573 // it the tag is inert and 4xx responses start swapping a rendered error
574 // page into whatever the request targeted.
575 let head = head();
576 let meta = head
577 .find("name=\"htmx-config\"")
578 .expect("the config is stated");
579 assert!(meta < head.find("htmx.min.js").expect("htmx is linked"));
580 assert!(head.contains("\"noSwap\""));
581 }
582 }
583