max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01WHu5vBgpCULUFXsEJBHryC
12 files changed,
+613 insertions,
-291 deletions
| @@ -6636,7 +6636,7 @@ | |||
| 6636 | 6636 | ||
| 6637 | 6637 | [[package]] | |
| 6638 | 6638 | name = "quasi-axum" | |
| 6639 | - | version = "0.92.0" | |
| 6639 | + | version = "0.93.0" | |
| 6640 | 6640 | dependencies = [ | |
| 6641 | 6641 | "axum", | |
| 6642 | 6642 | "http 1.4.2", | |
| @@ -6647,7 +6647,7 @@ | |||
| 6647 | 6647 | ||
| 6648 | 6648 | [[package]] | |
| 6649 | 6649 | name = "quasi-basics" | |
| 6650 | - | version = "0.92.0" | |
| 6650 | + | version = "0.93.0" | |
| 6651 | 6651 | dependencies = [ | |
| 6652 | 6652 | "makeover-layout", | |
| 6653 | 6653 | "quasi-router", | |
| @@ -6655,7 +6655,7 @@ | |||
| 6655 | 6655 | ||
| 6656 | 6656 | [[package]] | |
| 6657 | 6657 | name = "quasi-http" | |
| 6658 | - | version = "0.92.0" | |
| 6658 | + | version = "0.93.0" | |
| 6659 | 6659 | dependencies = [ | |
| 6660 | 6660 | "form_urlencoded", | |
| 6661 | 6661 | "http 1.4.2", | |
| @@ -6664,7 +6664,7 @@ | |||
| 6664 | 6664 | ||
| 6665 | 6665 | [[package]] | |
| 6666 | 6666 | name = "quasi-router" | |
| 6667 | - | version = "0.92.0" | |
| 6667 | + | version = "0.93.0" | |
| 6668 | 6668 | dependencies = [ | |
| 6669 | 6669 | "makeover-layout", | |
| 6670 | 6670 | ] | |
| @@ -6687,7 +6687,7 @@ | |||
| 6687 | 6687 | ||
| 6688 | 6688 | [[package]] | |
| 6689 | 6689 | name = "quasi-webview" | |
| 6690 | - | version = "0.92.0" | |
| 6690 | + | version = "0.93.0" | |
| 6691 | 6691 | dependencies = [ | |
| 6692 | 6692 | "docengine", | |
| 6693 | 6693 | "makeover-layout", | |
| @@ -10759,30 +10759,6 @@ | |||
| 10759 | 10759 | "pkg-config", | |
| 10760 | 10760 | ] | |
| 10761 | 10761 | ||
| 10762 | - | [[patch.unused]] | |
| 10763 | - | name = "quasi-immediate" | |
| 10764 | - | version = "0.92.0" | |
| 10765 | - | ||
| 10766 | - | [[patch.unused]] | |
| 10767 | - | name = "quasi-notifs" | |
| 10768 | - | version = "0.92.0" | |
| 10769 | - | ||
| 10770 | - | [[patch.unused]] | |
| 10771 | - | name = "quasi-store" | |
| 10772 | - | version = "0.1.0" | |
| 10773 | - | ||
| 10774 | - | [[patch.unused]] | |
| 10775 | - | name = "quasi-tauri" | |
| 10776 | - | version = "0.92.0" | |
| 10777 | - | ||
| 10778 | - | [[patch.unused]] | |
| 10779 | - | name = "synckit-client" | |
| 10780 | - | version = "0.10.0" | |
| 10781 | - | ||
| 10782 | - | [[patch.unused]] | |
| 10783 | - | name = "synckit-config" | |
| 10784 | - | version = "0.2.0" | |
| 10785 | - | ||
| 10786 | 10762 | [[patch.unused]] | |
| 10787 | 10763 | name = "kberg" | |
| 10788 | 10764 | version = "0.1.0" | |
| @@ -10794,3 +10770,27 @@ | |||
| 10794 | 10770 | [[patch.unused]] | |
| 10795 | 10771 | name = "painhours" | |
| 10796 | 10772 | version = "0.1.0" | |
| 10773 | + | ||
| 10774 | + | [[patch.unused]] | |
| 10775 | + | name = "quasi-immediate" | |
| 10776 | + | version = "0.93.0" | |
| 10777 | + | ||
| 10778 | + | [[patch.unused]] | |
| 10779 | + | name = "quasi-notifs" | |
| 10780 | + | version = "0.93.0" | |
| 10781 | + | ||
| 10782 | + | [[patch.unused]] | |
| 10783 | + | name = "quasi-store" | |
| 10784 | + | version = "0.1.0" | |
| 10785 | + | ||
| 10786 | + | [[patch.unused]] | |
| 10787 | + | name = "quasi-tauri" | |
| 10788 | + | version = "0.93.0" | |
| 10789 | + | ||
| 10790 | + | [[patch.unused]] | |
| 10791 | + | name = "synckit-client" | |
| 10792 | + | version = "0.10.0" | |
| 10793 | + | ||
| 10794 | + | [[patch.unused]] | |
| 10795 | + | name = "synckit-config" | |
| 10796 | + | version = "0.2.0" |
| @@ -163,7 +163,7 @@ | |||
| 163 | 163 | # through quasi beside its Askama equivalent, so the cost of describing a screen | |
| 164 | 164 | # here is measured rather than argued. Not load-bearing for any shipped route. | |
| 165 | 165 | # See wiki look-wave-2, tier G. | |
| 166 | - | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.92" } | |
| 166 | + | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.93" } | |
| 167 | 167 | # The description vocabulary quasi's screen types are built from. Pinned here | |
| 168 | 168 | # rather than reached through quasi-router's re-export because a described | |
| 169 | 169 | # screen names FieldKind and Tone directly; it has to track what quasi-router | |
| @@ -179,13 +179,13 @@ | |||
| 179 | 179 | # For the request head the per-viewer state factory reads. axum re-exports it, | |
| 180 | 180 | # but the factory's signature is quasi-axum's and names `http::request::Parts`. | |
| 181 | 181 | http = "1.3.1" | |
| 182 | - | quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.92" } | |
| 183 | - | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.92" } | |
| 182 | + | quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.93" } | |
| 183 | + | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.93" } | |
| 184 | 184 | # The first-party widget set. The carousel was proved here against three pages | |
| 185 | 185 | # and then moved there; what is left in `quasi/widgets/carousel.rs` is the | |
| 186 | 186 | # Askama glue. Not `makeover-basics`: a widget is an assembly of `Node`s, so it | |
| 187 | 187 | # sits above quasi-router rather than inside the description suite. | |
| 188 | - | quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.92" } | |
| 188 | + | quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.93" } | |
| 189 | 189 | ||
| 190 | 190 | # Git source browser | |
| 191 | 191 | gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] } | |
| @@ -277,7 +277,7 @@ | |||
| 277 | 277 | # Written from the crate rather than copied into the repo for the reason the | |
| 278 | 278 | # constants exist: a stale copy in a static directory is a silently dead bulk | |
| 279 | 279 | # bar, and the version has to be the one the emitter agrees with. | |
| 280 | - | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.92" } | |
| 280 | + | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.93" } | |
| 281 | 281 | ||
| 282 | 282 | [dev-dependencies] | |
| 283 | 283 | tower = { version = "0.5.3", features = ["util"] } |
| @@ -115,25 +115,15 @@ | |||
| 115 | 115 | // A document that does ask fails visibly on the first control pressed, | |
| 116 | 116 | // and nothing here asks: every control is an external link. | |
| 117 | 117 | // | |
| 118 | - | // Nine `without_` calls now. quasi 0.54.0 added the fill script, | |
| 119 | - | // 0.59.0 and 0.60.0 the reveal and repeat scripts, 0.68.0 the copy | |
| 120 | - | // script, 0.73.0 the menu script and 0.79.0 the outline script. All six | |
| 121 | - | // are independent of htmx by | |
| 122 | - | // design — what they read is attributes on a control, a region or a | |
| 123 | - | // fieldset, or the room a run has — so dropping the transport does not | |
| 124 | - | // drop them, and each has to be refused by name. An embed names no | |
| 125 | - | // destination field, no conditional region, no repeating question, no | |
| 126 | - | // act that copies, no run that folds and no list that discloses, so all | |
| 127 | - | // six have nothing to read here. | |
| 128 | - | .without_htmx() | |
| 129 | - | .without_hyperscript() | |
| 130 | - | .without_clock() | |
| 131 | - | .without_fill() | |
| 132 | - | .without_reveal() | |
| 133 | - | .without_repeat() | |
| 134 | - | .without_copy() | |
| 135 | - | .without_menu() | |
| 136 | - | .without_outline() | |
| 118 | + | // This was nine `without_` calls, growing by one every time quasi | |
| 119 | + | // added a script: 0.54.0 the fill script, 0.59.0 and 0.60.0 reveal and | |
| 120 | + | // repeat, 0.68.0 copy, 0.73.0 menu, 0.79.0 outline. Each is | |
| 121 | + | // independent of htmx by design -- what they read is attributes on a | |
| 122 | + | // control, a region or a fieldset -- so dropping the transport never | |
| 123 | + | // dropped them and each had to be refused by name. quasi 0.92.0 says | |
| 124 | + | // it in one, which is the list living where the scripts do rather than | |
| 125 | + | // at every host that wanted none. | |
| 126 | + | .without_scripts() | |
| 137 | 127 | .with_chrome(Chrome::new()) | |
| 138 | 128 | .with_head_first(head_first()); | |
| 139 | 129 | Webview::new().with_shell(shell).screen(screen) |
| @@ -40,6 +40,7 @@ | |||
| 40 | 40 | ||
| 41 | 41 | pub mod blog_delete_act; | |
| 42 | 42 | pub mod buyer_contacts; | |
| 43 | + | pub mod custom_page; | |
| 43 | 44 | pub mod discover_search; | |
| 44 | 45 | pub mod discover_typeahead; | |
| 45 | 46 | pub mod embeds; |
| @@ -14,14 +14,20 @@ | |||
| 14 | 14 | //! Routing on this host (path under `u.makenot.work`): | |
| 15 | 15 | //! `/{handle}` -> profile, `/{handle}/{project}` -> project, | |
| 16 | 16 | //! `/{handle}/{project}/{item}` -> item. `/static/*` falls through to the | |
| 17 | - | //! normal app so chrome assets and primitives resolve. | |
| 17 | + | //! normal app, which is what serves a creator's own uploads and the favicon. | |
| 18 | + | //! The pages themselves link nothing from it: the strip's styling is inline | |
| 19 | + | //! and no script is loaded at all. | |
| 18 | 20 | //! | |
| 19 | 21 | //! Sanitization happens on render (Phase 2). The columns hold the creator's | |
| 20 | 22 | //! original source; a future write-time cache can pre-sanitize, but rendering | |
| 21 | 23 | //! through [`crate::custom_pages`] every time is the safe default and sits | |
| 22 | 24 | //! behind a 5-minute edge cache. | |
| 25 | + | //! | |
| 26 | + | //! The three documents are described rather than templated, as of quasicoherent | |
| 27 | + | //! `48a6e9e5`. This module resolves and sanitizes; `crate::quasi::custom_page` | |
| 28 | + | //! says what the pages are and draws them. `templates/custom/` is gone with the | |
| 29 | + | //! change, the chrome partials included. | |
| 23 | 30 | ||
| 24 | - | use askama::Template; | |
| 25 | 31 | use axum::{ | |
| 26 | 32 | body::Body, | |
| 27 | 33 | extract::State, | |
| @@ -34,57 +40,10 @@ | |||
| 34 | 40 | config::Config, | |
| 35 | 41 | custom_pages, | |
| 36 | 42 | db::{self, PricingKind, Slug, Username}, | |
| 43 | + | quasi::custom_page, | |
| 37 | 44 | }; | |
| 38 | 45 | use sqlx::PgPool; | |
| 39 | 46 | ||
| 40 | - | /// One entry in a project's file-list system slot. | |
| 41 | - | struct SlotItem { | |
| 42 | - | title: String, | |
| 43 | - | url: String, | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | #[derive(Template)] | |
| 47 | - | #[template(path = "custom/user.html")] | |
| 48 | - | struct UserPageTemplate { | |
| 49 | - | page_title: String, | |
| 50 | - | apex_url: String, | |
| 51 | - | canonical_url: String, | |
| 52 | - | creator_label: String, | |
| 53 | - | canvas_id: String, | |
| 54 | - | sanitized_css: String, | |
| 55 | - | sanitized_html: String, | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | #[derive(Template)] | |
| 59 | - | #[template(path = "custom/project.html")] | |
| 60 | - | struct ProjectPageTemplate { | |
| 61 | - | page_title: String, | |
| 62 | - | apex_url: String, | |
| 63 | - | canonical_url: String, | |
| 64 | - | creator_label: String, | |
| 65 | - | canvas_id: String, | |
| 66 | - | sanitized_css: String, | |
| 67 | - | sanitized_html: String, | |
| 68 | - | price_label: String, | |
| 69 | - | buy_url: String, | |
| 70 | - | items: Vec<SlotItem>, | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | #[derive(Template)] | |
| 74 | - | #[template(path = "custom/item.html")] | |
| 75 | - | struct ItemPageTemplate { | |
| 76 | - | page_title: String, | |
| 77 | - | apex_url: String, | |
| 78 | - | canonical_url: String, | |
| 79 | - | creator_label: String, | |
| 80 | - | canvas_id: String, | |
| 81 | - | sanitized_css: String, | |
| 82 | - | item_title: String, | |
| 83 | - | item_description: Option<String>, | |
| 84 | - | price_label: String, | |
| 85 | - | buy_url: String, | |
| 86 | - | } | |
| 87 | - | ||
| 88 | 47 | /// Dispatch middleware: intercept the user-pages host, pass everything else | |
| 89 | 48 | /// (and `/static`) through to the normal app. Placed outermost so it runs | |
| 90 | 49 | /// before the session and access-gate layers, custom pages never touch them. | |
| @@ -163,25 +122,30 @@ | |||
| 163 | 122 | sanitize_user_page(config, &user) | |
| 164 | 123 | }; | |
| 165 | 124 | ||
| 166 | - | render_html(user_template( | |
| 125 | + | render(&custom_page::user(&user_view( | |
| 167 | 126 | &user, | |
| 168 | 127 | apex_url, | |
| 169 | 128 | canonical_url, | |
| 170 | 129 | creator_label, | |
| 171 | 130 | sanitized_html, | |
| 172 | 131 | sanitized_css, | |
| 173 | - | )) | |
| 132 | + | ))) | |
| 174 | 133 | } | |
| 175 | 134 | ||
| 176 | - | fn user_template( | |
| 135 | + | /// What a profile page is, as the description wants it. | |
| 136 | + | /// | |
| 137 | + | /// Also the whole of what a project page's strip and canvas need, which is why | |
| 138 | + | /// [`custom_page::ProjectView`] carries one rather than repeating its seven | |
| 139 | + | /// fields. | |
| 140 | + | fn user_view( | |
| 177 | 141 | user: &db::DbUser, | |
| 178 | 142 | apex_url: String, | |
| 179 | 143 | canonical_url: String, | |
| 180 | 144 | creator_label: String, | |
| 181 | 145 | sanitized_html: String, | |
| 182 | 146 | sanitized_css: String, | |
| 183 | - | ) -> UserPageTemplate { | |
| 184 | - | UserPageTemplate { | |
| 147 | + | ) -> custom_page::UserView { | |
| 148 | + | custom_page::UserView { | |
| 185 | 149 | page_title: format!("{creator_label} - makenot.work"), | |
| 186 | 150 | apex_url, | |
| 187 | 151 | canonical_url, | |
| @@ -192,11 +156,13 @@ | |||
| 192 | 156 | } | |
| 193 | 157 | } | |
| 194 | 158 | ||
| 195 | - | fn render_html(template: impl Template) -> Result<Response, StatusCode> { | |
| 196 | - | template | |
| 197 | - | .render() | |
| 198 | - | .map(|h| Html(h).into_response()) | |
| 199 | - | .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) | |
| 159 | + | /// The described screen as a response. | |
| 160 | + | /// | |
| 161 | + | /// Infallible, unlike the `render_html` it replaces: a description is drawn | |
| 162 | + | /// rather than rendered from a template that can fail to parse, so there is no | |
| 163 | + | /// error branch left to map to a 500. | |
| 164 | + | fn render(screen: &quasi_router::Screen) -> Result<Response, StatusCode> { | |
| 165 | + | Ok(Html(custom_page::document(screen)).into_response()) | |
| 200 | 166 | } | |
| 201 | 167 | ||
| 202 | 168 | async fn render_project( | |
| @@ -227,52 +193,64 @@ | |||
| 227 | 193 | sanitize_project_page(config, &project) | |
| 228 | 194 | }; | |
| 229 | 195 | ||
| 230 | - | let items = project_slot_items(db, &project, &apex_url).await; | |
| 231 | - | render_html(project_template( | |
| 196 | + | let files = project_files(db, &project, &apex_url).await; | |
| 197 | + | render(&custom_page::project(&project_view( | |
| 232 | 198 | &user, | |
| 233 | 199 | &project, | |
| 234 | 200 | apex_url, | |
| 235 | 201 | canonical_url, | |
| 236 | 202 | sanitized_html, | |
| 237 | 203 | sanitized_css, | |
| 238 | - | items, | |
| 239 | - | )) | |
| 204 | + | files, | |
| 205 | + | ))) | |
| 240 | 206 | } | |
| 241 | 207 | ||
| 242 | - | /// The project's published items as file-list slot entries (links to the apex). | |
| 243 | - | async fn project_slot_items(db: &PgPool, project: &db::DbProject, apex_url: &str) -> Vec<SlotItem> { | |
| 208 | + | /// The project's published items as file-list entries (links to the apex). | |
| 209 | + | async fn project_files( | |
| 210 | + | db: &PgPool, | |
| 211 | + | project: &db::DbProject, | |
| 212 | + | apex_url: &str, | |
| 213 | + | ) -> Vec<custom_page::File> { | |
| 244 | 214 | db::items::get_public_items_by_project(db, project.id) | |
| 245 | 215 | .await | |
| 246 | 216 | .unwrap_or_default() | |
| 247 | 217 | .into_iter() | |
| 248 | - | .map(|it| SlotItem { | |
| 218 | + | .map(|it| custom_page::File { | |
| 249 | 219 | title: it.title, | |
| 250 | 220 | url: format!("{apex_url}/i/{}", it.id), | |
| 251 | 221 | }) | |
| 252 | 222 | .collect() | |
| 253 | 223 | } | |
| 254 | 224 | ||
| 225 | + | /// What a project page is, as the description wants it. | |
| 226 | + | /// | |
| 227 | + | /// The title is the project's and the canvas id is the project's, so this is | |
| 228 | + | /// not the profile's view with two fields added: `canvas_id` in particular | |
| 229 | + | /// scopes the *project's* stylesheet, and passing the owner's would unstyle | |
| 230 | + | /// every project page. | |
| 255 | 231 | #[allow(clippy::too_many_arguments)] | |
| 256 | - | fn project_template( | |
| 232 | + | fn project_view( | |
| 257 | 233 | user: &db::DbUser, | |
| 258 | 234 | project: &db::DbProject, | |
| 259 | 235 | apex_url: String, | |
| 260 | 236 | canonical_url: String, | |
| 261 | 237 | sanitized_html: String, | |
| 262 | 238 | sanitized_css: String, | |
| 263 | - | items: Vec<SlotItem>, | |
| 264 | - | ) -> ProjectPageTemplate { | |
| 265 | - | ProjectPageTemplate { | |
| 266 | - | page_title: format!("{} - makenot.work", project.title), | |
| 267 | - | apex_url, | |
| 268 | - | creator_label: display_name(user), | |
| 269 | - | canvas_id: project.id.to_string(), | |
| 270 | - | sanitized_css, | |
| 271 | - | sanitized_html, | |
| 239 | + | files: Vec<custom_page::File>, | |
| 240 | + | ) -> custom_page::ProjectView { | |
| 241 | + | custom_page::ProjectView { | |
| 242 | + | page: custom_page::UserView { | |
| 243 | + | page_title: format!("{} - makenot.work", project.title), | |
| 244 | + | apex_url, | |
| 245 | + | canonical_url: canonical_url.clone(), | |
| 246 | + | creator_label: display_name(user), | |
| 247 | + | canvas_id: project.id.to_string(), | |
| 248 | + | sanitized_css, | |
| 249 | + | sanitized_html, | |
| 250 | + | }, | |
| 272 | 251 | price_label: project_price_label(project), | |
| 273 | - | buy_url: canonical_url.clone(), | |
| 274 | - | canonical_url, | |
| 275 | - | items, | |
| 252 | + | buy_url: canonical_url, | |
| 253 | + | files, | |
| 276 | 254 | } | |
| 277 | 255 | } | |
| 278 | 256 | ||
| @@ -313,14 +291,14 @@ | |||
| 313 | 291 | }; | |
| 314 | 292 | let canonical_url = format!("{apex_url}/u/{}", user.username); | |
| 315 | 293 | let label = display_name(&user); | |
| 316 | - | render_html(user_template( | |
| 294 | + | render(&custom_page::user(&user_view( | |
| 317 | 295 | &user, | |
| 318 | 296 | apex_url, | |
| 319 | 297 | canonical_url, | |
| 320 | 298 | label, | |
| 321 | 299 | html, | |
| 322 | 300 | css, | |
| 323 | - | )) | |
| 301 | + | ))) | |
| 324 | 302 | } | |
| 325 | 303 | db::custom_pages::KIND_PROJECT => { | |
| 326 | 304 | let project = | |
| @@ -345,16 +323,16 @@ | |||
| 345 | 323 | None => (String::new(), String::new()), | |
| 346 | 324 | }; | |
| 347 | 325 | let canonical_url = format!("{apex_url}/p/{}", project.slug); | |
| 348 | - | let items = project_slot_items(db, &project, &apex_url).await; | |
| 349 | - | render_html(project_template( | |
| 326 | + | let files = project_files(db, &project, &apex_url).await; | |
| 327 | + | render(&custom_page::project(&project_view( | |
| 350 | 328 | &user, | |
| 351 | 329 | &project, | |
| 352 | 330 | apex_url, | |
| 353 | 331 | canonical_url, | |
| 354 | 332 | html, | |
| 355 | 333 | css, | |
| 356 | - | items, | |
| 357 | - | )) | |
| 334 | + | files, | |
| 335 | + | ))) | |
| 358 | 336 | } | |
| 359 | 337 | _ => Err(StatusCode::NOT_FOUND), | |
| 360 | 338 | } | |
| @@ -397,23 +375,20 @@ | |||
| 397 | 375 | sanitize_item_css(config, &project) | |
| 398 | 376 | }; | |
| 399 | 377 | ||
| 400 | - | let price_label = item_price_label(&item); | |
| 401 | - | let html = ItemPageTemplate { | |
| 378 | + | render(&custom_page::item(&custom_page::ItemView { | |
| 402 | 379 | page_title: format!("{} - makenot.work", item.title), | |
| 403 | 380 | apex_url, | |
| 404 | 381 | canonical_url: canonical_url.clone(), | |
| 405 | 382 | creator_label: display_name(&user), | |
| 383 | + | // The *project's* id: an item page wears the parent's stylesheet, | |
| 384 | + | // re-scoped to the item canvas by `sanitize_item_css`. | |
| 406 | 385 | canvas_id: project.id.to_string(), | |
| 407 | 386 | sanitized_css, | |
| 387 | + | price_label: item_price_label(&item), | |
| 408 | 388 | item_title: item.title, | |
| 409 | 389 | item_description: item.description, | |
| 410 | - | price_label, | |
| 411 | 390 | buy_url: canonical_url, | |
| 412 | - | } | |
| 413 | - | .render() | |
| 414 | - | .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; | |
| 415 | - | ||
| 416 | - | Ok(Html(html).into_response()) | |
| 391 | + | })) | |
| 417 | 392 | } | |
| 418 | 393 | ||
| 419 | 394 | // --- Sanitization (on render) --- |
| @@ -1,0 +1,558 @@ | |||
| 1 | + | //! The custom-pages host, described. | |
| 2 | + | //! | |
| 3 | + | //! <!-- wiki: quasi-overview --> | |
| 4 | + | //! | |
| 5 | + | //! quasicoherent `48a6e9e5`, decided 2026-08-30. `u.makenot.work` serves a | |
| 6 | + | //! creator's own HTML and CSS with a platform strip around it. It was the last | |
| 7 | + | //! host in the server assembled entirely by Askama, and the reason it was last | |
| 8 | + | //! is that a description had no way to say either half of it: the creator's | |
| 9 | + | //! markup is opaque, and the stylesheet that goes with it is per request. quasi | |
| 10 | + | //! 0.92.0 added both -- [`Node::Canvas`] and [`Document::style`] -- and this | |
| 11 | + | //! module is their first consumer. | |
| 12 | + | //! | |
| 13 | + | //! # What this host is, and why its shell is its own | |
| 14 | + | //! | |
| 15 | + | //! Read `crate::routes::user_pages` for the isolation rules; they are unchanged | |
| 16 | + | //! by the conversion and they are what shapes the shell here: | |
| 17 | + | //! | |
| 18 | + | //! - **No script, at all.** The CSP is `default-src 'none'`, so a `<script>` | |
| 19 | + | //! in the head is a console error rather than a feature. | |
| 20 | + | //! [`Shell::without_scripts`] is the whole of it. Nothing on these pages | |
| 21 | + | //! calls a route: every control is a link to the apex. | |
| 22 | + | //! - **No linked stylesheet.** Not because the CSP forbids it -- `style-src` | |
| 23 | + | //! allows `'self'` -- but because linking the platform sheet would pull | |
| 24 | + | //! every platform class into scope on the one surface built for creator | |
| 25 | + | //! control. Settled 2026-08-16 and unchanged here. [`CHROME_CSS`] arrives | |
| 26 | + | //! through [`Shell::with_head_first`], the way an embed's design system | |
| 27 | + | //! does. | |
| 28 | + | //! - **No chrome and no session.** These are cookieless documents for readers | |
| 29 | + | //! who are not signed in, so `Chrome::new()` and ordinary axum handlers, as | |
| 30 | + | //! in [`super::embeds`]. | |
| 31 | + | //! | |
| 32 | + | //! # The strip is content here, not `Chrome` | |
| 33 | + | //! | |
| 34 | + | //! A [`quasi_router::Chrome`] is built once and held beside the router, and | |
| 35 | + | //! every link in this strip is per request: the canonical URL and the creator's | |
| 36 | + | //! name change with the page. So the header and footer are regions on the | |
| 37 | + | //! screen, [`RegionKind::Band`] each, which is what a band is -- a full-width | |
| 38 | + | //! strip with a title and an actions cluster. | |
| 39 | + | //! | |
| 40 | + | //! They are siblings of the canvas rather than ancestors, which is the whole of | |
| 41 | + | //! the isolation guarantee and is unchanged: `css_sanitizer` re-emits every | |
| 42 | + | //! creator rule nested inside `.user-canvas#uc-{owner}`, so a creator selector | |
| 43 | + | //! naming anything in the strip is emitted under the canvas and matches | |
| 44 | + | //! nothing. No specificity contest, no layer, no `!important`. See | |
| 45 | + | //! `css_sanitizer::platform_chrome_is_unreachable_from_creator_css`. | |
| 46 | + | //! | |
| 47 | + | //! # Every link out of here is [`Action::leaving`] | |
| 48 | + | //! | |
| 49 | + | //! quasi 0.93.0. These pages are a satellite of the apex and the strip exists | |
| 50 | + | //! to send readers to it, so the links replace the page rather than opening | |
| 51 | + | //! tabs, which is what they did as Askama and what `Destination::External` | |
| 52 | + | //! could not say. | |
| 53 | + | //! | |
| 54 | + | //! # The published class names survive the conversion | |
| 55 | + | //! | |
| 56 | + | //! `site-docs/public/guide/custom-pages.md` tells creators that the buy block | |
| 57 | + | //! is `.mnw-buy`, the file list `.mnw-files` and the item block `.mnw-item`, | |
| 58 | + | //! and gives worked CSS against them. Those are a contract with people outside | |
| 59 | + | //! this codebase, so they are carried on [`Node::Canvas`] scopes -- described | |
| 60 | + | //! content under a name the app has promised, which is the second use that | |
| 61 | + | //! member documents. Every class the strip itself uses is this renderer's and | |
| 62 | + | //! [`CHROME_CSS`] is written against them, because nobody outside was ever | |
| 63 | + | //! promised those. | |
| 64 | + | //! | |
| 65 | + | //! What did change is the markup *inside* those blocks, and it had to: a price | |
| 66 | + | //! is a described node now rather than a hand-written div. The one place that | |
| 67 | + | //! is visible to a creator is the call to action, which was an `<a class= | |
| 68 | + | //! "mnw-buy-cta">` and is now that class on the element around the link. The | |
| 69 | + | //! guide's own example sets `color` on it, and a `color` on an ancestor does | |
| 70 | + | //! not reach an anchor -- the UA sheet beats inheritance -- so [`CHROME_CSS`] | |
| 71 | + | //! says `color: inherit` for a link inside one. Without that line the documented | |
| 72 | + | //! example would quietly stop working. | |
| 73 | + | ||
| 74 | + | use quasi_axum::Serves as _; | |
| 75 | + | use quasi_router::screen::{Canvas, Document, Row}; | |
| 76 | + | use quasi_router::{Action, Chrome, Node, RegionKind, Screen, Slot, layout}; | |
| 77 | + | use quasi_webview::{Shell, Webview}; | |
| 78 | + | ||
| 79 | + | /// The platform strip's styling, and the system slots' defaults. | |
| 80 | + | /// | |
| 81 | + | /// Inline rather than linked, and the argument is `crate::routes::user_pages`'s | |
| 82 | + | /// rather than this module's: ~1.3KB against a second request, and a fetch that | |
| 83 | + | /// fails leaves the strip unstyled while the creator's inline CSS still renders. | |
| 84 | + | /// Inline cannot fail that way. | |
| 85 | + | /// | |
| 86 | + | /// Written against the classes and ids quasi emits, with two exceptions that | |
| 87 | + | /// are deliberate and are named in the module header: the `mnw-` names are a | |
| 88 | + | /// published contract with creators, and `#chrome-top` / `#chrome-bottom` are | |
| 89 | + | /// region ids this module chose. | |
| 90 | + | const CHROME_CSS: &str = "\ | |
| 91 | + | <style>\ | |
| 92 | + | html,body{margin:0;padding:0}\ | |
| 93 | + | body{font-family:system-ui,-apple-system,\"Segoe UI\",sans-serif}\ | |
| 94 | + | #chrome-top,#chrome-bottom{background:#161616;color:#e9e9e9;font-size:.85rem;line-height:1.4}\ | |
| 95 | + | #chrome-top{display:flex;justify-content:space-between;align-items:center;gap:1rem;\ | |
| 96 | + | padding:.55rem 1rem;border-bottom:1px solid #2c2c2c}\ | |
| 97 | + | #chrome-top a,#chrome-bottom a{color:#e9e9e9;text-decoration:none}\ | |
| 98 | + | #chrome-top a:hover,#chrome-bottom a:hover{text-decoration:underline}\ | |
| 99 | + | #chrome-top a:first-child{font-weight:600;letter-spacing:.01em}\ | |
| 100 | + | #chrome-bottom{padding:1rem;text-align:center;border-top:1px solid #2c2c2c;color:#9a9a9a}\ | |
| 101 | + | .mnw-buy,.mnw-item{margin:1.5rem 0;padding:1rem;border:1px solid currentColor;border-radius:4px}\ | |
| 102 | + | .mnw-buy-cta,.mnw-item-cta{display:inline-block;margin-top:.5rem;padding:.4rem .9rem;\ | |
| 103 | + | border:1px solid currentColor;border-radius:4px;font-weight:600}\ | |
| 104 | + | .mnw-buy-cta a,.mnw-item-cta a{color:inherit;text-decoration:none}\ | |
| 105 | + | .mnw-files ul{margin:0;padding:0;list-style:none}\ | |
| 106 | + | .mnw-files{margin:1.5rem 0}\ | |
| 107 | + | .mnw-files li{padding:.35rem 0;border-bottom:1px solid currentColor}\ | |
| 108 | + | .mnw-price{font-weight:600}\ | |
| 109 | + | </style>"; | |
| 110 | + | ||
| 111 | + | /// One entry in a project's file list. | |
| 112 | + | pub struct File { | |
| 113 | + | /// What the file is called. | |
| 114 | + | pub title: String, | |
| 115 | + | /// Where it is on the apex. | |
| 116 | + | pub url: String, | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /// Everything a creator profile draws from. | |
| 120 | + | pub struct UserView { | |
| 121 | + | /// The document's title. | |
| 122 | + | pub page_title: String, | |
| 123 | + | /// The apex, for the brand link. | |
| 124 | + | pub apex_url: String, | |
| 125 | + | /// This page's home on the apex. | |
| 126 | + | pub canonical_url: String, | |
| 127 | + | /// What to call the creator. | |
| 128 | + | pub creator_label: String, | |
| 129 | + | /// The owner id the sanitiser scoped the CSS to. | |
| 130 | + | pub canvas_id: String, | |
| 131 | + | /// The creator's stylesheet, sanitised. | |
| 132 | + | pub sanitized_css: String, | |
| 133 | + | /// The creator's markup, sanitised. | |
| 134 | + | pub sanitized_html: String, | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// Everything a project page draws from. | |
| 138 | + | pub struct ProjectView { | |
| 139 | + | /// The profile half, which a project page carries all of. | |
| 140 | + | pub page: UserView, | |
| 141 | + | /// What the project costs, as a sentence. | |
| 142 | + | pub price_label: String, | |
| 143 | + | /// Where buying happens. | |
| 144 | + | pub buy_url: String, | |
| 145 | + | /// The project's published items. | |
| 146 | + | pub files: Vec<File>, | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /// Everything an item page draws from. | |
| 150 | + | /// | |
| 151 | + | /// No creator markup of any kind: an item page is the trustworthy default | |
| 152 | + | /// layout wearing the parent project's CSS, re-scoped to the item canvas. It | |
| 153 | + | /// takes the head half of the conversion and no part of [`Node::Canvas`]'s | |
| 154 | + | /// markup, which is why it was the one to convert first. | |
| 155 | + | pub struct ItemView { | |
| 156 | + | /// The document's title. | |
| 157 | + | pub page_title: String, | |
| 158 | + | /// The apex, for the brand link. | |
| 159 | + | pub apex_url: String, | |
| 160 | + | /// This page's home on the apex. | |
| 161 | + | pub canonical_url: String, | |
| 162 | + | /// What to call the creator. | |
| 163 | + | pub creator_label: String, | |
| 164 | + | /// The project id the parent's CSS was re-scoped to. | |
| 165 | + | pub canvas_id: String, | |
| 166 | + | /// The parent project's stylesheet, re-scoped to the item canvas. | |
| 167 | + | pub sanitized_css: String, | |
| 168 | + | /// The item's name. | |
| 169 | + | pub item_title: String, | |
| 170 | + | /// What the creator said about it, if anything. | |
| 171 | + | pub item_description: Option<String>, | |
| 172 | + | /// What the item costs, as a sentence. | |
| 173 | + | pub price_label: String, | |
| 174 | + | /// Where buying happens. | |
| 175 | + | pub buy_url: String, | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | /// The whole document for one of these screens. | |
| 179 | + | /// | |
| 180 | + | /// [`super::embeds::document`]'s shape and for its reasons, with one addition: | |
| 181 | + | /// the screen carries a stylesheet of its own, which the renderer writes last | |
| 182 | + | /// in the head and outside the layer statement. That is what puts the creator's | |
| 183 | + | /// CSS on top of [`CHROME_CSS`] without either of them naming a layer. | |
| 184 | + | #[must_use] | |
| 185 | + | pub fn document(screen: &Screen) -> String { | |
| 186 | + | let shell = Shell::default() | |
| 187 | + | .without_scripts() | |
| 188 | + | .with_chrome(Chrome::new()) | |
| 189 | + | .with_head_first(CHROME_CSS); | |
| 190 | + | Webview::new().with_shell(shell).screen(screen) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | /// A creator's profile. | |
| 194 | + | #[must_use] | |
| 195 | + | pub fn user(view: &UserView) -> Screen { | |
| 196 | + | strip( | |
| 197 | + | Screen::single(&view.page_title) | |
| 198 | + | .documented(Document::default().styled(&view.sanitized_css)), | |
| 199 | + | view, | |
| 200 | + | ) | |
| 201 | + | .with(Slot::new("canvas", RegionKind::Pane).with(Node::Canvas(Box::new(canvas(view))))) | |
| 202 | + | .with(footer(view)) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | /// A creator's project page: their markup, then the platform's own blocks | |
| 206 | + | /// inside the same scope. | |
| 207 | + | #[must_use] | |
| 208 | + | pub fn project(view: &ProjectView) -> Screen { | |
| 209 | + | let page = &view.page; | |
| 210 | + | // Inside the canvas and after the markup, which is what the Askama template | |
| 211 | + | // did and is what lets a creator's sheet reach these blocks at all. Outside | |
| 212 | + | // would be a different page, quietly. | |
| 213 | + | let mut scope = canvas(page).with(slot_block( | |
| 214 | + | "mnw-buy", | |
| 215 | + | "mnw-buy-cta", | |
| 216 | + | &view.price_label, | |
| 217 | + | &view.buy_url, | |
| 218 | + | )); | |
| 219 | + | if !view.files.is_empty() { | |
| 220 | + | scope = scope.with(Node::Canvas(Box::new( | |
| 221 | + | Canvas::default().classed("mnw-files").with(Node::List { | |
| 222 | + | rows: view | |
| 223 | + | .files | |
| 224 | + | .iter() | |
| 225 | + | .map(|file| Row::new(&file.title).activate(Action::leaving(&file.url))) | |
| 226 | + | .collect(), | |
| 227 | + | more: None, | |
| 228 | + | }), | |
| 229 | + | ))); | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | strip( | |
| 233 | + | Screen::single(&page.page_title) | |
| 234 | + | .documented(Document::default().styled(&page.sanitized_css)), | |
| 235 | + | page, | |
| 236 | + | ) | |
| 237 | + | .with(Slot::new("canvas", RegionKind::Pane).with(Node::Canvas(Box::new(scope)))) | |
| 238 | + | .with(footer(page)) | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | /// An item page, which is the default layout in the parent project's clothes. | |
| 242 | + | #[must_use] | |
| 243 | + | pub fn item(view: &ItemView) -> Screen { | |
| 244 | + | let mut block = Canvas::default().classed("mnw-item").with(Node::Heading { | |
| 245 | + | level: layout::Heading::Page, | |
| 246 | + | text: view.item_title.clone(), | |
| 247 | + | }); | |
| 248 | + | if let Some(description) = &view.item_description { | |
| 249 | + | block = block.with(Node::text(description)); | |
| 250 | + | } | |
| 251 | + | let block = block | |
| 252 | + | .with(price(&view.price_label)) | |
| 253 | + | .with(cta("mnw-item-cta", &view.buy_url)); | |
| 254 | + | ||
| 255 | + | let page = UserView { | |
| 256 | + | page_title: view.page_title.clone(), | |
| 257 | + | apex_url: view.apex_url.clone(), | |
| 258 | + | canonical_url: view.canonical_url.clone(), | |
| 259 | + | creator_label: view.creator_label.clone(), | |
| 260 | + | canvas_id: view.canvas_id.clone(), | |
| 261 | + | sanitized_css: view.sanitized_css.clone(), | |
| 262 | + | sanitized_html: String::new(), | |
| 263 | + | }; | |
| 264 | + | ||
| 265 | + | strip( | |
| 266 | + | Screen::single(&view.page_title) | |
| 267 | + | .documented(Document::default().styled(&view.sanitized_css)), | |
| 268 | + | &page, | |
| 269 | + | ) | |
| 270 | + | // `item-canvas` and `ic-`, which is what `sanitize_item_css` scopes to. The | |
| 271 | + | // two names travel together and neither is this module's to choose. | |
| 272 | + | .with( | |
| 273 | + | Slot::new("canvas", RegionKind::Pane).with(Node::Canvas(Box::new( | |
| 274 | + | Canvas::default() | |
| 275 | + | .classed("item-canvas") | |
| 276 | + | .identified(format!("ic-{}", view.canvas_id)) | |
| 277 | + | .with(Node::Canvas(Box::new(block))), | |
| 278 | + | ))), | |
| 279 | + | ) | |
| 280 | + | .with(footer(&page)) | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | /// The creator's markup under the scope its stylesheet was rewritten for. | |
| 284 | + | /// | |
| 285 | + | /// `.user-canvas#uc-{owner}` is `css_sanitizer::sanitize_css`'s, not this | |
| 286 | + | /// module's: the class and the id are what every creator rule was nested | |
| 287 | + | /// inside, so a name changed here silently unstyles every custom page. | |
| 288 | + | fn canvas(view: &UserView) -> Canvas { | |
| 289 | + | Canvas::new(&view.sanitized_html) | |
| 290 | + | .classed("user-canvas") | |
| 291 | + | .identified(format!("uc-{}", view.canvas_id)) | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | /// The platform strip across the top. | |
| 295 | + | fn strip(screen: Screen, view: &UserView) -> Screen { | |
| 296 | + | screen.with( | |
| 297 | + | Slot::new("chrome-top", RegionKind::Band) | |
| 298 | + | .with(Node::Link { | |
| 299 | + | text: "makenot.work".into(), | |
| 300 | + | action: Action::leaving(&view.apex_url), | |
| 301 | + | }) | |
| 302 | + | .with(Node::Link { | |
| 303 | + | text: "View on makenot.work".into(), | |
| 304 | + | action: Action::leaving(&view.canonical_url), | |
| 305 | + | }), | |
| 306 | + | ) | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | /// The platform strip across the bottom. | |
| 310 | + | fn footer(view: &UserView) -> Slot { | |
| 311 | + | Slot::new("chrome-bottom", RegionKind::Band).with(Node::Link { | |
| 312 | + | text: format!("{} on makenot.work", view.creator_label), | |
| 313 | + | action: Action::leaving(&view.canonical_url), | |
| 314 | + | }) | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | /// A system slot: a price and the way to buy, under a published class name. | |
| 318 | + | fn slot_block(block_class: &str, cta_class: &str, price_label: &str, buy_url: &str) -> Node { | |
| 319 | + | Node::Canvas(Box::new( | |
| 320 | + | Canvas::default() | |
| 321 | + | .classed(block_class) | |
| 322 | + | .with(price(price_label)) | |
| 323 | + | .with(cta(cta_class, buy_url)), | |
| 324 | + | )) | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | /// What it costs, under the class the guide names. | |
| 328 | + | fn price(label: &str) -> Node { | |
| 329 | + | Node::Canvas(Box::new( | |
| 330 | + | Canvas::default() | |
| 331 | + | .classed("mnw-price") | |
| 332 | + | .with(Node::text(label)), | |
| 333 | + | )) | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | /// The way to buy, under the class the guide names. | |
| 337 | + | fn cta(class: &str, buy_url: &str) -> Node { | |
| 338 | + | Node::Canvas(Box::new(Canvas::default().classed(class).with( | |
| 339 | + | Node::Link { | |
| 340 | + | text: "Get on makenot.work".into(), | |
| 341 | + | action: Action::leaving(buy_url), | |
| 342 | + | }, | |
| 343 | + | ))) | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | #[cfg(test)] | |
| 347 | + | mod tests { | |
| 348 | + | use super::*; | |
| 349 | + | ||
| 350 | + | /// Everything from the open `<body>` tag on. | |
| 351 | + | /// | |
| 352 | + | /// Every assertion about what is *drawn* uses this rather than the whole | |
| 353 | + | /// document, because `CHROME_CSS` names `.mnw-files` and `.mnw-price` in | |
| 354 | + | /// the head: a search over the document finds the rule and concludes the | |
| 355 | + | /// block is on the page. | |
| 356 | + | fn body(html: &str) -> &str { | |
| 357 | + | &html[html.find("<body").expect("a document has a body")..] | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | fn a_user() -> UserView { | |
| 361 | + | UserView { | |
| 362 | + | page_title: "Ada - makenot.work".into(), | |
| 363 | + | apex_url: "https://makenot.work".into(), | |
| 364 | + | canonical_url: "https://makenot.work/u/ada".into(), | |
| 365 | + | creator_label: "Ada".into(), | |
| 366 | + | canvas_id: "11111111".into(), | |
| 367 | + | sanitized_css: ".user-canvas#uc-11111111 p{color:red}".into(), | |
| 368 | + | sanitized_html: "<h1>Ada</h1>".into(), | |
| 369 | + | } | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | #[test] | |
| 373 | + | fn the_canvas_carries_the_scope_the_sanitiser_rewrote_every_rule_for() { | |
| 374 | + | // `css_sanitizer` nests every creator rule inside | |
| 375 | + | // `.user-canvas#uc-{owner}`. If the element stops carrying both halves, | |
| 376 | + | // every custom page silently loses its styling, so this is the | |
| 377 | + | // load-bearing assertion in the module. | |
| 378 | + | let html = document(&user(&a_user())); | |
| 379 | + | ||
| 380 | + | assert!( | |
| 381 | + | html.contains("<div class=\"user-canvas\" id=\"uc-11111111\"><h1>Ada</h1></div>"), | |
| 382 | + | "{html}" | |
| 383 | + | ); | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | #[test] | |
| 387 | + | fn the_creators_stylesheet_is_in_the_head_after_the_platforms() { | |
| 388 | + | // Both are inline and neither is layered, so the later one wins. The | |
| 389 | + | // creator's is the screen's own and the renderer writes it last. | |
| 390 | + | let html = document(&user(&a_user())); | |
| 391 | + | let chrome = html.find("#chrome-top").expect("the strip is styled"); | |
| 392 | + | let creator = html | |
| 393 | + | .find(".user-canvas#uc-11111111 p{color:red}") | |
| 394 | + | .expect("the creator's sheet is written"); | |
| 395 | + | let head_end = html.find("</head>").expect("the head closes"); | |
| 396 | + | ||
| 397 | + | assert!(chrome < creator, "{html}"); | |
| 398 | + | assert!(creator < head_end, "{html}"); | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | #[test] | |
| 402 | + | fn the_strip_is_a_sibling_of_the_canvas_and_not_an_ancestor() { | |
| 403 | + | // The whole isolation guarantee. A creator selector naming anything in | |
| 404 | + | // the strip is emitted under the canvas and matches nothing, which only | |
| 405 | + | // holds while the strip is outside it. | |
| 406 | + | let html = document(&user(&a_user())); | |
| 407 | + | let body = body(&html); | |
| 408 | + | let top = body.find("id=\"chrome-top\"").expect("the top strip"); | |
| 409 | + | let canvas = body.find("class=\"user-canvas\"").expect("the canvas"); | |
| 410 | + | let bottom = body.find("id=\"chrome-bottom\"").expect("the bottom strip"); | |
| 411 | + | ||
| 412 | + | assert!(top < canvas && canvas < bottom, "{body}"); | |
| 413 | + | // Not nested: the canvas region closes before the footer opens. | |
| 414 | + | assert!(!body[canvas..bottom].contains("id=\"chrome-"), "{body}"); | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | #[test] | |
| 418 | + | fn no_script_reaches_a_page_whose_csp_forbids_every_one() { | |
| 419 | + | // `default-src 'none'`. A script element here is a console error. | |
| 420 | + | let html = document(&user(&a_user())); | |
| 421 | + | ||
| 422 | + | assert!(!html.contains("<script"), "{html}"); | |
| 423 | + | assert!(!html.contains("<link"), "{html}"); | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | #[test] | |
| 427 | + | fn every_way_off_this_page_replaces_it_rather_than_opening_a_tab() { | |
| 428 | + | // These pages are a satellite of the apex and the strip exists to send | |
| 429 | + | // readers to it. This is what the Askama version did. | |
| 430 | + | let html = document(&user(&a_user())); | |
| 431 | + | ||
| 432 | + | assert!(!html.contains("target=\"_blank\""), "{html}"); | |
| 433 | + | assert!(html.contains("href=\"https://makenot.work\""), "{html}"); | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | #[test] | |
| 437 | + | fn the_published_system_slot_names_survive_the_conversion() { | |
| 438 | + | // `site-docs/public/guide/custom-pages.md` names these to creators and | |
| 439 | + | // gives worked CSS against them, so they are a contract with people | |
| 440 | + | // outside this codebase rather than class names this module picked. | |
| 441 | + | let view = ProjectView { | |
| 442 | + | page: a_user(), | |
| 443 | + | price_label: "$12".into(), | |
| 444 | + | buy_url: "https://makenot.work/p/thing".into(), | |
| 445 | + | files: vec![File { | |
| 446 | + | title: "one.wav".into(), | |
| 447 | + | url: "https://makenot.work/i/1".into(), | |
| 448 | + | }], | |
| 449 | + | }; | |
| 450 | + | let html = document(&project(&view)); | |
| 451 | + | ||
| 452 | + | assert!(html.contains("class=\"mnw-buy\""), "{html}"); | |
| 453 | + | assert!(html.contains("class=\"mnw-buy-cta\""), "{html}"); | |
| 454 | + | assert!(html.contains("class=\"mnw-price\""), "{html}"); | |
| 455 | + | assert!(html.contains("class=\"mnw-files\""), "{html}"); | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | #[test] | |
| 459 | + | fn the_platform_blocks_sit_inside_the_creators_scope_after_their_markup() { | |
| 460 | + | // What the Askama template did, and what lets a creator's sheet reach | |
| 461 | + | // the buy block at all. Outside would be a different page, quietly. | |
| 462 | + | let view = ProjectView { | |
| 463 | + | page: a_user(), | |
| 464 | + | price_label: "$12".into(), | |
| 465 | + | buy_url: "https://makenot.work/p/thing".into(), | |
| 466 | + | files: Vec::new(), | |
| 467 | + | }; | |
| 468 | + | let html = document(&project(&view)); | |
| 469 | + | let canvas = html | |
| 470 | + | .find("class=\"user-canvas\"") | |
| 471 | + | .expect("the canvas opens"); | |
| 472 | + | let markup = html.find("<h1>Ada</h1>").expect("the creator's markup"); | |
| 473 | + | let buy = html.find("class=\"mnw-buy\"").expect("the buy block"); | |
| 474 | + | ||
| 475 | + | assert!(canvas < markup && markup < buy, "{html}"); | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | #[test] | |
| 479 | + | fn a_project_with_no_published_items_draws_no_file_list() { | |
| 480 | + | // The template guarded on `items.is_empty()`, and an empty bordered box | |
| 481 | + | // is worse than no box. | |
| 482 | + | let view = ProjectView { | |
| 483 | + | page: a_user(), | |
| 484 | + | price_label: "Free".into(), | |
| 485 | + | buy_url: "https://makenot.work/p/thing".into(), | |
| 486 | + | files: Vec::new(), | |
| 487 | + | }; | |
| 488 | + | ||
| 489 | + | assert!(!body(&document(&project(&view))).contains("mnw-files")); | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | #[test] | |
| 493 | + | fn an_item_page_wears_the_parents_stylesheet_under_the_item_canvas() { | |
| 494 | + | // `sanitize_item_css` scopes to `.item-canvas#ic-{project}`, so the two | |
| 495 | + | // names travel together and neither is this module's to choose. An item | |
| 496 | + | // page has no creator markup of its own. | |
| 497 | + | let html = document(&item(&ItemView { | |
| 498 | + | page_title: "Track - makenot.work".into(), | |
| 499 | + | apex_url: "https://makenot.work".into(), | |
| 500 | + | canonical_url: "https://makenot.work/i/9".into(), |
Lines truncated
| @@ -1,66 +1,0 @@ | |||
| 1 | - | <style> | |
| 2 | - | /* Platform chrome for custom pages. Lives outside the user canvas, so creator | |
| 3 | - | CSS (scoped to .user-canvas / .item-canvas) can never restyle or hide it. | |
| 4 | - | Deliberately minimal and theme-independent. | |
| 5 | - | ||
| 6 | - | STAYS INLINE. Asked and answered 2026-08-16. | |
| 7 | - | ||
| 8 | - | The guarantee is structural, not a cascade contest. css_sanitizer.rs | |
| 9 | - | re-emits every creator rule nested inside .user-canvas#uc-{owner}; the | |
| 10 | - | header and footer are siblings of that canvas, so a creator selector | |
| 11 | - | naming .mnw-chrome is emitted under the canvas and matches nothing. No | |
| 12 | - | specificity, layer or !important is involved, which is why this block | |
| 13 | - | needs none. Demonstrated by css_sanitizer.rs's | |
| 14 | - | platform_chrome_is_unreachable_from_creator_css, which walks eight escape | |
| 15 | - | attempts including `*`, `html body .mnw-chrome-brand` and `:root`. | |
| 16 | - | ||
| 17 | - | So a cascade layer would buy nothing here, and these pages have none to | |
| 18 | - | join: custom/*.html are standalone documents that do not go through | |
| 19 | - | shell.rs, link no stylesheet, and never emit the `@layer makeover, base, | |
| 20 | - | components, responsive` statement. Moving these lines into style.css | |
| 21 | - | would pull the whole platform sheet onto every creator page and put every | |
| 22 | - | platform class in scope on the one surface built for creator control. | |
| 23 | - | ||
| 24 | - | A separate linked chrome.css would be cacheable, and is still not worth | |
| 25 | - | it: ~1.3KB against a second request, and a fetch that fails leaves the | |
| 26 | - | chrome unstyled while the creator's inline CSS still renders. Inline | |
| 27 | - | cannot fail that way. Revisit only if this block grows past the point | |
| 28 | - | where its bytes beat the round trip. */ | |
| 29 | - | html, body { margin: 0; padding: 0; } | |
| 30 | - | /* NOT the house font, and deliberately so. The typography standard covers | |
| 31 | - | MNW's own frontends; this is a creator's document with our chrome strip on | |
| 32 | - | it, and the body element belongs to them. Pulling Quasi Body in here would | |
| 33 | - | restyle their page and fetch 81KB onto it to do so, neither of which they | |
| 34 | - | asked for. The chrome bar below inherits it for the same reason: one face | |
| 35 | - | on the page is the creator's choice, and two would be worse than a | |
| 36 | - | compromise. */ | |
| 37 | - | body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; } | |
| 38 | - | .mnw-chrome, .mnw-chrome-footer { | |
| 39 | - | background: #161616; color: #e9e9e9; font-size: .85rem; line-height: 1.4; | |
| 40 | - | } | |
| 41 | - | .mnw-chrome { | |
| 42 | - | display: flex; justify-content: space-between; align-items: center; | |
| 43 | - | gap: 1rem; padding: .55rem 1rem; border-bottom: 1px solid #2c2c2c; | |
| 44 | - | } | |
| 45 | - | .mnw-chrome a, .mnw-chrome-footer a { color: #e9e9e9; text-decoration: none; } | |
| 46 | - | .mnw-chrome a:hover, .mnw-chrome-footer a:hover { text-decoration: underline; } | |
| 47 | - | .mnw-chrome-brand { font-weight: 600; letter-spacing: .01em; } | |
| 48 | - | .mnw-chrome-actions { display: flex; gap: 1rem; } | |
| 49 | - | .mnw-chrome-footer { | |
| 50 | - | padding: 1rem; text-align: center; border-top: 1px solid #2c2c2c; | |
| 51 | - | color: #9a9a9a; | |
| 52 | - | } | |
| 53 | - | /* Canvas baseline so an unstyled page is still readable. */ | |
| 54 | - | .user-canvas, .item-canvas { min-height: 50vh; padding: 1rem; box-sizing: border-box; } | |
| 55 | - | /* System slots: visible defaults the creator can restyle but not remove. */ | |
| 56 | - | .mnw-buy, .mnw-item { | |
| 57 | - | margin: 1.5rem 0; padding: 1rem; border: 1px solid currentColor; border-radius: 4px; | |
| 58 | - | } | |
| 59 | - | .mnw-buy-cta, .mnw-item-cta { | |
| 60 | - | display: inline-block; margin-top: .5rem; padding: .4rem .9rem; | |
| 61 | - | border: 1px solid currentColor; border-radius: 4px; font-weight: 600; | |
| 62 | - | } | |
| 63 | - | .mnw-files { margin: 1.5rem 0; padding: 0; list-style: none; } | |
| 64 | - | .mnw-files li { padding: .35rem 0; border-bottom: 1px solid currentColor; } | |
| 65 | - | .mnw-price { font-weight: 600; } | |
| 66 | - | </style> |
| @@ -1,3 +1,0 @@ | |||
| 1 | - | <footer class="mnw-chrome-footer"> | |
| 2 | - | <a href="{{ canonical_url }}">{{ creator_label }} on makenot.work</a> | |
| 3 | - | </footer> |
| @@ -1,6 +1,0 @@ | |||
| 1 | - | <header class="mnw-chrome"> | |
| 2 | - | <a class="mnw-chrome-brand" href="{{ apex_url }}">makenot.work</a> | |
| 3 | - | <nav class="mnw-chrome-actions"> | |
| 4 | - | <a href="{{ canonical_url }}">View on makenot.work</a> | |
| 5 | - | </nav> | |
| 6 | - | </header> |
| @@ -1,24 +1,0 @@ | |||
| 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"> | |
| 6 | - | <title>{{ page_title }}</title> | |
| 7 | - | {% include "custom/_chrome_style.html" %} | |
| 8 | - | <style>{{ sanitized_css|safe }}</style> | |
| 9 | - | </head> | |
| 10 | - | <body> | |
| 11 | - | {% include "custom/_header.html" %} | |
| 12 | - | {# Item pages have no creator HTML: the trustworthy default layout wears the | |
| 13 | - | parent project's CSS, re-scoped to the item canvas. #} | |
| 14 | - | <div class="item-canvas" id="ic-{{ canvas_id }}"> | |
| 15 | - | <div class="mnw-item"> | |
| 16 | - | <h1>{{ item_title }}</h1> | |
| 17 | - | {% if let Some(desc) = item_description %}<p>{{ desc }}</p>{% endif %} | |
| 18 | - | <div class="mnw-price">{{ price_label }}</div> | |
| 19 | - | <a class="mnw-item-cta" href="{{ buy_url }}">Get on makenot.work</a> | |
| 20 | - | </div> | |
| 21 | - | </div> | |
| 22 | - | {% include "custom/_footer.html" %} | |
| 23 | - | </body> | |
| 24 | - | </html> |
| @@ -1,28 +1,0 @@ | |||
| 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"> | |
| 6 | - | <title>{{ page_title }}</title> | |
| 7 | - | {% include "custom/_chrome_style.html" %} | |
| 8 | - | <style>{{ sanitized_css|safe }}</style> | |
| 9 | - | </head> | |
| 10 | - | <body> | |
| 11 | - | {% include "custom/_header.html" %} | |
| 12 | - | <div class="user-canvas" id="uc-{{ canvas_id }}"> | |
| 13 | - | {{ sanitized_html|safe }} | |
| 14 | - | <div class="mnw-buy"> | |
| 15 | - | <div class="mnw-price">{{ price_label }}</div> | |
| 16 | - | <a class="mnw-buy-cta" href="{{ buy_url }}">Get on makenot.work</a> | |
| 17 | - | </div> | |
| 18 | - | {% if !items.is_empty() %} | |
| 19 | - | <ul class="mnw-files"> | |
| 20 | - | {% for it in items %} | |
| 21 | - | <li><a href="{{ it.url }}">{{ it.title }}</a></li> | |
| 22 | - | {% endfor %} | |
| 23 | - | </ul> | |
| 24 | - | {% endif %} | |
| 25 | - | </div> | |
| 26 | - | {% include "custom/_footer.html" %} | |
| 27 | - | </body> | |
| 28 | - | </html> |
| @@ -1,17 +1,0 @@ | |||
| 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"> | |
| 6 | - | <title>{{ page_title }}</title> | |
| 7 | - | {% include "custom/_chrome_style.html" %} | |
| 8 | - | <style>{{ sanitized_css|safe }}</style> | |
| 9 | - | </head> | |
| 10 | - | <body> | |
| 11 | - | {% include "custom/_header.html" %} | |
| 12 | - | <div class="user-canvas" id="uc-{{ canvas_id }}"> | |
| 13 | - | {{ sanitized_html|safe }} | |
| 14 | - | </div> | |
| 15 | - | {% include "custom/_footer.html" %} | |
| 16 | - | </body> | |
| 17 | - | </html> |