Skip to main content

max / quasi

58.3 KB · 1295 lines History Blame Raw
1 //! The document around a screen.
2 //!
3 //! The renderer owns the document and the host injects what it knows. The
4 //! alternative was each adapter supplying its own `<html>` and the renderer
5 //! filling the inside, which is honest about axum and Tauri resolving assets
6 //! differently and costs two heads to keep in step — the divergence the stack
7 //! exists to end. A served page and a custom-protocol page differ in where
8 //! their assets live and in nothing else, so where assets live is the
9 //! parameter and the rest is emitted once.
10 //!
11 //! What the host owes is small enough to list: four asset URLs, a language,
12 //! and whatever else belongs in its own head. Everything with an opinion in it
13 //! — the viewport, where the body's classes come from — is here, because a host that
14 //! could get those wrong is a host that can diverge.
15 //!
16 //! The transport is htmx 4 (see [`quasi_http::htmx`]), which is what took two
17 //! things out of this file: there is no extension to register for morphing,
18 //! since `outerMorph` is a swap style 4 ships, and there is no
19 //! `responseHandling` meta tag to emit, since 4 swaps every status but 204 and
20 //! 304 on its own.
21
22 use makeover_webview::form::escape_into;
23 use quasi_router::{Chrome, Discovery, Document};
24
25 /// The parts of a document only the host knows.
26 ///
27 /// A [`Default`] shell is a valid one: the asset paths are what a server
28 /// mounting its static directory at `/static` already serves, which is what
29 /// both MNW and multithreaded do today, and the Tauri adapter overrides them
30 /// with its own scheme.
31 #[derive(Debug, Clone, PartialEq, Eq)]
32 pub struct Shell {
33 /// The document language, for `<html lang>`.
34 pub lang: String,
35 /// Where htmx is served from.
36 ///
37 /// `None` drops the script, for a document that calls no route. The five
38 /// MNW embeds are the measured case: an embed is an iframe on a third
39 /// party's page, and it contains not one `hx-` attribute. Shipping htmx to
40 /// it is a script the reader downloads and runs to do nothing.
41 ///
42 /// There is nothing to get quietly wrong here. Dropping htmx from a
43 /// document that never asked it for anything changes nothing a reader can
44 /// see, and a document that does carry `hx-` attributes and drops this
45 /// stops working visibly, on the first control pressed, which is the
46 /// failure that reports itself.
47 ///
48 /// What is served has to be htmx 4. The emitted markup is 4's -- the
49 /// `outerMorph` swap, `hx-disable` for the lock, `hx-headers:inherited` on
50 /// the body -- and 2.x reads none of it, so pointing this at a 2.x bundle
51 /// is a page whose controls fetch and whose swaps destroy state.
52 pub htmx_src: Option<String>,
53 /// Where this crate's selection script is served from.
54 ///
55 /// [`SELECTION_JS`](crate::SELECTION_JS) is its source, shipped by this
56 /// crate rather than written per app: what it does is renderer behaviour,
57 /// the same way the terminal's keys are, and every app hand-rolling it is
58 /// the plumbing this stack exists to delete.
59 ///
60 /// `None` drops it, and the fallback is safe and visible rather than
61 /// silent: a commit control stays enabled and says no count: there is
62 /// nothing here to get quietly wrong.
63 ///
64 /// # What it is for
65 ///
66 /// The two things a bulk bar does that no description can say. The ticks on
67 /// a screen are the host's until something submits them, so a route
68 /// answering from the store cannot know how many are set, and a commit
69 /// control over an empty selection is otherwise offered, pressed, and
70 /// answers "0 tasks completed". Both are the browser's to know, and this is
71 /// the browser saying them. The terminal and egui renderers hold the set in
72 /// their own `View` and say the same two things in Rust.
73 pub selection_src: Option<String>,
74 /// Where _hyperscript is served from.
75 ///
76 /// The runtime for the programs this crate emits. A region that shows one
77 /// child at a time carries the programs that move between its frames, and
78 /// without the runtime they are unknown attributes a browser ignores.
79 ///
80 /// `None` drops it. What is lost is the local chrome and nothing else: the
81 /// frames are all in the document, the first one is showing, and a strip
82 /// button does what it did before this crate emitted a program, which is
83 /// whatever the host binds to `data-shows`. Visible and safe, the same
84 /// shape as the other two scripts' absence.
85 ///
86 /// It has to be the real hyperscript rather than a build with the `js`
87 /// feature stripped or added: nothing emitted here uses `js`, which is the
88 /// adoption's one rule, and it is also the only path in the interpreter
89 /// that would need `unsafe-eval` from a CSP.
90 pub hyperscript_src: Option<String>,
91 /// Where this crate's clock script is served from.
92 ///
93 /// [`CLOCK_JS`](crate::CLOCK_JS) is its source, shipped by this crate for
94 /// [`selection_src`](Self::selection_src)'s reason: keeping a readout
95 /// current is renderer behaviour, and every app writing it again is the
96 /// plumbing this stack exists to delete. goingson's copy is 564 lines.
97 ///
98 /// It also takes a toast off the page once its time is up, which is the
99 /// other thing on a page that happens because time passed.
100 ///
101 /// `None` drops it, and what is lost is the ticking rather than the
102 /// readout: the words the server emitted stay on the page and stop moving,
103 /// which is what every server-rendered timestamp in the tree already does.
104 /// A toast stays up instead of dismissing itself, which is the same shape
105 /// of loss: what the server said is there and nothing moves it. Visible and
106 /// safe, like the selection script's absence.
107 ///
108 /// Kept by [`without_htmx`](Self::without_htmx), which drops the selection
109 /// script. The difference is what each one needs: a bulk bar is a set of
110 /// ticks submitted to a route, so a document that calls none has no use for
111 /// one, while a page with no routes at all can still say how long ago
112 /// something happened.
113 pub clock_src: Option<String>,
114 /// Where this crate's download script is served from.
115 ///
116 /// [`DOWNLOAD_JS`](crate::DOWNLOAD_JS) is its source. `67881a88`: a route
117 /// can answer [`Outcome::File`](quasi_router::Outcome::File), and on this
118 /// host the http adapter writes `Content-Disposition`, which is the whole story
119 /// for a plain link and is not enough for a control that reaches its
120 /// route through htmx. htmx makes an XHR, an XHR is not a navigation, and a
121 /// browser saves nothing: the file is swapped into the page as text. This
122 /// script cancels that swap and hands the body to the browser itself.
123 ///
124 /// `None` drops it, and this is the one script whose absence is neither
125 /// safe nor quiet, which is why it says so here rather than claiming the
126 /// shape of the other four: a page that drops it and then presses an
127 /// export control gets the file's contents swapped into a region. Drop it
128 /// only on a document with no route that answers a file: a page whose
129 /// downloads are plain links has no use for it, and that is the case it is
130 /// [`None`] for.
131 pub download_src: Option<String>,
132 /// Where this crate's fill script is served from.
133 ///
134 /// [`FILL_JS`](crate::FILL_JS) is its source. `f35aafee`: an
135 /// [`Act::fills`](quasi_router::Act::fills) names a field on the same
136 /// screen that receives the act's value, and where inside that field the
137 /// value lands is the renderer's. Here it is the selection, which is what
138 /// the three MNW surfaces this was measured on need — a server-side append
139 /// costs up to thirty seconds of typing on one of them and the whole
140 /// unsaved draft on the other two.
141 ///
142 /// `None` drops it, and the loss reports itself on the first press: the
143 /// control runs its own action and writes nothing, which is what a picker
144 /// did before the member existed. Drop it on a document with no act that
145 /// fills.
146 ///
147 /// Independent of htmx, so [`without_htmx`](Self::without_htmx) keeps it:
148 /// what it reads is two attributes on a control, and a document that calls
149 /// no route can still deposit a value in a box.
150 pub fill_src: Option<String>,
151 /// Where this crate's reveal script is served from.
152 ///
153 /// [`REVEAL_JS`](crate::REVEAL_JS) is its source. `079a011e`: a region can
154 /// say which control and which value bring it out, and evaluating that is
155 /// local work with no request in it. This file is what does it here.
156 ///
157 /// `None` drops it, and the loss is visible on the first screen that has a
158 /// conditional region: every one of them is out, whatever its control
159 /// holds. Drop it on a document with no region that names a condition.
160 ///
161 /// Independent of htmx, so [`without_htmx`](Self::without_htmx) keeps it:
162 /// what it reads is three attributes on a region and the value of a control
163 /// beside it, and a document that calls no route can still have a form with
164 /// a section that only sometimes applies.
165 pub reveal_src: Option<String>,
166 /// Where this crate's clipboard script is served from.
167 ///
168 /// [`COPY_JS`](crate::COPY_JS) is its source. `c3e145e0`: an
169 /// [`Act::copies`](quasi_router::Act::copies) says a press puts a value on
170 /// the clipboard, which is local work with no request in it, and this file
171 /// is what does it here.
172 ///
173 /// `None` drops it, and the loss is total rather than partial: a copy
174 /// control does nothing at all when pressed. There is no markup fallback to
175 /// degrade to, because no browser copies without a script. Drop it on a
176 /// document with no act that copies.
177 ///
178 /// Independent of htmx, so [`without_htmx`](Self::without_htmx) keeps it:
179 /// what it reads is one attribute on a control, and a document that calls
180 /// no route can still hand a value to the clipboard.
181 pub copy_src: Option<String>,
182 /// Where this crate's repeat script is served from.
183 ///
184 /// [`REPEAT_JS`](crate::REPEAT_JS) is its source. `60d1753c`: a question
185 /// answered N times lets the reader add and remove the slots, and doing
186 /// that is local work with a `template` already in the document.
187 ///
188 /// `None` drops it, and the loss is visible on the first form with a
189 /// repeating question: the slots the description offered are there and the
190 /// add and remove controls do nothing. Drop it on a document with no
191 /// question that repeats.
192 ///
193 /// Independent of htmx, so [`without_htmx`](Self::without_htmx) keeps it:
194 /// what it reads is four attributes on a fieldset, and adding a slot is a
195 /// clone of markup that is already on the page.
196 pub repeat_src: Option<String>,
197 /// Where this crate's awaiting script is served from.
198 ///
199 /// [`AWAITING_JS`](crate::AWAITING_JS) is its source. `d43ea1c5`: the node
200 /// emitter says which calls wait and `makeover-webview` draws what a wait
201 /// looks like, and neither of them knows when one is running. This file
202 /// puts `aria-busy` on the element making the request, which is what the
203 /// design system keys the drawing off, and sets `--awaiting-share` from
204 /// bytes the browser reports delivered.
205 ///
206 /// `None` drops it, and the loss is a page where nothing marks a wait.
207 /// Every control still locks itself through `hx-disable`: less decoration,
208 /// never less behaviour.
209 ///
210 /// **Not** independent of htmx, unlike [`fill_src`](Self::fill_src),
211 /// [`reveal_src`](Self::reveal_src) and [`repeat_src`](Self::repeat_src).
212 /// What it listens to is the request lifecycle, so
213 /// [`without_htmx`](Self::without_htmx) drops it with the rest.
214 pub awaiting_src: Option<String>,
215 /// Where this crate's instant script is served from.
216 ///
217 /// [`INSTANT_JS`](crate::INSTANT_JS) is its source.
218 /// [`makeover_layout::Field::as_instant`] is a description saying a
219 /// wall-clock value is submitted as the moment it names and declining to
220 /// say how; the browser is the only party that knows what "your computer's
221 /// time zone" means, so how is this file. `makeover-webview` marks the
222 /// input and this rewrites the value on the way out.
223 ///
224 /// `None` drops it, and the loss is a form submitting a local wall-clock
225 /// string to a route expecting an instant. That is a wrong value rather
226 /// than a missing affordance, so a host with a described datetime field
227 /// that asks for one wants this served.
228 ///
229 /// **Not** independent of htmx, for [`awaiting_src`](Self::awaiting_src)'s
230 /// reason: what it listens to is the request lifecycle, so
231 /// [`without_htmx`](Self::without_htmx) drops it with the rest.
232 pub instant_src: Option<String>,
233 /// Where the focus script is served from, or `None` to leave it out.
234 ///
235 /// htmx restores focus around the *main* swap target and only that one, so
236 /// a focused control inside an out-of-band region is destroyed outside
237 /// that window and focus falls to `body`. This renderer emits out-of-band
238 /// swaps, so it owns the gap.
239 ///
240 /// **Not** independent of htmx, for [`awaiting_src`](Self::awaiting_src)'s
241 /// reason: it reads the request lifecycle.
242 ///
243 /// `None` drops it, and the loss falls entirely on readers navigating by
244 /// keyboard: after a response that updates a second region, the caret is
245 /// gone and the next Tab starts from the top of the document. Silent to
246 /// everyone else, which is the reason it is on by default.
247 pub focus_src: Option<String>,
248 /// Where this crate's menu script is served from.
249 ///
250 /// [`MENU_JS`](crate::MENU_JS) is its source.
251 /// [`makeover_layout::Fallback::Menu`] asks for the members that no longer
252 /// fit to move into one overflow control, which needs a measurement CSS
253 /// cannot make; this is the measuring.
254 ///
255 /// Independent of htmx, like [`fill_src`](Self::fill_src): it measures on
256 /// resize and on load, and takes a settle as one more occasion when there
257 /// is one. [`without_htmx`](Self::without_htmx) keeps it.
258 ///
259 /// `None` drops it, and the loss is a menu run that wraps instead of
260 /// folding. Every member stays reachable, so this is less rearrangement and
261 /// never less behaviour, which is the direction the rest of this renderer
262 /// degrades in.
263 pub menu_src: Option<String>,
264 /// Where [`crate::OUTLINE_JS`] is served from, or `None` not to ship it.
265 ///
266 /// A described hierarchy is a flat list of rows carrying their depth; the
267 /// emitter hides what a shut branch covers, and this is the press that
268 /// opens it again.
269 ///
270 /// Independent of htmx, like [`menu_src`](Self::menu_src): one delegated
271 /// listener on the document, so [`without_htmx`](Self::without_htmx) keeps
272 /// it and a list arriving in a swap is covered.
273 ///
274 /// `None` drops it, and the loss is an outline stuck in the shape the
275 /// description gave it, with every row still reachable. Less rearrangement
276 /// and never less content, which is the direction the rest of this renderer
277 /// degrades in.
278 pub outline_src: Option<String>,
279 /// Stylesheets, in link order.
280 pub stylesheets: Vec<String>,
281 /// The app's own cascade layer names, in priority order, lowest first.
282 ///
283 /// The renderer always emits the layer statement, with `makeover` first and
284 /// these after it, before any stylesheet link:
285 ///
286 /// ```css
287 /// @layer makeover, base, components, responsive;
288 /// ```
289 ///
290 /// It is emitted even when this is empty, because the point is not the
291 /// app's names but `makeover`'s position. A layer's place in the cascade is
292 /// fixed where its name is FIRST seen, so with no statement the generated
293 /// stylesheets establish `makeover` simply by loading first, and reordering
294 /// two link tags silently reorders the cascade.
295 ///
296 /// This is a field rather than something the host writes into
297 /// [`head_first`](Self::head_first) because it is exactly the class of
298 /// thing the module header says belongs here: a host that could get it
299 /// wrong is a host that can diverge, and getting it wrong is silent. The
300 /// CSS stays valid, the minifier stays happy, and buttons and badges look
301 /// subtly wrong. Only the app's own names are the host's to supply, since
302 /// the renderer cannot know them.
303 ///
304 /// Names are filtered to CSS identifier characters. A name is markup inside
305 /// a `<style>` element, where HTML escaping does not apply, so a stray `<`
306 /// would be a way out of the element rather than a character.
307 pub app_layers: Vec<String>,
308 /// Markup emitted near the top of the head verbatim, before the layer
309 /// statement and every stylesheet. Not escaped.
310 ///
311 /// For the things whose whole value is being early: a font preload, a
312 /// preconnect. [`head`](Self::head) is appended last and cannot serve them,
313 /// and a preload discovered after the stylesheets it races is a preload
314 /// that bought nothing.
315 pub head_first: Option<String>,
316 /// Markup appended to the head verbatim. Not escaped.
317 ///
318 /// The escape hatch for what no description will ever name: a favicon, a
319 /// preconnect, a theme bootstrap that has to run before first paint. It is
320 /// last in the head so it can override anything above it.
321 pub head: Option<String>,
322 /// Headers every request this document makes must carry.
323 ///
324 /// Named `sends` and not `csrf`, because the vocabulary describes what a
325 /// document does rather than why one host needs it: a request-scoped header
326 /// is a fact about this viewer's session, and a session token is one thing
327 /// it can hold. A description says the value must travel; the renderer
328 /// decides how it travels.
329 ///
330 /// The webview renderer puts them on `<body>` as `hx-headers:inherited`,
331 /// which is how htmx 4 spells an attribute meant for the elements below it:
332 /// 4 inherits nothing implicitly, so a bare `hx-headers` there would be a
333 /// header the body sends on its own requests and no control sends on any. A renderer that dispatches in-process has no
334 /// request to attach them to and ignores them, which is the correct answer
335 /// rather than a gap: there is no cross-origin caller to prove against.
336 ///
337 /// # Why this exists at all
338 ///
339 /// A server validating a session token rejects every write a described page
340 /// makes, because nothing in the document supplies one. The alternative was
341 /// each host shipping a script to read a meta tag and set the header, which
342 /// is the glue file a conversion to descriptions exists to delete, and which
343 /// a `script-src 'self'` policy will not run inline anyway.
344 pub sends: Vec<(String, String)>,
345 /// Markup opening the body, before anything the screen or the chrome draws.
346 ///
347 /// [`head_first`](Self::head_first)'s counterpart at the other end of the
348 /// document, and it exists for the one thing that has to be first to work
349 /// at all: a skip link. An anchor to the content is useless anywhere but
350 /// the top, since what it exists to skip is everything above it.
351 ///
352 /// Where it points is the host's, and a described screen gives it
353 /// somewhere to go: a region carries its slot id, so `#<slot>` is an
354 /// address the description already published.
355 ///
356 /// An escape hatch, and it reads as one. What belongs here is markup no
357 /// description will name.
358 pub body_first: Option<String>,
359 /// Markup appended to the end of the body verbatim. Not escaped.
360 ///
361 /// [`head`](Self::head)'s mirror, and it exists for the same host and the
362 /// same reason: a page being converted a screen at a time still owes
363 /// whatever its old template's tail owed.
364 ///
365 /// Last in the body, after the chrome, because that is where a template
366 /// puts the tail it is replacing: a script appended before the markup it
367 /// binds to is a script that finds nothing.
368 ///
369 /// It is an escape hatch and reads as one. What belongs here is what no
370 /// description will ever name -- a script tag, a container another script
371 /// writes into. Content goes in the screen.
372 pub body_last: Option<String>,
373 /// Classes added to `<body>`, space-separated.
374 pub body_class: Option<String>,
375 /// What the app offers from every screen, rather than from one of them.
376 ///
377 /// Emitted once at the end of the body: one hidden control per binding,
378 /// plus the container an [`Outcome::Over`](quasi_router::Outcome::Over)
379 /// is drawn into. An app declaring none gets the document it got before
380 /// chrome existed, byte for byte.
381 ///
382 /// Here rather than on a screen because that is the whole claim: an
383 /// affordance reachable from everywhere is not a fact about any one place.
384 pub chrome: Chrome,
385 }
386
387 /// The screen's own stylesheet, in a `<style>` element that it cannot escape.
388 ///
389 /// [`Document::style`] is opaque CSS: this crate does not parse it and the app
390 /// that produced it owns whether its *content* is safe. What is not the app's
391 /// is the one thing that is a markup fact rather than a CSS one -- `<style>`
392 /// is a raw-text element, so the parser leaves it at the first `</style`, and
393 /// a sheet carrying that sequence would close the element early and put
394 /// everything after it into the document as markup. A sanitiser that reasons
395 /// in CSS has no occasion to notice: inside a string literal `</style` is a
396 /// perfectly ordinary run of characters, and a serialiser has no reason to
397 /// escape it.
398 ///
399 /// HTML escaping is not available as the answer, because a `<style>` element's
400 /// contents are not HTML: `&gt;` reaches the CSS parser as those four
401 /// characters and a child combinator stops working. So the sequence is broken
402 /// in CSS's own vocabulary instead. `\3c` is the hex escape for `<`, and the
403 /// `/` that follows it is not a hex digit, so the escape ends there and no
404 /// space is needed to terminate it. Inside a string the CSS parser reads the
405 /// same six characters it would have read; outside one, `</style` was never
406 /// valid CSS to begin with and what is emitted is a different flavour of
407 /// invalid.
408 ///
409 /// Case-insensitive, because the HTML parser is: `</STYLE` closes the element
410 /// just as surely.
411 fn push_style(css: &str, out: &mut String) {
412 /// What the HTML parser looks for, in the case it looks for it in.
413 const CLOSE: &str = "</style";
414
415 out.push_str("<style>");
416 let mut rest = css;
417 // `to_ascii_lowercase` on the haystack rather than a case-insensitive
418 // search, so the index found is an index into `rest`: the two strings have
419 // the same length because ASCII case folding never changes a byte count,
420 // and every byte of the needle is ASCII.
421 while let Some(at) = rest.to_ascii_lowercase().find(CLOSE) {
422 out.push_str(&rest[..at]);
423 out.push_str("\\3c");
424 // The `<` is what was replaced; the rest of the sequence is emitted as
425 // it was written, so a sheet that meant those characters keeps them.
426 rest = &rest[at + 1..];
427 }
428 out.push_str(rest);
429 out.push_str("</style>");
430 }
431
432 /// The screen's own attributes on the root element.
433 ///
434 /// A free function rather than a method, because it is the screen's statement
435 /// rather than the shell's: nothing on a [`Shell`] takes part in it, and a
436 /// method would read as though the two composed the way the body classes do.
437 ///
438 /// A name [`writable_root_attr`](quasi_router::writable_root_attr) refuses is
439 /// dropped and the rest of the document is still emitted, which is the member's
440 /// own rule. So is a repeat: the first statement about a name wins, and a
441 /// second is a description arguing with itself rather than a reason to emit two
442 /// attributes a parser would halve.
443 fn push_root_attrs(document: Option<&Document>, out: &mut String) {
444 let Some(document) = document else {
445 return;
446 };
447 let mut written: Vec<&str> = Vec::new();
448 for (name, value) in &document.root {
449 if !quasi_router::writable_root_attr(name) || written.contains(&name.as_str()) {
450 continue;
451 }
452 written.push(name);
453 out.push(' ');
454 // The name is not escaped and does not need to be: `writable_root_attr`
455 // has already refused everything escaping would have had to catch,
456 // which is why the gate is a gate rather than an escape.
457 out.push_str(name);
458 out.push_str("=\"");
459 escape_into(value, out);
460 out.push('"');
461 }
462 }
463
464 impl Default for Shell {
465 fn default() -> Self {
466 Self {
467 lang: "en".into(),
468 htmx_src: Some("/static/htmx.min.js".into()),
469 selection_src: Some("/static/quasi-selection.js".into()),
470 hyperscript_src: Some("/static/_hyperscript.min.js".into()),
471 clock_src: Some("/static/quasi-clock.js".into()),
472 download_src: Some("/static/quasi-download.js".into()),
473 fill_src: Some("/static/quasi-fill.js".into()),
474 reveal_src: Some("/static/quasi-reveal.js".into()),
475 copy_src: Some("/static/quasi-copy.js".into()),
476 repeat_src: Some("/static/quasi-repeat.js".into()),
477 awaiting_src: Some("/static/quasi-awaiting.js".into()),
478 instant_src: Some("/static/quasi-instant.js".into()),
479 focus_src: Some("/static/quasi-focus.js".into()),
480 menu_src: Some("/static/quasi-menu.js".into()),
481 outline_src: Some("/static/quasi-outline.js".into()),
482 stylesheets: Vec::new(),
483 app_layers: Vec::new(),
484 head_first: None,
485 head: None,
486 sends: Vec::new(),
487 body_first: None,
488 body_last: None,
489 body_class: None,
490 chrome: Chrome::new(),
491 }
492 }
493 }
494
495 impl Shell {
496 /// A shell serving its assets from under this prefix.
497 ///
498 /// The common case said once: `Shell::under("/assets")` rather than a path
499 /// per script written out, each of which could disagree with the others.
500 #[must_use]
501 pub fn under(prefix: &str) -> Self {
502 let prefix = prefix.trim_end_matches('/');
503 Self {
504 htmx_src: Some(format!("{prefix}/htmx.min.js")),
505 selection_src: Some(format!("{prefix}/quasi-selection.js")),
506 hyperscript_src: Some(format!("{prefix}/_hyperscript.min.js")),
507 clock_src: Some(format!("{prefix}/quasi-clock.js")),
508 download_src: Some(format!("{prefix}/quasi-download.js")),
509 fill_src: Some(format!("{prefix}/quasi-fill.js")),
510 reveal_src: Some(format!("{prefix}/quasi-reveal.js")),
511 copy_src: Some(format!("{prefix}/quasi-copy.js")),
512 repeat_src: Some(format!("{prefix}/quasi-repeat.js")),
513 awaiting_src: Some(format!("{prefix}/quasi-awaiting.js")),
514 instant_src: Some(format!("{prefix}/quasi-instant.js")),
515 focus_src: Some(format!("{prefix}/quasi-focus.js")),
516 menu_src: Some(format!("{prefix}/quasi-menu.js")),
517 outline_src: Some(format!("{prefix}/quasi-outline.js")),
518 ..Self::default()
519 }
520 }
521
522 /// Declare what the app offers from every screen.
523 #[must_use]
524 pub fn with_chrome(mut self, chrome: Chrome) -> Self {
525 self.chrome = chrome;
526 self
527 }
528
529 /// Add a stylesheet, chaining.
530 #[must_use]
531 pub fn styled(mut self, href: impl Into<String>) -> Self {
532 self.stylesheets.push(href.into());
533 self
534 }
535
536 /// Name the app's cascade layers, in priority order, lowest first.
537 ///
538 /// `makeover` is not named here; the renderer puts it first on its own.
539 #[must_use]
540 pub fn layered<I, S>(mut self, names: I) -> Self
541 where
542 I: IntoIterator<Item = S>,
543 S: Into<String>,
544 {
545 self.app_layers = names.into_iter().map(Into::into).collect();
546 self
547 }
548
549 /// Declare a header every request from this document carries, chaining.
550 ///
551 /// Repeated calls add headers rather than replacing them, and a name given
552 /// twice is emitted twice: this writes what it is told, and deduplicating
553 /// would be the renderer deciding which of two values the host meant.
554 #[must_use]
555 pub fn sending(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
556 self.sends.push((name.into(), value.into()));
557 self
558 }
559
560 /// Prepend markup to the head, chaining. Not escaped.
561 ///
562 /// Repeated calls append to each other, so the emitted order matches the
563 /// call order, the way [`with_head`](Self::with_head) already does.
564 #[must_use]
565 pub fn with_head_first(mut self, markup: impl Into<String>) -> Self {
566 self.head_first = Some(match self.head_first.take() {
567 Some(existing) => format!("{existing}{}", markup.into()),
568 None => markup.into(),
569 });
570 self
571 }
572
573 /// Append markup to the head, chaining. Not escaped.
574 #[must_use]
575 pub fn with_head(mut self, markup: impl Into<String>) -> Self {
576 self.head = Some(match self.head.take() {
577 Some(existing) => format!("{existing}{}", markup.into()),
578 None => markup.into(),
579 });
580 self
581 }
582
583 /// Markup to append to the end of the body, verbatim.
584 ///
585 /// See [`body_last`](Self::body_last). The mirror of
586 /// [`with_head`](Self::with_head), for the tail of a template a host has
587 /// not described yet.
588 #[must_use]
589 pub fn with_body_last(mut self, markup: impl Into<String>) -> Self {
590 self.body_last = Some(markup.into());
591 self
592 }
593
594 /// Markup to open the body with, verbatim.
595 ///
596 /// See [`body_first`](Self::body_first). The mirror of
597 /// [`with_head_first`](Self::with_head_first), for what has to precede
598 /// everything the screen draws.
599 #[must_use]
600 pub fn with_body_first(mut self, markup: impl Into<String>) -> Self {
601 self.body_first = Some(markup.into());
602 self
603 }
604
605 /// Run with no script of any kind, chaining.
606 ///
607 /// For a document served under a CSP that permits none: MNW's custom pages
608 /// are creator-authored markup on a cookieless host under `default-src
609 /// 'none'`, so a `<script>` in the head is a console error rather than a
610 /// feature, and the shell would be advertising an attack surface the
611 /// header exists to deny.
612 ///
613 /// Here rather than thirteen [`without_htmx`](Self::without_htmx)-shaped
614 /// calls at the call site, for the reason MNW's own shell builder gives
615 /// about `Shell::under`: thirteen chances to forget one, and the one
616 /// forgotten is not visible in the document that still renders. A script
617 /// added to this crate joins the list here rather than at every host that
618 /// wanted none.
619 #[must_use]
620 pub fn without_scripts(self) -> Self {
621 // `without_htmx` drops the four that exist because htmx does; the rest
622 // are independent of it and are named individually, so this reads as
623 // the list it is.
624 self.without_htmx()
625 .without_fill()
626 .without_reveal()
627 .without_copy()
628 .without_repeat()
629 .without_instant()
630 .without_menu()
631 .without_outline()
632 .without_hyperscript()
633 .without_clock()
634 }
635
636 /// Run without htmx at all, chaining.
637 ///
638 /// For a document that calls no route: the five MNW embeds carry no `hx-`
639 /// attribute between them, and an iframe on someone else's page should not
640 /// be downloading a transport to leave unused.
641 ///
642 /// This drops the selection script with it, since a document that calls no
643 /// route has no ticks to submit to one. See [`htmx_src`](Self::htmx_src)
644 /// for why dropping the transport is safe.
645 #[must_use]
646 pub fn without_htmx(mut self) -> Self {
647 self.htmx_src = None;
648 self.selection_src = None;
649 // Nothing to intercept. The download script exists because htmx turns
650 // a control into an XHR; without htmx every download is a navigation
651 // and the browser already does the whole job.
652 self.download_src = None;
653 // Nothing to listen to. The awaiting script reads the request
654 // lifecycle, and a document that makes no requests has none.
655 self.awaiting_src = None;
656 // The same, and the loss is louder: a plain form post submits the local
657 // wall-clock string, so a host dropping htmx and keeping a described
658 // datetime field converts on the route instead.
659 self.instant_src = None;
660 // Nothing to listen to, and nothing to fix: without htmx every
661 // response is a navigation, and a navigation has no focus to preserve.
662 self.focus_src = None;
663 self
664 }
665
666 /// Run without the download script, chaining.
667 ///
668 /// For a document whose files are reached by plain links rather than by
669 /// htmx controls. See [`download_src`](Self::download_src) for what is lost,
670 /// which is the whole of downloading from a control rather than a detail of
671 /// it.
672 #[must_use]
673 pub fn without_download(mut self) -> Self {
674 self.download_src = None;
675 self
676 }
677
678 /// Run without the fill script, chaining.
679 ///
680 /// For a host that serves no assets of this crate's, and for a document
681 /// with no act that names a destination field. See
682 /// [`fill_src`](Self::fill_src) for what is lost, which is the whole of
683 /// depositing a value rather than a detail of it.
684 #[must_use]
685 pub fn without_fill(mut self) -> Self {
686 self.fill_src = None;
687 self
688 }
689
690 /// Run without the reveal script, chaining.
691 ///
692 /// For a host that serves no assets of this crate's, and for a document
693 /// with no region that names a condition. See
694 /// [`reveal_src`](Self::reveal_src) for what is lost, which is the whole of
695 /// a conditional region rather than a detail of one.
696 #[must_use]
697 pub fn without_reveal(mut self) -> Self {
698 self.reveal_src = None;
699 self
700 }
701
702 /// Run without the clipboard script, chaining.
703 ///
704 /// For a host that serves no assets of this crate's, and for a document
705 /// with no act that copies. See [`copy_src`](Self::copy_src) for what is
706 /// lost, which is the whole of copying rather than a detail of it.
707 #[must_use]
708 pub fn without_copy(mut self) -> Self {
709 self.copy_src = None;
710 self
711 }
712
713 /// Run without the repeat script, chaining.
714 ///
715 /// For a host that serves no assets of this crate's, and for a document
716 /// with no question that repeats. See [`repeat_src`](Self::repeat_src) for
717 /// what is lost, which is the reader's half of a repeating question rather
718 /// than a detail of it.
719 #[must_use]
720 pub fn without_repeat(mut self) -> Self {
721 self.repeat_src = None;
722 self
723 }
724
725 /// Run without the awaiting script, chaining.
726 ///
727 /// For a host that serves no assets of this crate's, and for a document
728 /// with no act that says it waits. See
729 /// [`awaiting_src`](Self::awaiting_src) for what is lost, which is the mark
730 /// on a running wait rather than any behaviour: the control still locks
731 /// itself.
732 #[must_use]
733 pub fn without_awaiting(mut self) -> Self {
734 self.awaiting_src = None;
735 self
736 }
737
738 /// Run without the instant script, chaining.
739 ///
740 /// For a host that serves no assets of this crate's, and for a document
741 /// with no datetime field asking to be submitted as a moment. See
742 /// [`instant_src`](Self::instant_src) for what is lost, which unlike every
743 /// other script here is a wrong value rather than a missing affordance.
744 #[must_use]
745 pub fn without_instant(mut self) -> Self {
746 self.instant_src = None;
747 self
748 }
749
750 /// Run without the menu script, chaining.
751 ///
752 /// For a host that serves no assets of this crate's, and for a document
753 /// with no run that folds. See [`menu_src`](Self::menu_src) for what is
754 /// lost, which is the folding rather than any member: a menu run that
755 /// nothing measures wraps, and every member stays reachable.
756 #[must_use]
757 pub fn without_menu(mut self) -> Self {
758 self.menu_src = None;
759 self
760 }
761
762 /// Run without the outline script, chaining.
763 ///
764 /// For a host that serves no assets of this crate's, and for an app with no
765 /// list that folds. See [`outline_src`](Self::outline_src) for what is
766 /// lost, which is the folding rather than any row: the outline stays in the
767 /// shape the description gave it and every row stays reachable.
768 #[must_use]
769 pub fn without_outline(mut self) -> Self {
770 self.outline_src = None;
771 self
772 }
773
774 /// Run without the selection script, chaining.
775 ///
776 /// For a host that serves no assets of this crate's, and for the tests that
777 /// assert what a document without it looks like. See
778 /// [`selection_src`](Self::selection_src) for what is lost, which is a
779 /// count and a disabled state rather than any behaviour.
780 #[must_use]
781 pub fn without_selection(mut self) -> Self {
782 self.selection_src = None;
783 self
784 }
785
786 /// Run without _hyperscript, chaining.
787 ///
788 /// For a host that serves no assets of this crate's, and for a document
789 /// with no region that shows one child at a time, which is every document
790 /// the emitter wrote before it emitted a program. See
791 /// [`hyperscript_src`](Self::hyperscript_src) for what is lost.
792 #[must_use]
793 pub fn without_hyperscript(mut self) -> Self {
794 self.hyperscript_src = None;
795 self
796 }
797
798 /// Run without the clock script, chaining.
799 ///
800 /// For a host that serves no assets of this crate's, and for the tests that
801 /// assert what a document without it looks like. See
802 /// [`clock_src`](Self::clock_src) for what is lost, which is the ticking
803 /// rather than the readout.
804 #[must_use]
805 pub fn without_clock(mut self) -> Self {
806 self.clock_src = None;
807 self
808 }
809
810 /// A whole document with this body inside it. The body is markup, not
811 /// escaped.
812 ///
813 /// For a host with markup of its own — a template it has not described yet,
814 /// or one it never will — that still wants one head. The alternative was
815 /// making [`open`](Self::open) and [`close`](Self::close) public, and the
816 /// invariant those two carry is that they are used as a pair: a public
817 /// `open` is a half-written document waiting to happen. What goes in is the
818 /// inside of `<body>`; the `<body>` tag itself and its classes are the
819 /// shell's, the same as on the described path.
820 ///
821 /// A host whose own templating writes into the head cannot hand a body over
822 /// as a string, and takes [`parts`](Self::parts) instead.
823 #[must_use]
824 pub fn document(&self, title: &str, body: &str) -> String {
825 let mut out = String::with_capacity(body.len() + 1024);
826 // No screen, so nothing to say about how this is found. A host on this
827 // path assembles its own head metadata through `head`, which is what it
828 // was doing before a screen could carry any.
829 // No screen either, so nothing to say about the document itself: this
830 // path's host owns the facts `Document` carries, and says them in the
831 // markup it hands over.
832 self.open(title, None, None, &mut out);
833 out.push_str(body);
834 self.close(&mut out);
835 out
836 }
837
838 /// The shell's half of a document the host assembles itself.
839 ///
840 /// [`document`](Self::document) is the one to reach for. This is for a host
841 /// whose templating emits into the head from inside the page — a template
842 /// language with inheritance, where the per-page title and metadata are
843 /// blocks the parent renders in place and no caller ever holds as a string.
844 /// Askama is the case this was measured on, converting a server one screen
845 /// at a time; the parent template assembles:
846 ///
847 /// ```html
848 /// {{ parts.head }}<title>...</title>...</head>
849 /// <body{{ parts.body_attrs }} class="...">
850 /// ...
851 /// </body></html>
852 /// ```
853 ///
854 /// The chrome here is emitted with the default class naming and no bespoke
855 /// fills, because a [`Shell`] holds neither: both live on the [`Webview`]
856 /// that draws screens. A host that has said either takes
857 /// [`Webview::parts`](crate::Webview::parts) instead, which is this with the
858 /// renderer's own.
859 ///
860 /// The split is by what could silently diverge, not by what is convenient.
861 /// The host owes `<title>`, `</head>`, the `<body>` tag and the close, which
862 /// are structure a template cannot get subtly wrong. Everything with an
863 /// opinion in it — the layer statement's position, the headers every
864 /// request carries, the order of sheets against scripts — is here, and
865 /// stays the same markup the described path emits.
866 #[must_use]
867 pub fn parts(&self) -> Parts {
868 let mut head = String::with_capacity(1024);
869 self.open_head(None, None, None, &mut head);
870 let mut body_chrome = String::new();
871 crate::chrome::chrome_html(
872 &self.chrome,
873 &makeover_webview::Emit::default(),
874 &crate::node::Doc::bare(),
875 &mut body_chrome,
876 );
877 Parts {
878 head,
879 body_attrs: self.body_attrs(),
880 body_chrome,
881 }
882 }
883
884 /// Everything from `<!doctype>` to the open `<body>` tag.
885 pub(crate) fn open(
886 &self,
887 title: &str,
888 discovery: Option<&Discovery>,
889 document: Option<&Document>,
890 out: &mut String,
891 ) {
892 self.open_head(Some(title), discovery, document, out);
893 out.push_str("</head><body");
894 self.push_body_attrs(document, out);
895 out.push('>');
896 // First thing inside the body, which is the only position a skip link
897 // has. See `body_first`.
898 if let Some(first) = &self.body_first {
899 out.push_str(first);
900 }
901 }
902
903 /// The head, less its close. `None` leaves the `<title>` to the caller.
904 fn open_head(
905 &self,
906 title: Option<&str>,
907 discovery: Option<&Discovery>,
908 document: Option<&Document>,
909 out: &mut String,
910 ) {
911 out.push_str("<!doctype html><html lang=\"");
912 escape_into(&self.lang, out);
913 out.push('"');
914 // The screen's attributes on the root element, after `lang` so a
915 // description cannot displace the one fact the shell owns here.
916 // `1d4f288e`.
917 push_root_attrs(document, out);
918 out.push_str("><head><meta charset=\"utf-8\">");
919 out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
920 if let Some(title) = title {
921 out.push_str("<title>");
922 escape_into(title, out);
923 out.push_str("</title>");
924 }
925
926 if let Some(discovery) = discovery {
927 Self::discovery_head(title, discovery, out);
928 }
929
930 if let Some(first) = &self.head_first {
931 out.push_str(first);
932 }
933
934 // Before every stylesheet, or the statement is not a statement. See
935 // `app_layers`.
936 out.push_str("<style>@layer ");
937 out.push_str(makeover_webview::CSS_LAYER);
938 for name in &self.app_layers {
939 let name: String = name
940 .chars()
941 .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
942 .collect();
943 if name.is_empty() {
944 continue;
945 }
946 out.push_str(", ");
947 out.push_str(&name);
948 }
949 out.push_str(";</style>");
950
951 for href in &self.stylesheets {
952 out.push_str("<link rel=\"stylesheet\" href=\"");
953 escape_into(href, out);
954 out.push_str("\">");
955 }
956
957 // Deferred, so the parser is never blocked. Nothing is registered
958 // beside it any more: morphing is a swap style htmx 4 ships, and the
959 // extension that used to be loaded here was idiomorph under 2.x.
960 if let Some(src) = &self.htmx_src {
961 out.push_str("<script src=\"");
962 escape_into(src, out);
963 out.push_str("\" defer></script>");
964 }
965 // After htmx, because it listens for htmx's own settle event and a
966 // deferred script that loads first would bind before the event exists
967 // to be dispatched.
968 if let Some(src) = &self.selection_src {
969 out.push_str("<script src=\"");
970 escape_into(src, out);
971 out.push_str("\" defer></script>");
972 }
973
974 if let Some(src) = &self.clock_src {
975 out.push_str("<script src=\"");
976 escape_into(src, out);
977 out.push_str("\" defer></script>");
978 }
979
980 // After htmx, for the selection script's reason: it binds an htmx
981 // event, and a deferred script that ran first would bind before htmx
982 // existed to dispatch one.
983 if let Some(src) = &self.download_src {
984 out.push_str("<script src=\"");
985 escape_into(src, out);
986 out.push_str("\" defer></script>");
987 }
988
989 // Deferred like the rest, and independent of htmx: it listens on the
990 // document for a press on a control the node emitter marked, so markup
991 // arriving in a swap is covered without this file knowing htmx exists.
992 if let Some(src) = &self.fill_src {
993 out.push_str("<script src=\"");
994 escape_into(src, out);
995 out.push_str("\" defer></script>");
996 }
997
998 // Deferred like the rest, and independent of htmx: it reads three
999 // attributes on a region and the control they name, and re-reads them
1000 // after a swap, so markup arriving mid-page is covered.
1001 if let Some(src) = &self.reveal_src {
1002 out.push_str("<script src=\"");
1003 escape_into(src, out);
1004 out.push_str("\" defer></script>");
1005 }
1006
1007 // Deferred like the rest, and independent of htmx: it listens on the
1008 // document for a press on a control carrying the value, so a control
1009 // arriving in a swap is covered without this file knowing htmx exists.
1010 if let Some(src) = &self.copy_src {
1011 out.push_str("<script src=\"");
1012 escape_into(src, out);
1013 out.push_str("\" defer></script>");
1014 }
1015
1016 // Deferred like the rest, and independent of htmx: it listens on the
1017 // document for a press on the add or remove control of a group the node
1018 // emitter marked, and the blank slot it clones is markup already in the
1019 // page, so a group arriving in a swap is covered.
1020 if let Some(src) = &self.repeat_src {
1021 out.push_str("<script src=\"");
1022 escape_into(src, out);
1023 out.push_str("\" defer></script>");
1024 }
1025
1026 // Deferred like the rest, and delegated on the document like the rest,
1027 // so a control arriving in a swap is covered. Unlike the rest it is not
1028 // independent of htmx: what it listens to is the request lifecycle.
1029 if let Some(src) = &self.awaiting_src {
1030 out.push_str("<script src=\"");
1031 escape_into(src, out);
1032 out.push_str("\" defer></script>");
1033 }
1034
1035 // Deferred like the rest, delegated on the document like the awaiting
1036 // script, and not independent of htmx for the same reason: it reads the
1037 // request lifecycle. Nothing about it is per-region, so one listener
1038 // pair covers every out-of-band swap the renderer will ever emit.
1039 if let Some(src) = &self.focus_src {
1040 out.push_str("<script src=\"");
1041 escape_into(src, out);
1042 out.push_str("\" defer></script>");
1043 }
1044
1045 // Deferred like the rest, delegated on the body like the awaiting
1046 // script, and not independent of htmx for the same reason: it rewrites
1047 // the request htmx is about to make.
1048 if let Some(src) = &self.instant_src {
1049 out.push_str("<script src=\"");
1050 escape_into(src, out);
1051 out.push_str("\" defer></script>");
1052 }
1053
1054 // Deferred like the rest, and independent of htmx: what it reads is the
1055 // room a run has, which a document with no routes has just as much of.
1056 if let Some(src) = &self.menu_src {
1057 out.push_str("<script src=\"");
1058 escape_into(src, out);
1059 out.push_str("\" defer></script>");
1060 }
1061
1062 // Deferred like the rest, and independent of htmx: one delegated
1063 // listener on the document for a press on a branch's chevron, so a list
1064 // arriving in a swap is covered without this file knowing htmx exists.
1065 if let Some(src) = &self.outline_src {
1066 out.push_str("<script src=\"");
1067 escape_into(src, out);
1068 out.push_str("\" defer></script>");
1069 }
1070
1071 // Deferred like the rest, and independent of htmx: what it reads is the
1072 // programs the node emitter wrote into the markup, and a document that
1073 // calls no route can still carry a carousel. It initialises on
1074 // `DOMContentLoaded` and watches the document after that, so markup
1075 // arriving in a swap is processed without this file knowing htmx exists.
1076 if let Some(src) = &self.hyperscript_src {
1077 out.push_str("<script src=\"");
1078 escape_into(src, out);
1079 out.push_str("\" defer></script>");
1080 }
1081
1082 if let Some(head) = &self.head {
1083 out.push_str(head);
1084 }
1085
1086 // Last in the head, and outside the layer statement above: an unlayered
1087 // rule beats a layered one whatever the specificity, which is what makes
1088 // the screen's own sheet win over everything the shell shares without it
1089 // having to name a layer it cannot know. `48a6e9e5`.
1090 if let Some(css) = document.and_then(|document| document.style.as_deref()) {
1091 push_style(css, out);
1092 }
1093 }
1094
1095 /// What a link preview and a crawler read, from the screen itself.
1096 ///
1097 /// Every value is escaped. These are user-authored strings — an item
1098 /// description, a bio — going into attribute values, and this is the one
1099 /// place in the head where that is true. Same `escape` the node emitter
1100 /// uses; a second one here would be a second thing to get wrong.
1101 ///
1102 /// A `None` emits nothing at all. An empty `og:description` is worse than
1103 /// no tag: a preview showing a blank line reads as a broken page rather
1104 /// than as a page that said nothing.
1105 ///
1106 /// The Twitter tags mirror the OG ones, which is what the server's 28
1107 /// templates do by hand today.
1108 fn discovery_head(title: Option<&str>, discovery: &Discovery, out: &mut String) {
1109 let mut meta = |property: &str, content: &str| {
1110 out.push_str("<meta property=\"");
1111 out.push_str(property);
1112 out.push_str("\" content=\"");
1113 escape_into(content, out);
1114 out.push_str("\">");
1115 };
1116
1117 if let Some(title) = title {
1118 meta("og:title", title);
1119 }
1120 if let Some(summary) = &discovery.summary {
1121 meta("og:description", summary);
1122 }
1123 if let Some(image) = &discovery.image {
1124 meta("og:image", image);
1125 }
1126 meta("og:type", discovery.kind.as_str());
1127 if let Some(url) = &discovery.canonical {
1128 meta("og:url", url);
1129 }
1130
1131 // `name`, not `property`: the Twitter tags were never part of RDFa, and
1132 // a card written with `property` is a card the crawler skips.
1133 let mut named = |name: &str, content: &str| {
1134 out.push_str("<meta name=\"");
1135 out.push_str(name);
1136 out.push_str("\" content=\"");
1137 escape_into(content, out);
1138 out.push_str("\">");
1139 };
1140
1141 named(
1142 "twitter:card",
1143 if discovery.image.is_some() {
1144 "summary_large_image"
1145 } else {
1146 "summary"
1147 },
1148 );
1149 if let Some(title) = title {
1150 named("twitter:title", title);
1151 }
1152 if let Some(summary) = &discovery.summary {
1153 named("twitter:description", summary);
1154 }
1155 if let Some(image) = &discovery.image {
1156 named("twitter:image", image);
1157 }
1158
1159 // The plain one, which is what a search engine reads. The pair above is
1160 // what a share sheet reads, and `base.html` emitted all three off one
1161 // string; a described document emitted two and left the host to append
1162 // the third through `Shell::head`, which is an escape hatch spending
1163 // itself on something the description already carries.
1164 //
1165 // Under `indexable`, unlike the social pair, and the asymmetry is the
1166 // point: a screen that has told crawlers not to index it has nothing to
1167 // gain from describing itself to them, while a noindex page can still
1168 // be linked and still owes a share sheet a preview.
1169 //
1170 // Measured 2026-08-29 before choosing the gate: MNW's five embeds -- an
1171 // iframe widget is the case where a search description would be wrong,
1172 // since it would compete with the page framing it -- call `summarised`
1173 // nowhere, so they emit nothing either way. The gate is what keeps that
1174 // true when one of them starts.
1175 if discovery.indexable
1176 && let Some(summary) = &discovery.summary
1177 {
1178 named("description", summary);
1179 }
1180
1181 if !discovery.indexable {
1182 named("robots", "noindex");
1183 }
1184
1185 // The canonical link, beside `og:url` rather than instead of it: one is
1186 // what a crawler dedupes on and the other is what a share sheet shows,
1187 // and the six purchased-content screens need both to agree.
1188 if let Some(url) = &discovery.canonical {
1189 out.push_str("<link rel=\"canonical\" href=\"");
1190 escape_into(url, out);
1191 out.push_str("\">");
1192 }
1193
1194 // Autodiscovery. This is what a browser and every reader look for, and
1195 // it is the one tag here a host wrote by hand at each of its three
1196 // sites; the media type comes off `FeedKind` rather than out of a
1197 // template, so `application/rss` cannot be written once and skipped
1198 // everywhere.
1199 //
1200 // The title is escaped like everything else here: it is the app's own
1201 // string going into an attribute value, and the rule in this function
1202 // is that none of them is trusted for being ours.
1203 if let Some(feed) = &discovery.feed {
1204 out.push_str("<link rel=\"alternate\" type=\"");
1205 out.push_str(feed.kind.media_type());
1206 out.push_str("\" title=\"");
1207 escape_into(&feed.title, out);
1208 out.push_str("\" href=\"");
1209 escape_into(&feed.href, out);
1210 out.push_str("\">");
1211 }
1212 }
1213
1214 /// The attributes the shell owns on `<body>`, each one space-prefixed so
1215 /// they compose with whatever else the host puts on the tag.
1216 ///
1217 /// [`Parts`] wants these as a value and the emitted document does not, so
1218 /// the buffer-writing form is the one with the code in it. On the described
1219 /// path this was a `String` per render for two attributes, one of which is
1220 /// a constant.
1221 fn push_body_attrs(&self, document: Option<&Document>, out: &mut String) {
1222 if !self.sends.is_empty() {
1223 // `:inherited` and not a bare `hx-headers`: htmx 4 inherits nothing
1224 // unless the attribute says so, and the point of putting these on
1225 // the body is that a control cannot be emitted without them. The
1226 // value is JSON inside an attribute, escaped by the same function
1227 // `hx-vals` uses: a token is opaque bytes and this is the one place
1228 // a shell puts host-supplied text into markup.
1229 out.push_str(" hx-headers:inherited=\"");
1230 crate::node::json_pairs_attr(
1231 self.sends.iter().map(|(n, v)| (n.as_str(), v.as_str())),
1232 out,
1233 );
1234 out.push('"');
1235 }
1236 // One attribute holding both halves, because two `class` attributes on
1237 // one tag is markup a parser drops half of. The shell's part is what is
1238 // true of every page and the screen's is what is true of this one, so
1239 // the shell's goes first: a stylesheet reading them in order reads the
1240 // general before the specific.
1241 let screen_class = document.and_then(|document| document.body_class.as_deref());
1242 if self.body_class.is_some() || screen_class.is_some() {
1243 out.push_str(" class=\"");
1244 if let Some(class) = &self.body_class {
1245 escape_into(class, out);
1246 if screen_class.is_some() {
1247 out.push(' ');
1248 }
1249 }
1250 if let Some(class) = screen_class {
1251 escape_into(class, out);
1252 }
1253 out.push('"');
1254 }
1255 }
1256
1257 /// The same attributes as a value, for a host writing the `<body>` tag.
1258 ///
1259 /// No screen reaches here: [`parts`](Self::parts) is for a host assembling
1260 /// its own document, and that host writes the `<body>` tag itself.
1261 fn body_attrs(&self) -> String {
1262 let mut out = String::new();
1263 self.push_body_attrs(None, &mut out);
1264 out
1265 }
1266
1267 /// The close of what [`open`](Self::open) opened.
1268 pub(crate) fn close(&self, out: &mut String) {
1269 // Before the close and after everything else, which is the whole of
1270 // what `body_last` promises.
1271 if let Some(last) = &self.body_last {
1272 out.push_str(last);
1273 }
1274 out.push_str("</body></html>");
1275 }
1276 }
1277
1278 /// The shell's half of a host-assembled document. See [`Shell::parts`].
1279 #[derive(Debug, Clone, PartialEq, Eq)]
1280 pub struct Parts {
1281 /// `<!doctype>` through the head's contents, without `</head>`. The host
1282 /// appends its own head markup and closes the element.
1283 pub head: String,
1284 /// The attributes the shell owns on `<body>`, space-prefixed, for a host
1285 /// writing the tag itself: `<body{body_attrs} class="...">`.
1286 pub body_attrs: String,
1287 /// The chrome's markup, for the end of the body the host is writing.
1288 ///
1289 /// Empty when the app declares no chrome, which is why this does not make
1290 /// the chrome unconditional: a host assembling its own document appends
1291 /// this before closing the body, and appending an empty string is what a
1292 /// document with no chrome already does.
1293 pub body_chrome: String,
1294 }
1295