Skip to main content

max / makenotwork

22.8 KB · 592 lines History Blame Raw
1 //! The custom-pages host, described.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! `u.makenot.work` serves a creator's own HTML and CSS with a platform strip
6 //! around it. Both halves are hard to describe: the creator's markup is opaque,
7 //! and the stylesheet that goes with it is per request. [`Node::Canvas`] and
8 //! [`Document::style`] are what say them.
9 //!
10 //! # What this host is, and why its shell is its own
11 //!
12 //! Read `crate::routes::user_pages` for the isolation rules; they are unchanged
13 //! by the conversion and they are what shapes the shell here:
14 //!
15 //! - **No script, at all.** The CSP is `default-src 'none'`, so a `<script>`
16 //! in the head is a console error rather than a feature.
17 //! [`Shell::without_scripts`] is the whole of it. Nothing on these pages
18 //! calls a route: every control is a link to the apex.
19 //! - **No linked stylesheet.** Not because the CSP forbids it -- `style-src`
20 //! allows `'self'` -- but because linking the platform sheet would pull
21 //! every platform class into scope on the one surface built for creator
22 //! control. [`CHROME_CSS`] arrives
23 //! through [`Shell::with_head_first`], the way an embed's design system
24 //! does.
25 //! - **No chrome and no session.** These are cookieless documents for readers
26 //! who are not signed in, so `Chrome::new()` and ordinary axum handlers, as
27 //! in [`super::embeds`].
28 //!
29 //! # The strip is content here, not `Chrome`
30 //!
31 //! A [`quasi_router::Chrome`] is built once and held beside the router, and
32 //! every link in this strip is per request: the canonical URL and the creator's
33 //! name change with the page. So the header and footer are regions on the
34 //! screen, [`RegionKind::Band`] each, which is what a band is -- a full-width
35 //! strip with a title and an actions cluster.
36 //!
37 //! They are siblings of the canvas rather than ancestors, which is the whole of
38 //! the isolation guarantee and is unchanged: `css_sanitizer` re-emits every
39 //! creator rule nested inside `.user-canvas#uc-{owner}`, so a creator selector
40 //! naming anything in the strip is emitted under the canvas and matches
41 //! nothing. No specificity contest, no layer, no `!important`. See
42 //! `css_sanitizer::platform_chrome_is_unreachable_from_creator_css`.
43 //!
44 //! # Every link out of here is [`Action::leaving`]
45 //!
46 //! quasi 0.93.0. These pages are a satellite of the apex and the strip exists
47 //! to send readers to it, so the links replace the page rather than opening
48 //! tabs, which is what they did as Askama and what `Destination::External`
49 //! could not say.
50 //!
51 //! # The published class names survive the conversion
52 //!
53 //! `site-docs/public/guide/custom-pages.md` tells creators that the buy block
54 //! is `.mnw-buy`, the file list `.mnw-files` and the item block `.mnw-item`,
55 //! and gives worked CSS against them. Those are a contract with people outside
56 //! this codebase, so they are carried on [`Node::Canvas`] scopes -- described
57 //! content under a name the app has promised, which is the second use that
58 //! member documents. Every class the strip itself uses is this renderer's and
59 //! [`CHROME_CSS`] is written against them, because nobody outside was ever
60 //! promised those.
61 //!
62 //! What did change is the markup *inside* those blocks, and it had to: a price
63 //! is a described node now rather than a hand-written div. The one place that
64 //! is visible to a creator is the call to action, which was an `<a class=
65 //! "mnw-buy-cta">` and is now that class on the element around the link. The
66 //! guide's own example sets `color` on it, and a `color` on an ancestor does
67 //! not reach an anchor -- the UA sheet beats inheritance -- so [`CHROME_CSS`]
68 //! says `color: inherit` for a link inside one. Without that line the documented
69 //! example would quietly stop working.
70
71 use quasi_axum::Serves as _;
72 use quasi_declare::declare;
73 use quasi_router::screen::Document;
74 use quasi_router::{Chrome, Screen};
75 use quasi_webview::{Shell, Webview};
76
77 /// The platform strip's styling, and the system slots' defaults.
78 ///
79 /// Inline rather than linked, and the argument is `crate::routes::user_pages`'s
80 /// rather than this module's: ~1.3KB against a second request, and a fetch that
81 /// fails leaves the strip unstyled while the creator's inline CSS still renders.
82 /// Inline cannot fail that way.
83 ///
84 /// Written against the classes and ids quasi emits, with two exceptions that
85 /// are deliberate and are named in the module header: the `mnw-` names are a
86 /// published contract with creators, and `#chrome-top` / `#chrome-bottom` are
87 /// region ids this module chose.
88 const CHROME_CSS: &str = "\
89 <style>\
90 html,body{margin:0;padding:0}\
91 body{font-family:system-ui,-apple-system,\"Segoe UI\",sans-serif}\
92 #chrome-top,#chrome-bottom{background:#161616;color:#e9e9e9;font-size:.85rem;line-height:1.4}\
93 #chrome-top{display:flex;justify-content:space-between;align-items:center;gap:1rem;\
94 padding:.55rem 1rem;border-bottom:1px solid #2c2c2c}\
95 #chrome-top a,#chrome-bottom a{color:#e9e9e9;text-decoration:none}\
96 #chrome-top a:hover,#chrome-bottom a:hover{text-decoration:underline}\
97 #chrome-top a:first-child{font-weight:600;letter-spacing:.01em}\
98 #chrome-bottom{padding:1rem;text-align:center;border-top:1px solid #2c2c2c;color:#9a9a9a}\
99 .mnw-buy,.mnw-item{margin:1.5rem 0;padding:1rem;border:1px solid currentColor;border-radius:4px}\
100 .mnw-buy-cta,.mnw-item-cta{display:inline-block;margin-top:.5rem;padding:.4rem .9rem;\
101 border:1px solid currentColor;border-radius:4px;font-weight:600}\
102 .mnw-buy-cta a,.mnw-item-cta a{color:inherit;text-decoration:none}\
103 .mnw-files ul{margin:0;padding:0;list-style:none}\
104 .mnw-files{margin:1.5rem 0}\
105 .mnw-files li{padding:.35rem 0;border-bottom:1px solid currentColor}\
106 .mnw-price{font-weight:600}\
107 </style>";
108
109 /// One entry in a project's file list.
110 pub struct File {
111 /// What the file is called.
112 pub title: String,
113 /// Where it is on the apex.
114 pub url: String,
115 }
116
117 /// Everything a creator profile draws from.
118 pub struct UserView {
119 /// The document's title.
120 pub page_title: String,
121 /// The apex, for the brand link.
122 pub apex_url: String,
123 /// This page's home on the apex.
124 pub canonical_url: String,
125 /// What to call the creator.
126 pub creator_label: String,
127 /// The owner id the sanitiser scoped the CSS to.
128 pub canvas_id: String,
129 /// The creator's stylesheet, sanitised.
130 pub sanitized_css: String,
131 /// The creator's markup, sanitised.
132 pub sanitized_html: String,
133 }
134
135 /// Everything a project page draws from.
136 pub struct ProjectView {
137 /// The profile half, which a project page carries all of.
138 pub page: UserView,
139 /// What the project costs, as a sentence.
140 pub price_label: String,
141 /// Where buying happens.
142 pub buy_url: String,
143 /// The project's published items.
144 pub files: Vec<File>,
145 }
146
147 /// Everything an item page draws from.
148 ///
149 /// No creator markup of any kind: an item page is the trustworthy default
150 /// layout wearing the parent project's CSS, re-scoped to the item canvas. It
151 /// takes the head half of the conversion and no part of [`Node::Canvas`]'s
152 /// markup, which is why it was the one to convert first.
153 pub struct ItemView {
154 /// The document's title.
155 pub page_title: String,
156 /// The apex, for the brand link.
157 pub apex_url: String,
158 /// This page's home on the apex.
159 pub canonical_url: String,
160 /// What to call the creator.
161 pub creator_label: String,
162 /// The project id the parent's CSS was re-scoped to.
163 pub canvas_id: String,
164 /// The parent project's stylesheet, re-scoped to the item canvas.
165 pub sanitized_css: String,
166 /// The item's name.
167 pub item_title: String,
168 /// What the creator said about it, if anything.
169 pub item_description: Option<String>,
170 /// What the item costs, as a sentence.
171 pub price_label: String,
172 /// Where buying happens.
173 pub buy_url: String,
174 }
175
176 /// The whole document for one of these screens.
177 ///
178 /// [`super::embeds::document`]'s shape and for its reasons, with one addition:
179 /// the screen carries a stylesheet of its own, which the renderer writes last
180 /// in the head and outside the layer statement. That is what puts the creator's
181 /// CSS on top of [`CHROME_CSS`] without either of them naming a layer.
182 #[must_use]
183 pub fn document(screen: &Screen) -> String {
184 let shell = Shell::default()
185 .without_scripts()
186 .with_chrome(Chrome::new())
187 .with_head_first(CHROME_CSS);
188 Webview::new().with_shell(shell).screen(screen)
189 }
190
191 /// The class every creator rule was nested inside.
192 ///
193 /// `css_sanitizer::sanitize_css`'s, not this module's: the class and the id it
194 /// pairs with are what every creator rule was rewritten under, so a name
195 /// changed here silently unstyles every custom page. [`UserView::scope_id`] is
196 /// the other half.
197 const SCOPE_CLASS: &str = "user-canvas";
198
199 impl UserView {
200 /// The id half of the scope the sanitiser rewrote every rule for.
201 ///
202 /// See [`SCOPE_CLASS`]. Two screens draw this element -- a profile and a
203 /// project page -- so the name is read from one place rather than written
204 /// out at both.
205 fn scope_id(&self) -> String {
206 format!("uc-{}", self.canvas_id)
207 }
208 }
209
210 impl ItemView {
211 /// Whether the creator said anything about this item.
212 ///
213 /// A predicate rather than an `Option` the description reaches into, which
214 /// is the call `super::embeds::ItemView::has_cover` records: the form has
215 /// no binding pattern.
216 fn has_description(&self) -> bool {
217 self.item_description.is_some()
218 }
219
220 /// What they said, or nothing.
221 ///
222 /// R9: the paragraph is built whether or not [`Self::has_description`]
223 /// places it, so the case that is not drawn is answered rather than
224 /// panicked on.
225 fn description(&self) -> &str {
226 self.item_description.as_deref().unwrap_or_default()
227 }
228 }
229
230 declare! {
231 /// The platform strip across the top.
232 ///
233 /// A region on the screen rather than [`quasi_router::Chrome`], because
234 /// every link in it is per request: the canonical URL and the creator's
235 /// name change with the page. See the module header.
236 shape top(apex_url: &str, canonical_url: &str) -> Slot;
237
238 region "chrome-top" as Band {
239 link "makenot.work" to leaving apex_url;
240 link "View on makenot.work" to leaving canonical_url;
241 }
242 }
243
244 declare! {
245 /// The platform strip across the bottom.
246 shape footer(creator_label: &str, canonical_url: &str) -> Slot;
247
248 region "chrome-bottom" as Band {
249 link "{creator_label} on makenot.work" to leaving canonical_url;
250 }
251 }
252
253 declare! {
254 /// A creator's profile.
255 ///
256 /// The strips are siblings of the canvas rather than ancestors, which is
257 /// the whole of the isolation guarantee: a creator selector naming anything
258 /// in a strip is emitted under the canvas and matches nothing.
259 #[must_use]
260 pub shape user(view: &UserView) -> Screen;
261
262 screen single &view.page_title {
263 documented Document::default().styled(&view.sanitized_css);
264
265 include top(&view.apex_url, &view.canonical_url);
266 region "canvas" as Pane {
267 canvas &view.sanitized_html {
268 classed SCOPE_CLASS;
269 identified view.scope_id();
270 }
271 }
272 include footer(&view.creator_label, &view.canonical_url);
273 }
274 }
275
276 declare! {
277 /// A creator's project page: their markup, then the platform's own blocks
278 /// inside the same scope.
279 #[must_use]
280 pub shape project(view: &ProjectView) -> Screen;
281
282 screen single &view.page.page_title {
283 documented Document::default().styled(&view.page.sanitized_css);
284
285 include top(&view.page.apex_url, &view.page.canonical_url);
286 region "canvas" as Pane {
287 canvas &view.page.sanitized_html {
288 classed SCOPE_CLASS;
289 identified view.page.scope_id();
290
291 // Inside the canvas and after the markup, which is what the
292 // Askama template did and is what lets a creator's sheet reach
293 // these blocks at all. Outside would be a different page,
294 // quietly.
295 include slot_block("mnw-buy", "mnw-buy-cta", &view.price_label, &view.buy_url);
296 canvas "" unless view.files.is_empty() {
297 classed "mnw-files";
298 list {
299 for file in view.files.iter() {
300 row &file.title {
301 activate to leaving &file.url;
302 }
303 }
304 }
305 }
306 }
307 }
308 include footer(&view.page.creator_label, &view.page.canonical_url);
309 }
310 }
311
312 declare! {
313 /// An item page, which is the default layout in the parent project's
314 /// clothes.
315 ///
316 /// `item-canvas` and `ic-` are what `sanitize_item_css` scopes to. The two
317 /// names travel together and neither is this module's to choose.
318 #[must_use]
319 pub shape item(view: &ItemView) -> Screen;
320
321 screen single &view.page_title {
322 documented Document::default().styled(&view.sanitized_css);
323
324 include top(&view.apex_url, &view.canonical_url);
325 region "canvas" as Pane {
326 canvas "" {
327 classed "item-canvas";
328 identified "ic-{view.canvas_id}";
329
330 canvas "" {
331 classed "mnw-item";
332 page &view.item_title;
333 text view.description() when view.has_description();
334 include price(&view.price_label);
335 include cta("mnw-item-cta", &view.buy_url);
336 }
337 }
338 }
339 include footer(&view.creator_label, &view.canonical_url);
340 }
341 }
342
343 declare! {
344 /// A system slot: a price and the way to buy, under a published class name.
345 shape slot_block(
346 block_class: &str,
347 cta_class: &str,
348 price_label: &str,
349 buy_url: &str,
350 ) -> Node;
351
352 canvas "" {
353 classed block_class;
354 include price(price_label);
355 include cta(cta_class, buy_url);
356 }
357 }
358
359 declare! {
360 /// What it costs, under the class the guide names.
361 shape price(label: &str) -> Node;
362
363 canvas "" {
364 classed "mnw-price";
365 text label;
366 }
367 }
368
369 declare! {
370 /// The way to buy, under the class the guide names.
371 shape cta(class: &str, buy_url: &str) -> Node;
372
373 canvas "" {
374 classed class;
375 link "Get on makenot.work" to leaving buy_url;
376 }
377 }
378
379 #[cfg(test)]
380 mod tests {
381 use super::*;
382
383 /// Everything from the open `<body>` tag on.
384 ///
385 /// Every assertion about what is *drawn* uses this rather than the whole
386 /// document, because `CHROME_CSS` names `.mnw-files` and `.mnw-price` in
387 /// the head: a search over the document finds the rule and concludes the
388 /// block is on the page.
389 fn body(html: &str) -> &str {
390 &html[html.find("<body").expect("a document has a body")..]
391 }
392
393 fn a_user() -> UserView {
394 UserView {
395 page_title: "Ada - makenot.work".into(),
396 apex_url: "https://makenot.work".into(),
397 canonical_url: "https://makenot.work/u/ada".into(),
398 creator_label: "Ada".into(),
399 canvas_id: "11111111".into(),
400 sanitized_css: ".user-canvas#uc-11111111 p{color:red}".into(),
401 sanitized_html: "<h1>Ada</h1>".into(),
402 }
403 }
404
405 #[test]
406 fn the_canvas_carries_the_scope_the_sanitiser_rewrote_every_rule_for() {
407 // `css_sanitizer` nests every creator rule inside
408 // `.user-canvas#uc-{owner}`. If the element stops carrying both halves,
409 // every custom page silently loses its styling, so this is the
410 // load-bearing assertion in the module.
411 let html = document(&user(&a_user()));
412
413 assert!(
414 html.contains("<div class=\"user-canvas\" id=\"uc-11111111\"><h1>Ada</h1></div>"),
415 "{html}"
416 );
417 }
418
419 #[test]
420 fn the_creators_stylesheet_is_in_the_head_after_the_platforms() {
421 // Both are inline and neither is layered, so the later one wins. The
422 // creator's is the screen's own and the renderer writes it last.
423 let html = document(&user(&a_user()));
424 let chrome = html.find("#chrome-top").expect("the strip is styled");
425 let creator = html
426 .find(".user-canvas#uc-11111111 p{color:red}")
427 .expect("the creator's sheet is written");
428 let head_end = html.find("</head>").expect("the head closes");
429
430 assert!(chrome < creator, "{html}");
431 assert!(creator < head_end, "{html}");
432 }
433
434 #[test]
435 fn the_strip_is_a_sibling_of_the_canvas_and_not_an_ancestor() {
436 // The whole isolation guarantee. A creator selector naming anything in
437 // the strip is emitted under the canvas and matches nothing, which only
438 // holds while the strip is outside it.
439 let html = document(&user(&a_user()));
440 let body = body(&html);
441 let top = body.find("id=\"chrome-top\"").expect("the top strip");
442 let canvas = body.find("class=\"user-canvas\"").expect("the canvas");
443 let bottom = body.find("id=\"chrome-bottom\"").expect("the bottom strip");
444
445 assert!(top < canvas && canvas < bottom, "{body}");
446 // Not nested: the canvas region closes before the footer opens.
447 assert!(!body[canvas..bottom].contains("id=\"chrome-"), "{body}");
448 }
449
450 #[test]
451 fn no_script_reaches_a_page_whose_csp_forbids_every_one() {
452 // `default-src 'none'`. A script element here is a console error.
453 let html = document(&user(&a_user()));
454
455 assert!(!html.contains("<script"), "{html}");
456 assert!(!html.contains("<link"), "{html}");
457 }
458
459 #[test]
460 fn every_way_off_this_page_replaces_it_rather_than_opening_a_tab() {
461 // These pages are a satellite of the apex and the strip exists to send
462 // readers to it. This is what the Askama version did.
463 let html = document(&user(&a_user()));
464
465 assert!(!html.contains("target=\"_blank\""), "{html}");
466 assert!(html.contains("href=\"https://makenot.work\""), "{html}");
467 }
468
469 #[test]
470 fn the_published_system_slot_names_survive_the_conversion() {
471 // `site-docs/public/guide/custom-pages.md` names these to creators and
472 // gives worked CSS against them, so they are a contract with people
473 // outside this codebase rather than class names this module picked.
474 let view = ProjectView {
475 page: a_user(),
476 price_label: "$12".into(),
477 buy_url: "https://makenot.work/p/thing".into(),
478 files: vec![File {
479 title: "one.wav".into(),
480 url: "https://makenot.work/i/1".into(),
481 }],
482 };
483 let html = document(&project(&view));
484
485 assert!(html.contains("class=\"mnw-buy\""), "{html}");
486 assert!(html.contains("class=\"mnw-buy-cta\""), "{html}");
487 assert!(html.contains("class=\"mnw-price\""), "{html}");
488 assert!(html.contains("class=\"mnw-files\""), "{html}");
489 }
490
491 #[test]
492 fn the_platform_blocks_sit_inside_the_creators_scope_after_their_markup() {
493 // What the Askama template did, and what lets a creator's sheet reach
494 // the buy block at all. Outside would be a different page, quietly.
495 let view = ProjectView {
496 page: a_user(),
497 price_label: "$12".into(),
498 buy_url: "https://makenot.work/p/thing".into(),
499 files: Vec::new(),
500 };
501 let html = document(&project(&view));
502 let canvas = html
503 .find("class=\"user-canvas\"")
504 .expect("the canvas opens");
505 let markup = html.find("<h1>Ada</h1>").expect("the creator's markup");
506 let buy = html.find("class=\"mnw-buy\"").expect("the buy block");
507
508 assert!(canvas < markup && markup < buy, "{html}");
509 }
510
511 #[test]
512 fn a_project_with_no_published_items_draws_no_file_list() {
513 // The template guarded on `items.is_empty()`, and an empty bordered box
514 // is worse than no box.
515 let view = ProjectView {
516 page: a_user(),
517 price_label: "Free".into(),
518 buy_url: "https://makenot.work/p/thing".into(),
519 files: Vec::new(),
520 };
521
522 assert!(!body(&document(&project(&view))).contains("mnw-files"));
523 }
524
525 #[test]
526 fn an_item_page_wears_the_parents_stylesheet_under_the_item_canvas() {
527 // `sanitize_item_css` scopes to `.item-canvas#ic-{project}`, so the two
528 // names travel together and neither is this module's to choose. An item
529 // page has no creator markup of its own.
530 let html = document(&item(&ItemView {
531 page_title: "Track - makenot.work".into(),
532 apex_url: "https://makenot.work".into(),
533 canonical_url: "https://makenot.work/i/9".into(),
534 creator_label: "Ada".into(),
535 canvas_id: "22222222".into(),
536 sanitized_css: ".item-canvas#ic-22222222 p{color:red}".into(),
537 item_title: "Track".into(),
538 item_description: Some("A track.".into()),
539 price_label: "$3".into(),
540 buy_url: "https://makenot.work/i/9".into(),
541 }));
542
543 assert!(
544 html.contains("<div class=\"item-canvas\" id=\"ic-22222222\">"),
545 "{html}"
546 );
547 assert!(html.contains("class=\"mnw-item\""), "{html}");
548 assert!(html.contains("class=\"mnw-item-cta\""), "{html}");
549 assert!(!html.contains("user-canvas"), "{html}");
550 }
551
552 #[test]
553 fn an_item_with_no_description_draws_none() {
554 let html = document(&item(&ItemView {
555 page_title: "Track - makenot.work".into(),
556 apex_url: "https://makenot.work".into(),
557 canonical_url: "https://makenot.work/i/9".into(),
558 creator_label: "Ada".into(),
559 canvas_id: "22222222".into(),
560 sanitized_css: String::new(),
561 item_title: "Track".into(),
562 item_description: None,
563 price_label: "$3".into(),
564 buy_url: "https://makenot.work/i/9".into(),
565 }));
566
567 // The heading, the price and the call to action, and nothing between
568 // the first two.
569 let drawn = body(&html);
570 let heading = drawn.find("<h1").expect("the item is named");
571 let price = drawn.find("mnw-price").expect("the price is drawn");
572 assert!(!drawn[heading..price].contains("class=\"text\""), "{drawn}");
573 }
574
575 #[test]
576 fn a_locked_creator_gets_the_strip_and_an_empty_canvas() {
577 // The route passes empty strings for a locked owner, and the page still
578 // has to be a page.
579 let mut view = a_user();
580 view.sanitized_html = String::new();
581 view.sanitized_css = String::new();
582 let html = document(&user(&view));
583
584 assert!(
585 html.contains("<div class=\"user-canvas\" id=\"uc-11111111\">"),
586 "{html}"
587 );
588 assert!(html.contains("id=\"chrome-top\""), "{html}");
589 assert!(html.contains("id=\"chrome-bottom\""), "{html}");
590 }
591 }
592