Skip to main content

max / quasi

A screen says how it is found, shared and indexed A description named no head metadata and no indexability, so converting a public commercial site meant dropping every social preview and every noindex into the host's lap, silently. The parity harness catches a regression against what Askama rendered; it cannot say the description should have been able to name the thing. Measured first. Every og:* value across the server's 37 templates interpolates one of four things from the entity the screen is about: a title, a summary sentence, an image URL, or the screen's own address. Not one needed knowledge only a handler has, which is what makes this the screen's to say. Discovery carries indexable, summary, image, kind and canonical, and lives in quasi-router rather than makeover-layout: a terminal ignores every field, the same way it ignores Slot::id. It is an address-and-identity fact, which is the line that put Action here too. Default is written out rather than derived. bool::default() is false, so a derived impl would deindex every screen that never mentioned the subject, and the failure would surface as traffic rather than as a test. Asserted. SocialKind is the six the server emits and no more. A seventh named before a screen has one is how a description becomes a framework, which is the argument Arrangement is held to two by. quasi-webview emits the OG and Twitter tags, the robots meta and the canonical link, all escaped through the node emitter's own path: a summary is an item description or a bio, user-authored, landing in an attribute value. A None emits nothing at all, because an empty og:description previews as a broken page rather than as a quiet one. Not in scope: ld+json. The 7 templates emitting it stay host-side through Shell::head. Its schema is per-type and naming schema.org in the description layer would import someone else's vocabulary into ours.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-11 18:59 UTC
Signed with PGP, not checked
Commit: 4728769fbf689def5233eb14f9100ea799bb9107
Parent: 4a50ba4
5 files changed, +389 insertions, -15 deletions
@@ -110,8 +110,8 @@
110 110 pub use crate::response::{Address, Message, Outcome, Response};
111 111 pub use crate::router::{Handler, Router};
112 112 pub use crate::screen::{
113 - Act, Action, Cell, Cells, Choice, Column, Destination, Field, Figure, Meter, Node, Prose,
114 - RegionKind, Rest, Row, Screen, Slot, Tag,
113 + Act, Action, Cell, Cells, Choice, Column, Destination, Discovery, Field, Figure, Meter, Node,
114 + Prose, RegionKind, Rest, Row, Screen, Slot, SocialKind, Tag,
115 115 };
116 116
117 117 #[cfg(test)]
@@ -304,4 +304,41 @@
304 304 .described()
305 305 );
306 306 }
307 +
308 + #[test]
309 + fn a_screen_nobody_described_is_indexable() {
310 + // `bool::default()` is false, so a derived Default here would deindex
311 + // every screen that never mentioned the subject. The impl is written
312 + // out for exactly this, and this is the assertion that keeps it.
313 + assert!(Discovery::default().indexable);
314 + assert!(Screen::sidebar_content("Home").discovery.indexable);
315 + assert!(
316 + !Screen::sidebar_content("Home")
317 + .indexed(false)
318 + .discovery
319 + .indexable
320 + );
321 + }
322 +
323 + #[test]
324 + fn every_social_kind_has_a_distinct_spelling() {
325 + // A kind added upstream without a spelling fails here rather than
326 + // reaching a crawler as an og:type nothing recognises.
327 + let kinds = [
328 + SocialKind::Website,
329 + SocialKind::Article,
330 + SocialKind::Profile,
331 + SocialKind::Product,
332 + SocialKind::Video,
333 + SocialKind::Song,
334 + ];
335 + let mut seen = Vec::new();
336 + for kind in kinds {
337 + let spelling = kind.as_str();
338 + assert!(!spelling.is_empty(), "{kind:?} spells nothing");
339 + assert!(!seen.contains(&spelling), "{spelling} is spelled twice");
340 + seen.push(spelling);
341 + }
342 + assert_eq!(SocialKind::default(), SocialKind::Website);
343 + }
307 344 }
@@ -1955,6 +1955,108 @@
1955 1955 /// than to a place in it: which region a toast stacks in is the renderer's
1956 1956 /// question, and a handler answering it would be describing a webview.
1957 1957 pub notices: Vec<Node>,
1958 + /// How this screen is found, shared and indexed.
1959 + ///
1960 + /// Not an `Option`. The default is meaningful — a screen nobody said
1961 + /// anything about is an indexable website — and an `Option` would make
1962 + /// "nobody said" and "indexable" two spellings of one thing.
1963 + pub discovery: Discovery,
1964 + }
1965 +
1966 + /// How a screen is found, shared and indexed.
1967 + ///
1968 + /// Not presentation, which is why it is here and not in `makeover-layout`: a
1969 + /// terminal ignores every field, the same way it ignores [`Slot::id`]. It is an
1970 + /// address-and-identity fact, and that is the line that put [`Action`] in this
1971 + /// crate rather than in the vocabulary.
1972 + ///
1973 + /// Measured before it was added. Every `og:*` value in the MNW server's 37
1974 + /// templates is one of four things interpolated from the entity the screen is
1975 + /// about: a title, a summary sentence, an image URL, or the screen's own
1976 + /// address. None of them needed knowledge only a handler has, which is what
1977 + /// made this the screen's to say rather than the host's.
1978 + #[derive(Debug, Clone, PartialEq, Eq)]
1979 + pub struct Discovery {
1980 + /// Whether a crawler should index this screen.
1981 + ///
1982 + /// Defaults to indexable, because most screens are and a default that hides
1983 + /// pages is a default that hides the bug. The six screens saying otherwise
1984 + /// are purchased-content pages, and this field is why that is a fact the
1985 + /// type carries rather than a line in a template that a conversion can drop
1986 + /// in silence.
1987 + pub indexable: bool,
1988 + /// The sentence a link preview shows. [`Screen::title`] is the title.
1989 + pub summary: Option<String>,
1990 + /// The image a link preview shows, as an absolute URL.
1991 + pub image: Option<String>,
1992 + /// What kind of thing this screen is about.
1993 + pub kind: SocialKind,
1994 + /// The canonical address, when the screen answers at more than one.
1995 + pub canonical: Option<String>,
1996 + }
1997 +
1998 + impl Default for Discovery {
1999 + /// Indexable, and nothing else claimed.
2000 + ///
2001 + /// Written out rather than derived, and the reason is the one field that
2002 + /// matters: `bool::default()` is `false`, so a derived impl would deindex
2003 + /// every screen that never mentioned the subject, silently, and the failure
2004 + /// would show up as traffic rather than as a test.
2005 + fn default() -> Self {
2006 + Self {
2007 + indexable: true,
2008 + summary: None,
2009 + image: None,
2010 + kind: SocialKind::Website,
2011 + canonical: None,
2012 + }
2013 + }
2014 + }
2015 +
2016 + /// What kind of thing a screen is about.
2017 + ///
2018 + /// The six the server actually emits, and no more. Naming a seventh before a
2019 + /// screen has one is how a description becomes a framework, which is the
2020 + /// argument [`Arrangement`](layout::Arrangement) is held to two screens by.
2021 + ///
2022 + /// `#[non_exhaustive]`, because a seventh arriving should not be a lockstep
2023 + /// event across every renderer that spells one. The match below stays
2024 + /// exhaustive: within this crate the attribute does not apply, and a wildcard
2025 + /// here would only hide a member added without a spelling.
2026 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2027 + #[non_exhaustive]
2028 + pub enum SocialKind {
2029 + /// A page. The default, and four of the server's screens.
2030 + #[default]
2031 + Website,
2032 + /// Something written, with an author and a date.
2033 + Article,
2034 + /// A person or an account.
2035 + Profile,
2036 + /// Something for sale.
2037 + Product,
2038 + /// A video.
2039 + Video,
2040 + /// A piece of music.
2041 + Song,
2042 + }
2043 +
2044 + impl SocialKind {
2045 + /// What this is spelled as in `og:type`.
2046 + ///
2047 + /// Named here rather than agreed between each renderer and each host, which
2048 + /// is how one screen ends up `video.other` and the next `video`.
2049 + #[must_use]
2050 + pub const fn as_str(self) -> &'static str {
2051 + match self {
2052 + Self::Website => "website",
2053 + Self::Article => "article",
2054 + Self::Profile => "profile",
2055 + Self::Product => "product",
2056 + Self::Video => "video.other",
2057 + Self::Song => "music.song",
2058 + }
2059 + }
1958 2060 }
1959 2061
1960 2062 impl Screen {
@@ -1965,9 +2067,45 @@
1965 2067 arrangement,
1966 2068 slots: Vec::new(),
1967 2069 notices: Vec::new(),
2070 + discovery: Discovery::default(),
1968 2071 }
1969 2072 }
1970 2073
2074 + /// Whether a crawler should index this screen, chaining.
2075 + #[must_use]
2076 + pub fn indexed(mut self, indexable: bool) -> Self {
2077 + self.discovery.indexable = indexable;
2078 + self
2079 + }
2080 +
2081 + /// The sentence a link preview shows, chaining.
2082 + #[must_use]
2083 + pub fn summarised(mut self, text: impl Into<String>) -> Self {
2084 + self.discovery.summary = Some(text.into());
2085 + self
2086 + }
2087 +
2088 + /// The image a link preview shows, chaining. An absolute URL.
2089 + #[must_use]
2090 + pub fn illustrated(mut self, url: impl Into<String>) -> Self {
2091 + self.discovery.image = Some(url.into());
2092 + self
2093 + }
2094 +
2095 + /// What kind of thing this screen is about, chaining.
2096 + #[must_use]
2097 + pub fn about(mut self, kind: SocialKind) -> Self {
2098 + self.discovery.kind = kind;
2099 + self
2100 + }
2101 +
2102 + /// The address this screen should be known by, chaining.
2103 + #[must_use]
2104 + pub fn canonical_at(mut self, url: impl Into<String>) -> Self {
2105 + self.discovery.canonical = Some(url.into());
2106 + self
2107 + }
2108 +
1971 2109 /// A list that chooses what the detail beside it shows.
1972 2110 pub fn list_detail(title: impl Into<String>, tabbed: bool) -> Self {
1973 2111 Self::new(title, layout::Arrangement::ListDetail { tabbed })
@@ -145,7 +145,8 @@
145 145 impl Render for Webview {
146 146 fn screen(&self, screen: &Screen) -> String {
147 147 let mut out = String::with_capacity(1024);
148 - self.shell.open(&screen.title, &mut out);
148 + self.shell
149 + .open(&screen.title, Some(&screen.discovery), &mut out);
149 150
150 151 out.push_str("<main class=\"");
151 152 out.push_str(&node::class(
@@ -15,6 +15,7 @@
15 15 //! could get those wrong is a host that can diverge.
16 16
17 17 use makeover_webview::form::escape;
18 + use quasi_router::Discovery;
18 19
19 20 /// The parts of a document only the host knows.
20 21 ///
@@ -189,7 +190,10 @@
189 190 #[must_use]
190 191 pub fn document(&self, title: &str, body: &str) -> String {
191 192 let mut out = String::with_capacity(body.len() + 1024);
192 - self.open(title, &mut out);
193 + // No screen, so nothing to say about how this is found. A host on this
194 + // path assembles its own head metadata through `head`, which is what it
195 + // was doing before a screen could carry any.
196 + self.open(title, None, &mut out);
193 197 out.push_str(body);
194 198 Self::close(&mut out);
195 199 out
@@ -220,7 +224,7 @@
220 224 #[must_use]
221 225 pub fn parts(&self) -> Parts {
222 226 let mut head = String::with_capacity(1024);
223 - self.open_head(None, &mut head);
227 + self.open_head(None, None, &mut head);
224 228 Parts {
225 229 head,
226 230 body_attrs: self.body_attrs(),
@@ -228,15 +232,15 @@
228 232 }
229 233
230 234 /// Everything from `<!doctype>` to the open `<body>` tag.
231 - pub(crate) fn open(&self, title: &str, out: &mut String) {
232 - self.open_head(Some(title), out);
235 + pub(crate) fn open(&self, title: &str, discovery: Option<&Discovery>, out: &mut String) {
236 + self.open_head(Some(title), discovery, out);
233 237 out.push_str("</head><body");
234 238 out.push_str(&self.body_attrs());
235 239 out.push('>');
236 240 }
237 241
238 242 /// The head, less its close. `None` leaves the `<title>` to the caller.
239 - fn open_head(&self, title: Option<&str>, out: &mut String) {
243 + fn open_head(&self, title: Option<&str>, discovery: Option<&Discovery>, out: &mut String) {
240 244 out.push_str("<!doctype html><html lang=\"");
241 245 out.push_str(&escape(&self.lang));
242 246 out.push_str("\"><head><meta charset=\"utf-8\">");
@@ -247,6 +251,10 @@
247 251 out.push_str("</title>");
248 252 }
249 253
254 + if let Some(discovery) = discovery {
255 + Self::discovery_head(title, discovery, out);
256 + }
257 +
250 258 // Decision 9's gap, closed in one place. A 4xx that does not swap is a
251 259 // banner the user never sees, so this tag is required rather than a
252 260 // refinement, and it is a tag rather than a script so it survives a
@@ -296,6 +304,84 @@
296 304 }
297 305 }
298 306
307 + /// What a link preview and a crawler read, from the screen itself.
308 + ///
309 + /// Every value is escaped. These are user-authored strings — an item
310 + /// description, a bio — going into attribute values, and this is the one
311 + /// place in the head where that is true. Same `escape` the node emitter
312 + /// uses; a second one here would be a second thing to get wrong.
313 + ///
314 + /// A `None` emits nothing at all. An empty `og:description` is worse than
315 + /// no tag: a preview showing a blank line reads as a broken page rather
316 + /// than as a page that said nothing.
317 + ///
318 + /// The Twitter tags mirror the OG ones, which is what the server's 28
319 + /// templates do by hand today.
320 + fn discovery_head(title: Option<&str>, discovery: &Discovery, out: &mut String) {
321 + let mut meta = |property: &str, content: &str| {
322 + out.push_str("<meta property=\"");
323 + out.push_str(property);
324 + out.push_str("\" content=\"");
325 + out.push_str(&escape(content));
326 + out.push_str("\">");
327 + };
328 +
329 + if let Some(title) = title {
330 + meta("og:title", title);
331 + }
332 + if let Some(summary) = &discovery.summary {
333 + meta("og:description", summary);
334 + }
335 + if let Some(image) = &discovery.image {
336 + meta("og:image", image);
337 + }
338 + meta("og:type", discovery.kind.as_str());
339 + if let Some(url) = &discovery.canonical {
340 + meta("og:url", url);
341 + }
342 +
343 + // `name`, not `property`: the Twitter tags were never part of RDFa, and
344 + // a card written with `property` is a card the crawler skips.
345 + let mut named = |name: &str, content: &str| {
346 + out.push_str("<meta name=\"");
347 + out.push_str(name);
348 + out.push_str("\" content=\"");
349 + out.push_str(&escape(content));
350 + out.push_str("\">");
351 + };
352 +
353 + named(
354 + "twitter:card",
355 + if discovery.image.is_some() {
356 + "summary_large_image"
357 + } else {
358 + "summary"
359 + },
360 + );
361 + if let Some(title) = title {
362 + named("twitter:title", title);
363 + }
364 + if let Some(summary) = &discovery.summary {
365 + named("twitter:description", summary);
366 + }
367 + if let Some(image) = &discovery.image {
368 + named("twitter:image", image);
369 + }
370 +
371 + if !discovery.indexable {
372 + named("robots", "noindex");
373 + }
374 +
375 + // The canonical link, beside `og:url` rather than instead of it: one is
376 + // what a crawler dedupes on and the other is what a share sheet shows,
377 + // and the six purchased-content screens need both to agree.
378 + if let Some(url) = &discovery.canonical {
379 + out.push_str("<link rel=\"canonical\" href=\"");
380 + out.push_str(&escape(url));
381 + out.push_str("\">");
382 + }
383 + }
384 +
299 385 /// The attributes the shell owns on `<body>`, each one space-prefixed so
300 386 /// they compose with whatever else the host puts on the tag.
301 387 fn body_attrs(&self) -> String {
@@ -16,6 +16,27 @@
16 16
17 17 use crate::{Emit, Shell, Webview};
18 18
19 + /// A head with the screen's discovery tags removed.
20 + ///
21 + /// They are the one part of the head that comes off the `Screen` rather than
22 + /// off the `Shell`, so a comparison against the shell's own parts has to drop
23 + /// them or it is comparing two different questions.
24 + fn strip_discovery(head: &str) -> String {
25 + let mut out = String::with_capacity(head.len());
26 + let mut rest = head;
27 + while let Some(start) = rest.find("<meta property=\"og:").or_else(|| {
28 + rest.find("<meta name=\"twitter:")
29 + .or_else(|| rest.find("<meta name=\"robots\""))
30 + .or_else(|| rest.find("<link rel=\"canonical\""))
31 + }) {
32 + out.push_str(&rest[..start]);
33 + let end = rest[start..].find('>').expect("a tag closes") + start + 1;
34 + rest = &rest[end..];
35 + }
36 + out.push_str(rest);
37 + out
38 + }
39 +
19 40 fn render(screen: &Screen) -> String {
20 41 Webview::new().screen(screen)
21 42 }
@@ -1586,13 +1607,18 @@
1586 1607 .with_shell(shell)
1587 1608 .screen(&Screen::list_detail("Console", false));
1588 1609
1589 - // Everything the shell owns is the same markup, in the same order: the
1590 - // title is the only thing that moves, because the host writes it.
1610 + // Everything the SHELL owns is the same markup, in the same order. Two
1611 + // things are not the shell's and are stripped before comparing: the title,
1612 + // because the host writes it, and the screen's discovery tags, because a
1613 + // host on this path has no `Screen` to read them from and writes its own
1614 + // head metadata -- which is exactly what the server does today in its
1615 + // `block head`.
1591 1616 let head = screen
1592 1617 .split("</head>")
1593 1618 .next()
1594 1619 .expect("the screen has a head")
1595 1620 .replace("<title>Console</title>", "");
1621 + let head = strip_discovery(&head);
1596 1622 assert_eq!(parts.head, head);
1597 1623 assert!(screen.contains(&format!("<body{}>", parts.body_attrs)));
1598 1624 }
@@ -1653,11 +1679,13 @@
1653 1679
1654 1680 let html = render(&screen);
1655 1681 assert!(!html.contains("<script>"));
1656 - // Ten sinks: the title, the notice, the heading, the prose, the act's
1657 - // label, the row's four parts, and the chip's label. Counted rather than
1658 - // merely checked for absence, so a variant that silently stops rendering
1659 - // its text fails here too.
1660 - assert_eq!(html.matches("&lt;script&gt;").count(), 10);
1682 + // Twelve sinks: the title, the notice, the heading, the prose, the act's
1683 + // label, the row's four parts, the chip's label, and the two the title is
1684 + // repeated into for a link preview -- `og:title` and `twitter:title`, which
1685 + // are attribute values and escape through the same path. Counted rather
1686 + // than merely checked for absence, so a variant that silently stops
1687 + // rendering its text fails here too.
1688 + assert_eq!(html.matches("&lt;script&gt;").count(), 12);
1661 1689 }
1662 1690
1663 1691 #[test]
@@ -1857,3 +1885,87 @@
1857 1885 "{with_tuple}"
1858 1886 );
1859 1887 }
1888 +
1889 + #[test]
1890 + fn a_screen_that_says_nothing_is_still_a_findable_page() {
1891 + // The default has to be right, because most screens will never mention the
1892 + // subject. Indexable, a title a preview can show, and a type.
1893 + let html = render(&Screen::sidebar_content("Projects"));
1894 +
1895 + assert!(
1896 + html.contains("<meta property=\"og:title\" content=\"Projects\">"),
1897 + "{html}"
1898 + );
1899 + assert!(
1900 + html.contains("<meta property=\"og:type\" content=\"website\">"),
1901 + "{html}"
1902 + );
1903 + assert!(!html.contains("robots"), "{html}");
1904 +
1905 + // A None emits nothing rather than an empty tag. A preview showing a blank
1906 + // line reads as a broken page.
1907 + assert!(!html.contains("og:description"), "{html}");
1908 + assert!(!html.contains("og:image"), "{html}");
1909 + assert!(!html.contains("canonical"), "{html}");
1910 + }
1911 +
1912 + #[test]
1913 + fn a_purchased_content_screen_can_say_it_is_not_for_crawlers() {
1914 + // Six of the server's screens. This is the assertion the whole decision
1915 + // exists to buy: a conversion that drops the tag fails here rather than
1916 + // exposing the URLs and being noticed in a search result.
1917 + let html = render(&Screen::sidebar_content("Downloads").indexed(false));
1918 + assert!(
1919 + html.contains("<meta name=\"robots\" content=\"noindex\">"),
1920 + "{html}"
1921 + );
1922 + }
1923 +
1924 + #[test]
1925 + fn a_screen_names_what_it_is_about_and_how_it_previews() {
1926 + let html = render(
1927 + &Screen::sidebar_content("Blue Hour")
1928 + .summarised("Nine tracks recorded in one night.")
1929 + .illustrated("https://makenot.work/media/cover.png")
1930 + .about(quasi_router::SocialKind::Song)
1931 + .canonical_at("https://makenot.work/i/7"),
1932 + );
1933 +
1934 + assert!(
1935 + html.contains("content=\"Nine tracks recorded in one night.\""),
1936 + "{html}"
1937 + );
1938 + assert!(
1939 + html.contains("content=\"https://makenot.work/media/cover.png\""),
1940 + "{html}"
1941 + );
1942 + assert!(
1943 + html.contains("<meta property=\"og:type\" content=\"music.song\">"),
1944 + "{html}"
1945 + );
1946 + assert!(
1947 + html.contains("<link rel=\"canonical\" href=\"https://makenot.work/i/7\">"),
1948 + "{html}"
1949 + );
1950 +
1951 + // An image means a large card. The Twitter tags are `name`, never
1952 + // `property`: they were not part of RDFa, and a card written the other way
1953 + // is a card the crawler skips.
1954 + assert!(
1955 + html.contains("<meta name=\"twitter:card\" content=\"summary_large_image\">"),
1956 + "{html}"
1957 + );
1958 + assert!(!html.contains("property=\"twitter:"), "{html}");
1959 + }
1960 +
1961 + #[test]
1962 + fn a_summary_is_escaped_because_a_person_wrote_it() {
1963 + // An item description and a bio are user-authored, and they land in an
1964 + // attribute value. The one place in the head where that is true.
1965 + let html =
1966 + render(&Screen::sidebar_content("Item").summarised("She said \"hi\" & <b>waved</b>"));
1967 +
1968 + assert!(!html.contains("<b>waved"), "{html}");
1969 + assert!(html.contains("&quot;hi&quot;"), "{html}");
1970 + assert!(html.contains("&amp;"), "{html}");
1971 + }