max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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("<script>").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("<script>").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(""hi""), "{html}"); | |
| 1970 | + | assert!(html.contains("&"), "{html}"); | |
| 1971 | + | } |