max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+170 insertions,
-54 deletions
| @@ -44,7 +44,6 @@ | |||
| 44 | 44 | .sqlx/ | |
| 45 | 45 | ||
| 46 | 46 | # Generated template partials (build.rs output) | |
| 47 | - | server/templates/_head_assets.html | |
| 48 | 47 | server/templates/_island.html | |
| 49 | 48 | server/templates/_sheet.html | |
| 50 | 49 |
| @@ -56,9 +56,9 @@ | |||
| 56 | 56 | // busting browser caches automatically. | |
| 57 | 57 | let static_files = [ | |
| 58 | 58 | "static/style.css", | |
| 59 | - | // The two per-page sheets. Linked from page templates rather than from | |
| 60 | - | // _head_assets.html, which is why they were outside the fingerprint | |
| 61 | - | // and served stale to any browser holding a cached copy. | |
| 59 | + | // The two per-page sheets. Linked from page templates rather than | |
| 60 | + | // from the head, which is why they were outside the fingerprint and | |
| 61 | + | // served stale to any browser holding a cached copy. | |
| 62 | 62 | "static/wizard.css", | |
| 63 | 63 | "static/media-player.css", | |
| 64 | 64 | "static/htmx.min.js", | |
| @@ -98,45 +98,11 @@ | |||
| 98 | 98 | let static_hash = format!("{:016x}", hasher.finish()); | |
| 99 | 99 | let version = &static_hash[..8]; | |
| 100 | 100 | ||
| 101 | - | // Generate a template partial with versioned asset URLs. | |
| 102 | - | // base.html includes this via {% include "_head_assets.html" %} | |
| 103 | - | let partial = format!( | |
| 104 | - | r#" <link rel="preload" href="/static/fonts/Lato-Regular.woff2" as="font" type="font/woff2" crossorigin> | |
| 105 | - | <link rel="preload" href="/static/fonts/ysrf.woff2" as="font" type="font/woff2" crossorigin> | |
| 106 | - | <!-- Cascade layer order, declared before any stylesheet so it is a statement | |
| 107 | - | rather than an accident of link order. A layer's position is fixed where | |
| 108 | - | its name is FIRST seen, so without this the two generated sheets below | |
| 109 | - | would establish `makeover` simply by loading first, and reordering these | |
| 110 | - | links would silently reorder the cascade. It also has to precede the | |
| 111 | - | per-page sheets (wizard.css, media-player.css), which this partial does | |
| 112 | - | not link and which base.html therefore cannot order on its own. | |
| 113 | - | ||
| 114 | - | Earlier in the list = lower priority. `makeover` is first because the | |
| 115 | - | design system is what the site overrides, never the reverse. | |
| 116 | - | ||
| 117 | - | `components` is where the site's own sheets live: style.css, and the | |
| 118 | - | per-page wizard.css and media-player.css. It sits after `makeover`, so | |
| 119 | - | those sheets win the contests they were already winning when they were | |
| 120 | - | unlayered, and they now win them by a stated rule. A layer resolves | |
| 121 | - | internally by source order, so a per-page sheet still beats style.css | |
| 122 | - | on a tie exactly as before. | |
| 123 | - | ||
| 124 | - | Unlayered rules still beat every named layer, and one thing relies on | |
| 125 | - | that: the theme block theming.rs injects into <head> is unlayered, so a | |
| 126 | - | creator's chosen theme outranks all of the above without knowing this | |
| 127 | - | order. `base` and `responsive` are still empty; declaring an empty | |
| 128 | - | layer costs nothing and fixes its position. --> | |
| 129 | - | <style>@layer makeover, base, components, responsive;</style> | |
| 130 | - | <link rel="stylesheet" href="/static/geometry.css?v={version}"> | |
| 131 | - | <link rel="stylesheet" href="/static/layout.css?v={version}"> | |
| 132 | - | <link rel="stylesheet" href="/static/style.css?v={version}"> | |
| 133 | - | <link rel="icon" href="/static/images/favicon.ico" type="image/x-icon"> | |
| 134 | - | <script src="/static/htmx.min.js"></script> | |
| 135 | - | <script src="/static/upload.js?v={version}"></script> | |
| 136 | - | <script type="module" src="/static/dist/core/index.js?v={version}"></script>"#, | |
| 137 | - | ); | |
| 138 | - | ||
| 139 | - | write_if_changed(Path::new("templates/_head_assets.html"), &partial); | |
| 101 | + | // The head's own assets are no longer a generated partial: crate::shell | |
| 102 | + | // builds them into quasi-webview's Shell, which is what a described screen | |
| 103 | + | // renders through, so both halves of the converted site emit one head. All | |
| 104 | + | // that crosses the build boundary now is the version. | |
| 105 | + | println!("cargo::rustc-env=STATIC_VERSION={version}"); | |
| 140 | 106 | ||
| 141 | 107 | // Per-page island loader macro. Heavy/page-specific islands (media player, | |
| 142 | 108 | // uploader, ...) load on the pages that use them via | |
| @@ -151,7 +117,7 @@ | |||
| 151 | 117 | ||
| 152 | 118 | // Per-page stylesheet loader, same idea as the island macro. wizard.css and | |
| 153 | 119 | // media-player.css are linked by the pages that need them rather than by | |
| 154 | - | // _head_assets.html, so this is how they get the content hash. | |
| 120 | + | // the shell, so this is how they get the content hash. | |
| 155 | 121 | let sheet_partial = r#"{% macro sheet(name) -%} | |
| 156 | 122 | <link rel="stylesheet" href="/static/{{ name }}?v=__VER__"> | |
| 157 | 123 | {%- endmacro %} |
| @@ -56,6 +56,7 @@ | |||
| 56 | 56 | pub mod scheduler; | |
| 57 | 57 | pub mod security_signals; | |
| 58 | 58 | pub mod seed; | |
| 59 | + | pub mod shell; | |
| 59 | 60 | pub mod site_docs; | |
| 60 | 61 | pub mod storage; | |
| 61 | 62 | pub mod synckit_auth; |
| @@ -1,8 +1,10 @@ | |||
| 1 | - | <!DOCTYPE html> | |
| 2 | - | <html lang="en"> | |
| 3 | - | <head> | |
| 4 | - | <meta charset="UTF-8"> | |
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 1 | + | {# Everything from <!doctype> down to the last script in the head is quasi's, | |
| 2 | + | emitted by the same shell a described screen renders through. See | |
| 3 | + | crate::shell: the site is being converted a screen at a time, and two heads | |
| 4 | + | written by two hands drift in ways nothing catches. What is left here is | |
| 5 | + | what a template has to own, because Askama renders these blocks in place and | |
| 6 | + | no caller ever holds them as a string. #} | |
| 7 | + | {{ crate::shell::head()|safe }} | |
| 6 | 8 | {% if let Some(token) = csrf_token %}<meta name="csrf-token" content="{{ token }}">{% endif %} | |
| 7 | 9 | <title>{% block title %}Makenotwork{% endblock %}</title> | |
| 8 | 10 | <meta name="description" content="{% block meta_description %}Sell your work directly. 0% platform fee, only Stripe's ~3% processing. Music, software, writing, and more.{% endblock %}"> | |
| @@ -16,10 +18,9 @@ | |||
| 16 | 18 | <meta property="og:type" content="website"> | |
| 17 | 19 | <meta name="twitter:card" content="summary_large_image"> | |
| 18 | 20 | ||
| 19 | - | {% include "_head_assets.html" %} | |
| 20 | 21 | {% block head %}{% endblock %} | |
| 21 | 22 | </head> | |
| 22 | - | <body{% block body_attrs %}{% endblock %}> | |
| 23 | + | <body{{ crate::shell::body_attrs()|safe }}{% block body_attrs %}{% endblock %}> | |
| 23 | 24 | <a href="#main-content" class="skip-to-main">Skip to main content</a> | |
| 24 | 25 | <main id="main-content"> | |
| 25 | 26 | {% block content %}{% endblock %} | |
| @@ -49,7 +50,7 @@ | |||
| 49 | 50 | <!-- Delegated on* handlers, extracted from inline attributes so the CSP can | |
| 50 | 51 | drop script-src 'unsafe-inline'. The data-action dispatcher + core | |
| 51 | 52 | primitives now live in the typed core module (frontend/src/core), | |
| 52 | - | loaded as an ES module in <head> via _head_assets.html; these classic | |
| 53 | + | loaded as an ES module in <head> via crate::shell; these classic | |
| 53 | 54 | shims resolve through its registry / window bridge. --> | |
| 54 | 55 | <script src="/static/actions-pages.js?v=0701"></script> | |
| 55 | 56 | <script src="/static/actions-partials.js?v=0701"></script> |
| @@ -5,7 +5,7 @@ | |||
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | 6 | <title>Buy {{ item.title }}: Makenotwork</title> | |
| 7 | 7 | {# Standalone head, so the three sheets are listed here rather than coming | |
| 8 | - | from _head_assets.html. Order matters: style.css reads --bevel-raised / | |
| 8 | + | from crate::shell. Order matters: style.css reads --bevel-raised / | |
| 9 | 9 | --bevel-inset out of layout.css, and a box-shadow naming an undefined | |
| 10 | 10 | var drops the whole declaration instead of degrading. #} | |
| 11 | 11 | <link rel="stylesheet" href="/static/geometry.css"> |
| @@ -671,7 +671,7 @@ | |||
| 671 | 671 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 672 | 672 | <title>Payment Complete | Makenotwork</title> | |
| 673 | 673 | <!-- Standalone head, so the three sheets are listed here rather than | |
| 674 | - | coming from _head_assets.html. style.css reads the bevel pair out of | |
| 674 | + | coming from crate::shell. style.css reads the bevel pair out of | |
| 675 | 675 | layout.css, so it has to load after it. --> | |
| 676 | 676 | <link rel="stylesheet" href="/static/geometry.css"> | |
| 677 | 677 | <link rel="stylesheet" href="/static/layout.css"> |
| @@ -1,0 +1,149 @@ | |||
| 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 that loads | |
| 8 | + | //! without the morph extension, a viewport that says one thing on half the | |
| 9 | + | //! site. So the head is the renderer's on both paths from here, before any | |
| 10 | + | //! 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. It used to reach the head through a generated | |
| 32 | + | /// `_head_assets.html`; the head is the renderer's now, so the version is all | |
| 33 | + | /// that crosses. `_sheet.html` and `_island.html` are still generated with the | |
| 34 | + | /// same hash, for the per-page sheets and islands this module does not see. | |
| 35 | + | const V: &str = env!("STATIC_VERSION"); | |
| 36 | + | ||
| 37 | + | fn parts() -> &'static quasi_webview::Parts { | |
| 38 | + | static PARTS: OnceLock<quasi_webview::Parts> = OnceLock::new(); | |
| 39 | + | PARTS.get_or_init(|| { | |
| 40 | + | Shell::under("/static") | |
| 41 | + | // Earlier in the list = lower priority, and `makeover` is prepended | |
| 42 | + | // by the renderer. A layer's position is fixed where its name is | |
| 43 | + | // FIRST seen, so without the statement the generated sheets would | |
| 44 | + | // establish `makeover` simply by loading first and reordering two | |
| 45 | + | // links would silently reorder the cascade. `components` is where | |
| 46 | + | // the site's own sheets live, including the per-page wizard.css and | |
| 47 | + | // media-player.css that arrive later through `{% block head %}` and | |
| 48 | + | // that nothing here can order. `base` and `responsive` are still | |
| 49 | + | // empty; declaring an empty layer costs nothing and fixes its | |
| 50 | + | // position. | |
| 51 | + | .layered(["base", "components", "responsive"]) | |
| 52 | + | // Whole value is being early: a preload discovered after the sheets | |
| 53 | + | // it races bought nothing. | |
| 54 | + | .with_head_first( | |
| 55 | + | "<link rel=\"preload\" href=\"/static/fonts/Lato-Regular.woff2\" as=\"font\" type=\"font/woff2\" crossorigin>\ | |
| 56 | + | <link rel=\"preload\" href=\"/static/fonts/ysrf.woff2\" as=\"font\" type=\"font/woff2\" crossorigin>", | |
| 57 | + | ) | |
| 58 | + | .styled(format!("/static/geometry.css?v={V}")) | |
| 59 | + | .styled(format!("/static/layout.css?v={V}")) | |
| 60 | + | .styled(format!("/static/style.css?v={V}")) | |
| 61 | + | // Last in the head, after htmx: the favicon has no order to keep, | |
| 62 | + | // and neither script reads htmx at load. `upload.js` only defines | |
| 63 | + | // `S3Uploader`, and the core module is deferred by being a module, | |
| 64 | + | // so it still runs after the deferred htmx above it. | |
| 65 | + | .with_head(format!( | |
| 66 | + | "<link rel=\"icon\" href=\"/static/images/favicon.ico\" type=\"image/x-icon\">\ | |
| 67 | + | <script src=\"/static/upload.js?v={V}\"></script>\ | |
| 68 | + | <script type=\"module\" src=\"/static/dist/core/index.js?v={V}\"></script>" | |
| 69 | + | )) | |
| 70 | + | .parts() | |
| 71 | + | }) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// `<!doctype>` through the head's contents, without `</head>`. | |
| 75 | + | /// | |
| 76 | + | /// Called from `base.html`, which appends the title and the per-page head and | |
| 77 | + | /// closes the element. | |
| 78 | + | pub fn head() -> &'static str { | |
| 79 | + | &parts().head | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// The attributes the shell owns on `<body>`, each one space-prefixed. | |
| 83 | + | /// | |
| 84 | + | /// `base.html` writes `<body{{ body_attrs() }}{% block body_attrs %}>`, so a | |
| 85 | + | /// page's own class attribute composes with these instead of replacing them. | |
| 86 | + | pub fn body_attrs() -> &'static str { | |
| 87 | + | &parts().body_attrs | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | #[cfg(test)] | |
| 91 | + | mod tests { | |
| 92 | + | use super::*; | |
| 93 | + | ||
| 94 | + | #[test] | |
| 95 | + | fn the_layer_statement_precedes_every_stylesheet() { | |
| 96 | + | // The property the hand-written `<style>@layer ...</style>` existed to | |
| 97 | + | // hold, now held by the renderer. It is the one that fails silently: | |
| 98 | + | // the CSS stays valid and buttons and badges look subtly wrong. | |
| 99 | + | let head = head(); | |
| 100 | + | let stmt = head | |
| 101 | + | .find("@layer makeover, base, components, responsive;") | |
| 102 | + | .expect("the order is stated"); | |
| 103 | + | for sheet in ["geometry.css", "layout.css", "style.css"] { | |
| 104 | + | assert!(stmt < head.find(sheet).expect("the sheet is linked")); | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | #[test] | |
| 109 | + | fn the_head_is_not_closed_and_carries_no_title() { | |
| 110 | + | // Both are `base.html`'s, and emitting either here would produce a | |
| 111 | + | // second one rather than an error. | |
| 112 | + | assert!(!head().contains("</head>")); | |
| 113 | + | assert!(!head().contains("<title>")); | |
| 114 | + | assert!(head().starts_with("<!doctype html><html lang=\"en\">")); | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | #[test] | |
| 118 | + | fn the_fonts_are_preloaded_before_the_sheets_that_race_them() { | |
| 119 | + | let head = head(); | |
| 120 | + | assert!(head.find("Lato-Regular.woff2") < head.find("style.css")); | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | #[test] | |
| 124 | + | fn the_body_attributes_start_with_a_space_so_a_page_can_add_its_own() { | |
| 125 | + | // `base.html` writes them straight against `<body`, and a page's | |
| 126 | + | // `{% block body_attrs %}` straight after. Neither side puts a | |
| 127 | + | // separator in, so this one has to carry it. | |
| 128 | + | let attrs = body_attrs(); | |
| 129 | + | assert!(attrs.starts_with(' ')); | |
| 130 | + | assert!(attrs.contains("hx-ext=\"morph\"")); | |
| 131 | + | // No class of its own, or a page's class attribute would be the second | |
| 132 | + | // on the tag and the browser would drop it. | |
| 133 | + | assert!(!attrs.contains("class=")); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | #[test] | |
| 137 | + | fn morph_is_loaded_wherever_a_swap_can_ask_for_it() { | |
| 138 | + | // The shell writes `hx-ext="morph"` onto every page's body, so the | |
| 139 | + | // extension has to be served. It is: `static/idiomorph-ext.min.js`. | |
| 140 | + | assert!(head().contains("idiomorph-ext.min.js")); | |
| 141 | + | assert!( | |
| 142 | + | std::path::Path::new(concat!( | |
| 143 | + | env!("CARGO_MANIFEST_DIR"), | |
| 144 | + | "/static/idiomorph-ext.min.js" | |
| 145 | + | )) | |
| 146 | + | .exists() | |
| 147 | + | ); | |
| 148 | + | } | |
| 149 | + | } |