Skip to main content

max / quasi

18.1 KB · 457 lines History Blame Raw
1 //! The document around a screen.
2 //!
3 //! Settled 2026-08-08 (Max): the renderer owns the document and the host
4 //! injects what it knows. The alternative was each adapter supplying its own
5 //! `<html>` and the renderer filling the inside, which is honest about axum and
6 //! Tauri resolving assets differently and costs two heads to keep in step —
7 //! the divergence the stack exists to end. A served page and a
8 //! custom-protocol page differ in where their assets live and in nothing else,
9 //! so where assets live is the parameter and the rest is emitted once.
10 //!
11 //! What the host owes is small enough to list: three asset URLs, a language,
12 //! and whatever else belongs in its own head. Everything with an opinion in it
13 //! — the htmx config from [`quasi_http::htmx`], the morph extension, the
14 //! viewport, where the body's classes come from — is here, because a host that
15 //! could get those wrong is a host that can diverge.
16
17 use makeover_webview::form::escape_into;
18 use quasi_router::{Chrome, Discovery};
19
20 /// The parts of a document only the host knows.
21 ///
22 /// A [`Default`] shell is a valid one: the asset paths are what a server
23 /// mounting its static directory at `/static` already serves, which is what
24 /// both MNW and multithreaded do today, and the Tauri adapter overrides them
25 /// with its own scheme.
26 #[derive(Debug, Clone, PartialEq, Eq)]
27 pub struct Shell {
28 /// The document language, for `<html lang>`.
29 pub lang: String,
30 /// Where htmx is served from.
31 pub htmx_src: String,
32 /// Where the idiomorph htmx extension is served from.
33 ///
34 /// `None` drops both the script and the `hx-ext` attribute, which is the
35 /// honest way to run without it: emitting `hx-swap="morph"` with no
36 /// extension loaded makes htmx fall back to `innerHTML` silently, and a
37 /// silent fallback to the destructive behaviour is the one outcome
38 /// decision 7 was avoiding.
39 pub morph_src: Option<String>,
40 /// Stylesheets, in link order.
41 pub stylesheets: Vec<String>,
42 /// The app's own cascade layer names, in priority order, lowest first.
43 ///
44 /// The renderer always emits the layer statement, with `makeover` first and
45 /// these after it, before any stylesheet link:
46 ///
47 /// ```css
48 /// @layer makeover, base, components, responsive;
49 /// ```
50 ///
51 /// It is emitted even when this is empty, because the point is not the
52 /// app's names but `makeover`'s position. A layer's place in the cascade is
53 /// fixed where its name is FIRST seen, so with no statement the generated
54 /// stylesheets establish `makeover` simply by loading first, and reordering
55 /// two link tags silently reorders the cascade.
56 ///
57 /// This is a field rather than something the host writes into
58 /// [`head_first`](Self::head_first) because it is exactly the class of
59 /// thing the module header says belongs here: a host that could get it
60 /// wrong is a host that can diverge, and getting it wrong is silent. The
61 /// CSS stays valid, the minifier stays happy, and buttons and badges look
62 /// subtly wrong. Only the app's own names are the host's to supply, since
63 /// the renderer cannot know them.
64 ///
65 /// Names are filtered to CSS identifier characters. A name is markup inside
66 /// a `<style>` element, where HTML escaping does not apply, so a stray `<`
67 /// would be a way out of the element rather than a character.
68 pub app_layers: Vec<String>,
69 /// Markup emitted near the top of the head verbatim, before the layer
70 /// statement and every stylesheet. Not escaped.
71 ///
72 /// For the things whose whole value is being early: a font preload, a
73 /// preconnect. [`head`](Self::head) is appended last and cannot serve them,
74 /// and a preload discovered after the stylesheets it races is a preload
75 /// that bought nothing.
76 pub head_first: Option<String>,
77 /// Markup appended to the head verbatim. Not escaped.
78 ///
79 /// The escape hatch for what no description will ever name: a favicon, a
80 /// preconnect, a theme bootstrap that has to run before first paint. It is
81 /// last in the head so it can override anything above it.
82 pub head: Option<String>,
83 /// Classes added to `<body>`, space-separated.
84 pub body_class: Option<String>,
85 /// What the app offers from every screen, rather than from one of them.
86 ///
87 /// Emitted once at the end of the body: one hidden control per binding,
88 /// plus the container an [`Outcome::Over`](quasi_router::Outcome::Over)
89 /// is drawn into. An app declaring none gets the document it got before
90 /// chrome existed, byte for byte.
91 ///
92 /// Here rather than on a screen because that is the whole claim: an
93 /// affordance reachable from everywhere is not a fact about any one place.
94 pub chrome: Chrome,
95 }
96
97 impl Default for Shell {
98 fn default() -> Self {
99 Self {
100 lang: "en".into(),
101 htmx_src: "/static/htmx.min.js".into(),
102 morph_src: Some("/static/idiomorph-ext.min.js".into()),
103 stylesheets: Vec::new(),
104 app_layers: Vec::new(),
105 head_first: None,
106 head: None,
107 body_class: None,
108 chrome: Chrome::new(),
109 }
110 }
111 }
112
113 impl Shell {
114 /// A shell serving its assets from under this prefix.
115 ///
116 /// The common case said once: `Shell::under("/assets")` rather than three
117 /// paths written out, each of which could disagree with the others.
118 #[must_use]
119 pub fn under(prefix: &str) -> Self {
120 let prefix = prefix.trim_end_matches('/');
121 Self {
122 htmx_src: format!("{prefix}/htmx.min.js"),
123 morph_src: Some(format!("{prefix}/idiomorph-ext.min.js")),
124 ..Self::default()
125 }
126 }
127
128 /// Declare what the app offers from every screen.
129 #[must_use]
130 pub fn with_chrome(mut self, chrome: Chrome) -> Self {
131 self.chrome = chrome;
132 self
133 }
134
135 /// Add a stylesheet, chaining.
136 #[must_use]
137 pub fn styled(mut self, href: impl Into<String>) -> Self {
138 self.stylesheets.push(href.into());
139 self
140 }
141
142 /// Name the app's cascade layers, in priority order, lowest first.
143 ///
144 /// `makeover` is not named here; the renderer puts it first on its own.
145 #[must_use]
146 pub fn layered<I, S>(mut self, names: I) -> Self
147 where
148 I: IntoIterator<Item = S>,
149 S: Into<String>,
150 {
151 self.app_layers = names.into_iter().map(Into::into).collect();
152 self
153 }
154
155 /// Prepend markup to the head, chaining. Not escaped.
156 ///
157 /// Repeated calls append to each other, so the emitted order matches the
158 /// call order, the way [`with_head`](Self::with_head) already does.
159 #[must_use]
160 pub fn with_head_first(mut self, markup: impl Into<String>) -> Self {
161 self.head_first = Some(match self.head_first.take() {
162 Some(existing) => format!("{existing}{}", markup.into()),
163 None => markup.into(),
164 });
165 self
166 }
167
168 /// Append markup to the head, chaining. Not escaped.
169 #[must_use]
170 pub fn with_head(mut self, markup: impl Into<String>) -> Self {
171 self.head = Some(match self.head.take() {
172 Some(existing) => format!("{existing}{}", markup.into()),
173 None => markup.into(),
174 });
175 self
176 }
177
178 /// Run without idiomorph, chaining.
179 #[must_use]
180 pub fn without_morph(mut self) -> Self {
181 self.morph_src = None;
182 self
183 }
184
185 /// Whether responses may ask for a morph swap.
186 ///
187 /// Read by the node emitter rather than assumed, so that turning the
188 /// extension off changes what is emitted instead of leaving an attribute
189 /// naming a swap nothing implements.
190 #[must_use]
191 pub fn morphs(&self) -> bool {
192 self.morph_src.is_some()
193 }
194
195 /// A whole document with this body inside it. The body is markup, not
196 /// escaped.
197 ///
198 /// For a host with markup of its own — a template it has not described yet,
199 /// or one it never will — that still wants one head. The alternative was
200 /// making [`open`](Self::open) and [`close`](Self::close) public, and the
201 /// invariant those two carry is that they are used as a pair: a public
202 /// `open` is a half-written document waiting to happen. What goes in is the
203 /// inside of `<body>`; the `<body>` tag itself, its classes and the morph
204 /// registration are the shell's, the same as on the described path.
205 ///
206 /// A host whose own templating writes into the head cannot hand a body over
207 /// as a string, and takes [`parts`](Self::parts) instead.
208 #[must_use]
209 pub fn document(&self, title: &str, body: &str) -> String {
210 let mut out = String::with_capacity(body.len() + 1024);
211 // No screen, so nothing to say about how this is found. A host on this
212 // path assembles its own head metadata through `head`, which is what it
213 // was doing before a screen could carry any.
214 self.open(title, None, &mut out);
215 out.push_str(body);
216 Self::close(&mut out);
217 out
218 }
219
220 /// The shell's half of a document the host assembles itself.
221 ///
222 /// [`document`](Self::document) is the one to reach for. This is for a host
223 /// whose templating emits into the head from inside the page — a template
224 /// language with inheritance, where the per-page title and metadata are
225 /// blocks the parent renders in place and no caller ever holds as a string.
226 /// Askama is the case this was measured on, converting a server one screen
227 /// at a time; the parent template assembles:
228 ///
229 /// ```html
230 /// {{ parts.head }}<title>...</title>...</head>
231 /// <body{{ parts.body_attrs }} class="...">
232 /// ...
233 /// </body></html>
234 /// ```
235 ///
236 /// The split is by what could silently diverge, not by what is convenient.
237 /// The host owes `<title>`, `</head>`, the `<body>` tag and the close, which
238 /// are structure a template cannot get subtly wrong. Everything with an
239 /// opinion in it — the layer statement's position, the htmx config, the
240 /// morph registration, the order of sheets against scripts — is here, and
241 /// stays the same markup the described path emits.
242 #[must_use]
243 pub fn parts(&self) -> Parts {
244 let mut head = String::with_capacity(1024);
245 self.open_head(None, None, &mut head);
246 let mut body_chrome = String::new();
247 crate::chrome::chrome_html(&self.chrome, self.morphs(), &mut body_chrome);
248 Parts {
249 head,
250 body_attrs: self.body_attrs(),
251 body_chrome,
252 }
253 }
254
255 /// Everything from `<!doctype>` to the open `<body>` tag.
256 pub(crate) fn open(&self, title: &str, discovery: Option<&Discovery>, out: &mut String) {
257 self.open_head(Some(title), discovery, out);
258 out.push_str("</head><body");
259 self.push_body_attrs(out);
260 out.push('>');
261 }
262
263 /// The head, less its close. `None` leaves the `<title>` to the caller.
264 fn open_head(&self, title: Option<&str>, discovery: Option<&Discovery>, out: &mut String) {
265 out.push_str("<!doctype html><html lang=\"");
266 escape_into(&self.lang, out);
267 out.push_str("\"><head><meta charset=\"utf-8\">");
268 out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
269 if let Some(title) = title {
270 out.push_str("<title>");
271 escape_into(title, out);
272 out.push_str("</title>");
273 }
274
275 if let Some(discovery) = discovery {
276 Self::discovery_head(title, discovery, out);
277 }
278
279 // Decision 9's gap, closed in one place. A 4xx that does not swap is a
280 // banner the user never sees, so this tag is required rather than a
281 // refinement, and it is a tag rather than a script so it survives a
282 // `script-src` with no `unsafe-inline`.
283 out.push_str(quasi_http::htmx::CONFIG_META);
284
285 if let Some(first) = &self.head_first {
286 out.push_str(first);
287 }
288
289 // Before every stylesheet, or the statement is not a statement. See
290 // `app_layers`.
291 out.push_str("<style>@layer ");
292 out.push_str(makeover_webview::CSS_LAYER);
293 for name in &self.app_layers {
294 let name: String = name
295 .chars()
296 .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
297 .collect();
298 if name.is_empty() {
299 continue;
300 }
301 out.push_str(", ");
302 out.push_str(&name);
303 }
304 out.push_str(";</style>");
305
306 for href in &self.stylesheets {
307 out.push_str("<link rel=\"stylesheet\" href=\"");
308 escape_into(href, out);
309 out.push_str("\">");
310 }
311
312 // Deferred, so the parser is never blocked and the extension is
313 // registered before htmx processes the body either way.
314 out.push_str("<script src=\"");
315 escape_into(&self.htmx_src, out);
316 out.push_str("\" defer></script>");
317 if let Some(src) = &self.morph_src {
318 out.push_str("<script src=\"");
319 escape_into(src, out);
320 out.push_str("\" defer></script>");
321 }
322
323 if let Some(head) = &self.head {
324 out.push_str(head);
325 }
326 }
327
328 /// What a link preview and a crawler read, from the screen itself.
329 ///
330 /// Every value is escaped. These are user-authored strings — an item
331 /// description, a bio — going into attribute values, and this is the one
332 /// place in the head where that is true. Same `escape` the node emitter
333 /// uses; a second one here would be a second thing to get wrong.
334 ///
335 /// A `None` emits nothing at all. An empty `og:description` is worse than
336 /// no tag: a preview showing a blank line reads as a broken page rather
337 /// than as a page that said nothing.
338 ///
339 /// The Twitter tags mirror the OG ones, which is what the server's 28
340 /// templates do by hand today.
341 fn discovery_head(title: Option<&str>, discovery: &Discovery, out: &mut String) {
342 let mut meta = |property: &str, content: &str| {
343 out.push_str("<meta property=\"");
344 out.push_str(property);
345 out.push_str("\" content=\"");
346 escape_into(content, out);
347 out.push_str("\">");
348 };
349
350 if let Some(title) = title {
351 meta("og:title", title);
352 }
353 if let Some(summary) = &discovery.summary {
354 meta("og:description", summary);
355 }
356 if let Some(image) = &discovery.image {
357 meta("og:image", image);
358 }
359 meta("og:type", discovery.kind.as_str());
360 if let Some(url) = &discovery.canonical {
361 meta("og:url", url);
362 }
363
364 // `name`, not `property`: the Twitter tags were never part of RDFa, and
365 // a card written with `property` is a card the crawler skips.
366 let mut named = |name: &str, content: &str| {
367 out.push_str("<meta name=\"");
368 out.push_str(name);
369 out.push_str("\" content=\"");
370 escape_into(content, out);
371 out.push_str("\">");
372 };
373
374 named(
375 "twitter:card",
376 if discovery.image.is_some() {
377 "summary_large_image"
378 } else {
379 "summary"
380 },
381 );
382 if let Some(title) = title {
383 named("twitter:title", title);
384 }
385 if let Some(summary) = &discovery.summary {
386 named("twitter:description", summary);
387 }
388 if let Some(image) = &discovery.image {
389 named("twitter:image", image);
390 }
391
392 if !discovery.indexable {
393 named("robots", "noindex");
394 }
395
396 // The canonical link, beside `og:url` rather than instead of it: one is
397 // what a crawler dedupes on and the other is what a share sheet shows,
398 // and the six purchased-content screens need both to agree.
399 if let Some(url) = &discovery.canonical {
400 out.push_str("<link rel=\"canonical\" href=\"");
401 escape_into(url, out);
402 out.push_str("\">");
403 }
404 }
405
406 /// The attributes the shell owns on `<body>`, each one space-prefixed so
407 /// they compose with whatever else the host puts on the tag.
408 ///
409 /// [`Parts`] wants these as a value and the emitted document does not, so
410 /// the buffer-writing form is the one with the code in it. On the described
411 /// path this was a `String` per render for two attributes, one of which is
412 /// a constant.
413 fn push_body_attrs(&self, out: &mut String) {
414 if self.morphs() {
415 // Registered once on the body rather than per element: the
416 // extension is inherited, and an app that has to remember it per
417 // control is an app that will forget it.
418 out.push_str(" hx-ext=\"morph\"");
419 }
420 if let Some(class) = &self.body_class {
421 out.push_str(" class=\"");
422 escape_into(class, out);
423 out.push('"');
424 }
425 }
426
427 /// The same attributes as a value, for a host writing the `<body>` tag.
428 fn body_attrs(&self) -> String {
429 let mut out = String::new();
430 self.push_body_attrs(&mut out);
431 out
432 }
433
434 /// The close of what [`open`](Self::open) opened.
435 pub(crate) fn close(out: &mut String) {
436 out.push_str("</body></html>");
437 }
438 }
439
440 /// The shell's half of a host-assembled document. See [`Shell::parts`].
441 #[derive(Debug, Clone, PartialEq, Eq)]
442 pub struct Parts {
443 /// `<!doctype>` through the head's contents, without `</head>`. The host
444 /// appends its own head markup and closes the element.
445 pub head: String,
446 /// The attributes the shell owns on `<body>`, space-prefixed, for a host
447 /// writing the tag itself: `<body{body_attrs} class="...">`.
448 pub body_attrs: String,
449 /// The chrome's markup, for the end of the body the host is writing.
450 ///
451 /// Empty when the app declares no chrome, which is why this does not make
452 /// the chrome unconditional: a host assembling its own document appends
453 /// this before closing the body, and appending an empty string is what a
454 /// document with no chrome already does.
455 pub body_chrome: String,
456 }
457