Skip to main content

max / quasi

The shell can hand a host half a document to assemble document() assumes the host holds its body as a string. A template language with inheritance does not: Askama renders the per-page title and head blocks in place, inside the parent, and no caller ever sees them. So a server converting one screen at a time could not take the shell at all, which is the one case document() was written for. Shell::parts() returns the head without its close and the attributes the shell owns on <body>, space-prefixed so they compose with the tag the host writes. The split is by what could silently diverge: the host owes the title, </head>, the body tag and the close, and everything with an opinion in it stays here. A test renders both ways from one shell and asserts the heads are the same markup.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 17:44 UTC
Signed with PGP, not checked
Commit: b94222d02fb9563f708dc97a8dc5d24658d270f0
Parent: 751f49a
3 files changed, +104 insertions, -6 deletions
@@ -39,7 +39,7 @@
39 39 #[cfg(test)]
40 40 mod tests;
41 41
42 - pub use crate::shell::Shell;
42 + pub use crate::shell::{Parts, Shell};
43 43 pub use makeover_webview::Emit;
44 44
45 45 use std::collections::HashMap;
@@ -183,6 +183,9 @@
183 183 /// `open` is a half-written document waiting to happen. What goes in is the
184 184 /// inside of `<body>`; the `<body>` tag itself, its classes and the morph
185 185 /// registration are the shell's, the same as on the described path.
186 + ///
187 + /// A host whose own templating writes into the head cannot hand a body over
188 + /// as a string, and takes [`parts`](Self::parts) instead.
186 189 #[must_use]
187 190 pub fn document(&self, title: &str, body: &str) -> String {
188 191 let mut out = String::with_capacity(body.len() + 1024);
@@ -192,15 +195,57 @@
192 195 out
193 196 }
194 197
198 + /// The shell's half of a document the host assembles itself.
199 + ///
200 + /// [`document`](Self::document) is the one to reach for. This is for a host
201 + /// whose templating emits into the head from inside the page — a template
202 + /// language with inheritance, where the per-page title and metadata are
203 + /// blocks the parent renders in place and no caller ever holds as a string.
204 + /// Askama is the case this was measured on, converting a server one screen
205 + /// at a time; the parent template assembles:
206 + ///
207 + /// ```html
208 + /// {{ parts.head }}<title>...</title>...</head>
209 + /// <body{{ parts.body_attrs }} class="...">
210 + /// ...
211 + /// </body></html>
212 + /// ```
213 + ///
214 + /// The split is by what could silently diverge, not by what is convenient.
215 + /// The host owes `<title>`, `</head>`, the `<body>` tag and the close, which
216 + /// are structure a template cannot get subtly wrong. Everything with an
217 + /// opinion in it — the layer statement's position, the htmx config, the
218 + /// morph registration, the order of sheets against scripts — is here, and
219 + /// stays the same markup the described path emits.
220 + #[must_use]
221 + pub fn parts(&self) -> Parts {
222 + let mut head = String::with_capacity(1024);
223 + self.open_head(None, &mut head);
224 + Parts {
225 + head,
226 + body_attrs: self.body_attrs(),
227 + }
228 + }
229 +
195 230 /// Everything from `<!doctype>` to the open `<body>` tag.
196 231 pub(crate) fn open(&self, title: &str, out: &mut String) {
232 + self.open_head(Some(title), out);
233 + out.push_str("</head><body");
234 + out.push_str(&self.body_attrs());
235 + out.push('>');
236 + }
237 +
238 + /// The head, less its close. `None` leaves the `<title>` to the caller.
239 + fn open_head(&self, title: Option<&str>, out: &mut String) {
197 240 out.push_str("<!doctype html><html lang=\"");
198 241 out.push_str(&escape(&self.lang));
199 242 out.push_str("\"><head><meta charset=\"utf-8\">");
200 243 out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
201 - out.push_str("<title>");
202 - out.push_str(&escape(title));
203 - out.push_str("</title>");
244 + if let Some(title) = title {
245 + out.push_str("<title>");
246 + out.push_str(&escape(title));
247 + out.push_str("</title>");
248 + }
204 249
205 250 // Decision 9's gap, closed in one place. A 4xx that does not swap is a
206 251 // banner the user never sees, so this tag is required rather than a
@@ -249,8 +294,12 @@
249 294 if let Some(head) = &self.head {
250 295 out.push_str(head);
251 296 }
297 + }
252 298
253 - out.push_str("</head><body");
299 + /// The attributes the shell owns on `<body>`, each one space-prefixed so
300 + /// they compose with whatever else the host puts on the tag.
301 + fn body_attrs(&self) -> String {
302 + let mut out = String::new();
254 303 if self.morphs() {
255 304 // Registered once on the body rather than per element: the
256 305 // extension is inherited, and an app that has to remember it per
@@ -262,7 +311,7 @@
262 311 out.push_str(&escape(class));
263 312 out.push('"');
264 313 }
265 - out.push('>');
314 + out
266 315 }
267 316
268 317 /// The close of what [`open`](Self::open) opened.
@@ -270,3 +319,14 @@
270 319 out.push_str("</body></html>");
271 320 }
272 321 }
322 +
323 + /// The shell's half of a host-assembled document. See [`Shell::parts`].
324 + #[derive(Debug, Clone, PartialEq, Eq)]
325 + pub struct Parts {
326 + /// `<!doctype>` through the head's contents, without `</head>`. The host
327 + /// appends its own head markup and closes the element.
328 + pub head: String,
329 + /// The attributes the shell owns on `<body>`, space-prefixed, for a host
330 + /// writing the tag itself: `<body{body_attrs} class="...">`.
331 + pub body_attrs: String,
332 + }
@@ -1120,6 +1120,44 @@
1120 1120 assert!(html.contains("<body hx-ext=\"morph\" class=\"console\">x</body>"));
1121 1121 }
1122 1122
1123 + #[test]
1124 + fn a_host_assembling_its_own_document_gets_the_same_head_as_a_screen() {
1125 + // The property the split exists for. A server converting one screen at a
1126 + // time renders both ways at once, and the two heads agreeing is the whole
1127 + // reason its templates take the shell at all.
1128 + let shell = Shell::default()
1129 + .layered(["base", "components"])
1130 + .styled("/style.css")
1131 + .with_head_first("<link rel=\"preload\" href=\"/f.woff2\" as=\"font\">");
1132 + let parts = shell.parts();
1133 + let screen = Webview::new()
1134 + .with_shell(shell)
1135 + .screen(&Screen::list_detail("Console", false));
1136 +
1137 + // Everything the shell owns is the same markup, in the same order: the
1138 + // title is the only thing that moves, because the host writes it.
1139 + let head = screen
1140 + .split("</head>")
1141 + .next()
1142 + .expect("the screen has a head")
1143 + .replace("<title>Console</title>", "");
1144 + assert_eq!(parts.head, head);
1145 + assert!(screen.contains(&format!("<body{}>", parts.body_attrs)));
1146 + }
1147 +
1148 + #[test]
1149 + fn the_body_attributes_compose_with_the_hosts_own() {
1150 + // Space-prefixed and never a bare `class`, so a template that writes its
1151 + // own class attribute after them does not produce two.
1152 + let parts = Shell::default().parts();
1153 + assert_eq!(parts.body_attrs, " hx-ext=\"morph\"");
1154 + assert!(!parts.head.contains("</head>"));
1155 + assert!(!parts.head.contains("<title>"));
1156 +
1157 + let quiet = Shell::default().without_morph().parts();
1158 + assert_eq!(quiet.body_attrs, "");
1159 + }
1160 +
1123 1161 #[test]
1124 1162 fn a_nested_region_renders_inside_its_parent() {
1125 1163 let screen =