diff --git a/README.md b/README.md index dece8f2..d1cf2bb 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,9 @@ site = "smol" HTTP and Spartan route by the hostname the request carries, falling back to `default_site`; Nex and Gopher carry none, so their listener names one site outright. Every format a listener serves must be compiled in, which `--check` reports. A `.itsybitsy.toml` in a content directory sets rendering options for that directory — `[defaults]` for all its Markdown files, `[page."name.md"]` for one — with no inheritance from parent directories. Editing it re-renders on the next request. -A site can set `mews_profile = true` to render its `xhtmlmp` and `html` output as [Mews Profile](https://mews.page/spec/) pages: the XHTML-MP 1.2 doctype, the conformance marker, the viewport tag and the default stylesheet link, with raw HTML and off-site or `data:` images dropped rather than passed through. The stylesheet itself is served from an embedded copy, at whatever path the link's `href` resolves to, so there is nothing to place in the content root. Sites sharing one root must agree on this setting, since they share one render cache. +A site can set `mews_profile = true` to render its `xhtmlmp` and `html` output as [Mews Profile](https://mews.page/spec/) pages: the XML declaration, the XHTML-MP 1.2 doctype, the conformance marker, the viewport tag and the default stylesheet link, with raw HTML and off-site or `data:` images dropped rather than passed through. The declaration stays in the `text/html` form too, unlike an ordinary page's, because section 3.1 requires it and a validator reads whichever bytes it was served; a browser logs a warning for it and renders the page the same. The stylesheet itself is served from an embedded copy, at whatever path the link's `href` resolves to, so there is nothing to place in the content root. Sites sharing one root must agree on this setting, since they share one render cache. + +A site may also set `feed` to the URL of its Atom feed, which is then declared in the head of every `xhtmlmp` and `html` page so a client can subscribe without reading the body (Mews Profile 6.2). Give it absolute: a capsule rarely carries the feed its web site publishes, so a root-relative path would point at a document this server has no file for. Sites sharing one root must agree on it too. ### Formats and protocols diff --git a/core/src/config.rs b/core/src/config.rs index fd3a320..d9cec90 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -55,6 +55,13 @@ pub struct SiteSpec { /// profile's permitted-element list cannot otherwise be guaranteed. #[serde(default)] pub mews_profile: bool, + /// The site's Atom feed, declared in the head of its `xhtmlmp`/`html` + /// pages (mews.page/spec 6.2) so a client can subscribe without reading + /// the body. Absolute: a capsule rarely carries the feed its web site + /// publishes, so a root-relative path would point at a document this + /// server has no file for. + #[serde(default)] + pub feed: Option, } #[derive(Debug, Deserialize)] @@ -177,21 +184,32 @@ impl ServerConfig { let hosts = self.build_host_map()?; let formats = self.validate_listeners(available_formats)?; let shared_roots = group_shared_roots(&roots); - self.validate_shared_mews_profile(&shared_roots)?; + self.validate_shared_markup(&shared_roots)?; Ok(Checked { hosts, roots: roots.clone(), shared_roots, formats }) } /// Sites sharing a root share one [`crate::site::Site`] and so one render /// cache; a page rendered once there cannot differ by which name served the - /// request, so `mews_profile` must agree within a group. - fn validate_shared_mews_profile(&self, shared_roots: &[Vec]) -> Result<(), Error> { + /// request, so every setting that changes its markup must agree within a + /// group. + fn validate_shared_markup(&self, shared_roots: &[Vec]) -> Result<(), Error> { + self.validate_shared(shared_roots, "mews_profile", |spec| spec.mews_profile)?; + self.validate_shared(shared_roots, "feed", |spec| spec.feed.clone()) + } + + fn validate_shared( + &self, + shared_roots: &[Vec], + key: &str, + of: impl Fn(&SiteSpec) -> T, + ) -> Result<(), Error> { for group in shared_roots { - let mut settings = group.iter().map(|name| self.site[name].mews_profile); - let first = settings.next().unwrap_or(false); + let mut settings = group.iter().map(|name| of(&self.site[name])); + let Some(first) = settings.next() else { continue }; if settings.any(|value| value != first) { return Err(Error::config(format!( - "sites {} share one root, so mews_profile must agree between them", + "sites {} share one root, so {key} must agree between them", group.join(", ") ))); } diff --git a/core/src/render.rs b/core/src/render.rs index 3915cb8..eaa859a 100644 --- a/core/src/render.rs +++ b/core/src/render.rs @@ -39,6 +39,18 @@ pub trait Renderer: Send + Sync { fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result; } +/// What a Mews Profile page needs beyond the document itself (mews.page/spec). +/// Present when the site has opted in, absent otherwise, so one `Option` says +/// both whether the profile applies and what it applies with. +#[derive(Debug, Clone, Copy)] +pub struct Mews<'a> { + /// The site's own hostnames, for judging an image `src` as same-site (4.2). + pub hosts: &'a [String], + /// The site's Atom feed, declared in the head (6.2). Absolute, since a + /// capsule need not host the feed its web site publishes. + pub feed: Option<&'a str>, +} + pub struct RenderCtx<'a> { /// Canonical root-relative URL of this document: `/`, `/about`, `/dir/`. A /// format that addresses sub-documents builds their URLs from this. @@ -48,11 +60,10 @@ pub struct RenderCtx<'a> { pub title: &'a str, pub settings: &'a PageSettings, pub width: Option, - /// The site's own hostnames, present when it has opted into Mews Profile - /// compliance (mews.page/spec) and absent otherwise. Threaded through so a - /// renderer that cares can switch its markup and judge an image `src` as - /// same-site, without reaching into site configuration itself. - pub mews_hosts: Option<&'a [String]>, + /// Mews Profile settings, present when the site has opted in. Threaded + /// through so a renderer that cares can switch its markup without reaching + /// into site configuration itself. + pub mews: Option>, } pub struct Rendered { @@ -117,7 +128,7 @@ impl Registry { url: &str, settings: &PageSettings, fallback_title: &str, - mews_hosts: Option<&[String]>, + mews: Option>, ) -> Result { let title = settings .title @@ -142,7 +153,7 @@ impl Registry { title: &page.title, settings, width: renderer.default_width(), - mews_hosts, + mews, }; // Format gates are resolved here, so no renderer meets one and a // gated run costs nothing extra in the cache: bodies are already @@ -222,7 +233,7 @@ mod tests { // after gates — is visible in the body. format!( "{} {} {:?} {} {:?} {:?}", - self.id, ctx.url, ctx.width, ctx.title, doc.blocks, ctx.mews_hosts + self.id, ctx.url, ctx.width, ctx.title, doc.blocks, ctx.mews ) .into_bytes(), )) @@ -269,13 +280,16 @@ mod tests { } #[test] - fn mews_hosts_reaches_the_renderer_when_the_site_opted_in() { + fn mews_settings_reach_the_renderer_when_the_site_opted_in() { let formats = vec!["one".to_string()]; let hosts = vec!["example.test".to_string()]; + let mews = Mews { hosts: &hosts, feed: Some("https://example.test/feed.xml") }; let page = registry() - .page(&formats, &doc(None), "/x", &PageSettings::default(), "x", Some(&hosts)) + .page(&formats, &doc(None), "/x", &PageSettings::default(), "x", Some(mews)) .unwrap(); - assert!(String::from_utf8_lossy(page.body("one").unwrap()).contains("example.test")); + let body = String::from_utf8_lossy(page.body("one").unwrap()).to_string(); + assert!(body.contains("example.test"), "{body}"); + assert!(body.contains("feed.xml"), "{body}"); let unset = registry() .page(&formats, &doc(None), "/x", &PageSettings::default(), "x", None) diff --git a/core/src/site.rs b/core/src/site.rs index 0004ea1..43de368 100644 --- a/core/src/site.rs +++ b/core/src/site.rs @@ -16,7 +16,7 @@ use crate::error::Error; use crate::mime; use crate::parse; use crate::path::{clean_path, url_for}; -use crate::render::{Page, Registry, title_from_stem}; +use crate::render::{Mews, Page, Registry, title_from_stem}; /// How deep a chain of server-side redirects `resolve_flat` will follow. Every /// redirect currently points at a directly resolvable document, so one hop is @@ -82,6 +82,10 @@ pub struct Site { /// differing host lists keeps only that one's, which is an accepted /// simplification rather than a union of them all. hosts: Vec, + /// This site's Atom feed (`SiteSpec::feed`), declared in the head of a + /// Mews page. Validation requires every site sharing this root to agree, + /// since the declaration is part of the cached markup. + feed: Option, } impl Site { @@ -93,6 +97,7 @@ impl Site { formats: Vec, mews_profile: bool, hosts: Vec, + feed: Option, ) -> Result { let root = root.canonicalize().map_err(|cause| Error::Io { path: root.to_path_buf(), cause })?; @@ -108,6 +113,7 @@ impl Site { formats, mews_profile, hosts, + feed, }) } @@ -256,15 +262,10 @@ impl Site { let settings = self.dir_config(dir)?.settings_for(name)?; let doc = parse::document(source, &self.root)?; let stem = source.file_stem().and_then(|s| s.to_str()).unwrap_or_default(); - let mews_hosts = self.mews_profile.then_some(self.hosts.as_slice()); - self.registry.page( - &self.formats, - &doc, - url, - &settings, - &title_from_stem(stem), - mews_hosts, - ) + let mews = self + .mews_profile + .then_some(Mews { hosts: self.hosts.as_slice(), feed: self.feed.as_deref() }); + self.registry.page(&self.formats, &doc, url, &settings, &title_from_stem(stem), mews) }) } @@ -300,7 +301,7 @@ mod tests { } fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result { - let mut body = format!("title={}\nmews_hosts={:?}\n", ctx.title, ctx.mews_hosts); + let mut body = format!("title={}\nmews={:?}\n", ctx.title, ctx.mews); for block in &doc.blocks { body.push_str(&format!("{block:?}\n")); } @@ -315,7 +316,7 @@ mod tests { } fn open(root: &Path) -> Site { - Site::new(root, registry(), vec!["stub".to_string()], false, Vec::new()).unwrap() + Site::new(root, registry(), vec!["stub".to_string()], false, Vec::new(), None).unwrap() } /// One of each kind of thing a request can land on, shared by the resolution @@ -502,7 +503,8 @@ mod tests { fn the_mews_stylesheet_is_served_embedded_when_the_profile_is_on() { let (dir, _site) = fixture(); let mews_site = - Site::new(dir.path(), registry(), vec!["stub".to_string()], true, Vec::new()).unwrap(); + Site::new(dir.path(), registry(), vec!["stub".to_string()], true, Vec::new(), None) + .unwrap(); for path in ["/mews-0.1.css", "/dir/mews-0.1.css"] { match mews_site.resolve(path).unwrap() { Resolution::Found(Resource::Embedded { bytes, media_type }) => { @@ -628,10 +630,10 @@ mod tests { let (dir, _site) = fixture(); let hosts = vec!["example.test".to_string()]; let mews_site = - Site::new(dir.path(), registry(), vec!["stub".to_string()], true, hosts).unwrap(); + Site::new(dir.path(), registry(), vec!["stub".to_string()], true, hosts, None).unwrap(); let (_, page) = document(&mews_site, "/about"); let body = String::from_utf8(page.body("stub").unwrap().to_vec()).unwrap(); - assert!(body.contains("mews_hosts=Some([\"example.test\"])"), "{body}"); + assert!(body.contains("hosts: [\"example.test\"]"), "{body}"); } #[test] @@ -639,7 +641,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let absent = dir.path().join("absent"); assert!(matches!( - Site::new(&absent, registry(), vec![], false, Vec::new()), + Site::new(&absent, registry(), vec![], false, Vec::new(), None), Err(Error::Io { .. }) )); } @@ -650,7 +652,7 @@ mod tests { let file = dir.path().join("not-a-dir"); fs::write(&file, "x").unwrap(); assert!(matches!( - Site::new(&file, registry(), vec![], false, Vec::new()), + Site::new(&file, registry(), vec![], false, Vec::new(), None), Err(Error::Config { .. }) )); } @@ -712,9 +714,15 @@ mod part_tests { let mut registry = Registry::new(); registry.insert(Arc::new(Paginating)).unwrap(); - let site = - Site::new(root, Arc::new(registry), vec!["paginating".to_string()], false, Vec::new()) - .unwrap(); + let site = Site::new( + root, + Arc::new(registry), + vec!["paginating".to_string()], + false, + Vec::new(), + None, + ) + .unwrap(); (dir, site) } diff --git a/core/src/siteset.rs b/core/src/siteset.rs index 986b799..387e501 100644 --- a/core/src/siteset.rs +++ b/core/src/siteset.rs @@ -39,8 +39,14 @@ impl SiteSet { for (name, spec) in &config.site { let hosts = spec.hosts.iter().filter_map(|host| normalize_host(host)).collect(); - let site = - Site::new(&spec.root, registry.clone(), formats.clone(), spec.mews_profile, hosts)?; + let site = Site::new( + &spec.root, + registry.clone(), + formats.clone(), + spec.mews_profile, + hosts, + spec.feed.clone(), + )?; // One `Site` per distinct root, so sites sharing a folder share its // caches rather than each building their own. let index = match set.sites.iter().position(|open| open.root() == site.root()) { diff --git a/gemtext/src/lib.rs b/gemtext/src/lib.rs index 7d1fa3c..d7fbde9 100644 --- a/gemtext/src/lib.rs +++ b/gemtext/src/lib.rs @@ -113,7 +113,7 @@ impl Writer { // number in the text to keep the sequence readable. if *ordered { self.line(&format!("* {}. {text}", start + index as u64)); - } else { + } else if !is_only_links_item(item) { self.line(&format!("* {text}")); } for block in item { @@ -201,6 +201,16 @@ impl Writer { } } +/// Whether a list item holds links and nothing else worth printing, in which +/// case the `=>` lines are the whole item and a marker line above them would +/// list every entry twice -- the same reading [`is_only_links`] gives a +/// paragraph. Markup other than one paragraph keeps its marker, since the +/// shape is then doing something a link catalogue alone would not carry; so +/// does an ordered item, whose number would go with it. +fn is_only_links_item(item: &[Block]) -> bool { + matches!(item, [Block::Paragraph(inline)] if is_only_links(inline)) +} + /// Whether a paragraph holds links and nothing else worth printing. fn is_only_links(inline: &[Inline]) -> bool { let mut saw_link = false; @@ -295,8 +305,7 @@ mod tests_support { pub fn render(markdown: &str) -> String { let doc = parse::markdown(markdown).unwrap(); let settings = PageSettings::default(); - let ctx = - RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews_hosts: None }; + let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews: None }; let out = Gemtext.render(&doc, &ctx).unwrap(); String::from_utf8(out.body).unwrap() } @@ -371,6 +380,19 @@ mod tests { assert_eq!(render("- see [x](/x)\n"), "* see x\n=> /x x\n"); } + #[test] + fn a_list_of_bare_links_is_a_run_of_link_lines() { + // The shape a generator wants for a dated index: a real list, so the + // markup formats can give it
  • , without gemtext printing both + // the marker line and the link line for every entry. + assert_eq!(render("- [a](/a)\n- [b](/b)\n"), "=> /a a\n=> /b b\n"); + } + + #[test] + fn an_ordered_list_of_bare_links_keeps_its_numbers() { + assert_eq!(render("1. [a](/a)\n"), "* 1. a\n=> /a a\n"); + } + #[test] fn code_blocks_become_fences_carrying_their_info_string() { assert_eq!(render("```rust\nlet x = 1;\n```\n"), "```rust\nlet x = 1;\n```\n"); @@ -399,8 +421,7 @@ mod tests { // Pagination is a WML concern; a Gemini client scrolls one document. let doc = parse::markdown("a\n\nb\n").unwrap(); let settings = PageSettings::default(); - let ctx = - RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews_hosts: None }; + let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews: None }; let plain = String::from_utf8(Gemtext.render(&doc, &ctx).unwrap().body).unwrap(); assert_eq!(plain, "a\n\nb\n"); } diff --git a/text/src/lib.rs b/text/src/lib.rs index e6951af..8aabf6b 100644 --- a/text/src/lib.rs +++ b/text/src/lib.rs @@ -58,8 +58,7 @@ mod tests { /// assertions read as the content itself. fn render_with(settings: &PageSettings, width: u16, markdown: &str) -> String { let doc = parse::markdown(markdown).unwrap(); - let ctx = - RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews_hosts: None }; + let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews: None }; String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap() } @@ -214,8 +213,7 @@ mod tests { /// `parse::document`, which needs a file, so the unit tests build them. fn render_blocks(settings: &PageSettings, width: u16, blocks: Vec) -> String { let doc = Doc { blocks, first_h1: None }; - let ctx = - RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews_hosts: None }; + let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews: None }; String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap() } @@ -269,13 +267,8 @@ mod separation_tests { fn render(markdown: &str) -> String { let doc = parse::markdown(markdown).unwrap(); let settings = PageSettings { margin_left: 0, margin_right: 0, ..Default::default() }; - let ctx = RenderCtx { - url: "/x", - title: "T", - settings: &settings, - width: Some(40), - mews_hosts: None, - }; + let ctx = + RenderCtx { url: "/x", title: "T", settings: &settings, width: Some(40), mews: None }; String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap() } @@ -307,8 +300,7 @@ mod optional_feature_tests { fn render_with(settings: &PageSettings, width: u16, markdown: &str) -> String { let doc = parse::markdown(markdown).unwrap(); - let ctx = - RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews_hosts: None }; + let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews: None }; String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap() } diff --git a/wap/src/lib.rs b/wap/src/lib.rs index 78e53a6..162551f 100644 --- a/wap/src/lib.rs +++ b/wap/src/lib.rs @@ -1,9 +1,9 @@ //! The WAP family: XHTML Mobile Profile, and the `text/html` body derived from it. //! //! One crate rather than one per format, because the HTML form is the XHTML-MP -//! bytes with the XML declaration removed, and because WML's deck configuration -//! is shared with nothing outside this family. Splitting them would duplicate the -//! escaper and the markup builder. +//! bytes, less the XML declaration on a page that is not a Mews page, and because +//! WML's deck configuration is shared with nothing outside this family. Splitting +//! them would duplicate the escaper and the markup builder. #[cfg(feature = "wml")] mod deck; @@ -29,7 +29,7 @@ impl Renderer for XhtmlMp { } fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result { - Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, true, ctx.mews_hosts).into_bytes())) + Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, true, ctx.mews).into_bytes())) } } @@ -46,7 +46,7 @@ impl Renderer for Html { } fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result { - Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, false, ctx.mews_hosts).into_bytes())) + Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, false, ctx.mews).into_bytes())) } } @@ -87,7 +87,7 @@ mod tests { title: "The Title", settings: &settings, width: None, - mews_hosts: None, + mews: None, }; String::from_utf8(renderer.render(&doc, &ctx).unwrap().body).unwrap() } @@ -184,8 +184,7 @@ mod tests { first_h1: None, }; let settings = PageSettings::default(); - let ctx = - RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews_hosts: None }; + let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews: None }; let out = String::from_utf8(Html.render(&doc, &ctx).unwrap().body).unwrap(); assert!(out.contains("
    /\\
    "), "{out}"); } @@ -202,8 +201,7 @@ mod tests { first_h1: None, }; let settings = PageSettings::default(); - let ctx = - RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews_hosts: None }; + let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews: None }; let out = String::from_utf8(Html.render(&doc, &ctx).unwrap().body).unwrap(); assert!(out.contains("\n"), "{out}"); } @@ -226,8 +224,7 @@ mod deck_tests { // Card dividers are comment directives, so the IR is built by hand here // the way `parse::document` would produce it. let doc = with_card_breaks(markdown); - let ctx = - RenderCtx { url: "/trail", title: "Trail", settings, width: None, mews_hosts: None }; + let ctx = RenderCtx { url: "/trail", title: "Trail", settings, width: None, mews: None }; Wml.render(&doc, &ctx).unwrap() } @@ -488,7 +485,7 @@ mod oracle_dump { title: "Trail", settings: &settings, width: None, - mews_hosts: None, + mews: None, }; let out = Wml.render(&doc, &ctx).unwrap(); std::fs::write(format!("/tmp/mine-{name}.wml"), out.body).unwrap(); diff --git a/wap/src/xhtmlmp.rs b/wap/src/xhtmlmp.rs index cdafcb0..5b4d430 100644 --- a/wap/src/xhtmlmp.rs +++ b/wap/src/xhtmlmp.rs @@ -3,15 +3,18 @@ //! One document serves both: a WAP 2.0 client needs well-formed XML with the //! prolog and the Mobile Profile doctype, while a browser parsing the same bytes //! as `text/html` tolerates the leading `` as a bogus comment but -//! logs a warning for it. So the HTML form is the same markup with that one line -//! removed. +//! logs a warning for it. So an ordinary page's HTML form is the same markup +//! with that one line removed. A Mews page keeps it in both forms: see +//! [`document`]. use itsybitsy_core::config::normalize_host; use itsybitsy_core::ir::{Block, Doc, Inline}; +use itsybitsy_core::render::Mews; use crate::escape; -/// The XML declaration, which only the XHTML-MP form carries. +/// The XML declaration, which every form of a Mews page carries and only the +/// XHTML-MP form of an ordinary one does. const PROLOG: &str = "\n"; /// The ordinary doctype, used whenever a site has not opted into Mews Profile @@ -32,44 +35,55 @@ const MEWS_HEAD_EXTRA: &str = "\n\ \n"; /// Build the whole document. `prolog` distinguishes the two media types. -/// `mews_hosts` is `Some` with the site's own hostnames when it has opted into -/// Mews Profile compliance, and `None` otherwise. -pub fn document(doc: &Doc, title: &str, prolog: bool, mews_hosts: Option<&[String]>) -> String { +/// `mews` is `Some` when the site has opted into Mews Profile compliance, and +/// `None` otherwise. +pub fn document(doc: &Doc, title: &str, prolog: bool, mews: Option>) -> String { let mut out = String::new(); - if prolog { + // A Mews page carries the declaration whatever its media type: 3.1 requires + // it, and a validator or directory checker judges the bytes it was handed, + // which for anything fetching the way a browser does is the `text/html` + // form. The cost is the console warning an HTML parser logs for a comment + // it did not expect; it reads the line as a comment before the doctype, so + // nothing about how the page renders changes. + if prolog || mews.is_some() { out.push_str(PROLOG); } - out.push_str(if mews_hosts.is_some() { DOCTYPE_1_2 } else { DOCTYPE_1_0 }); + out.push_str(if mews.is_some() { DOCTYPE_1_2 } else { DOCTYPE_1_0 }); out.push_str("\n"); - if mews_hosts.is_some() { - out.push_str(&format!( - "\n{}\n{MEWS_HEAD_EXTRA}\n", - escape::text(title) - )); - } else { - out.push_str(&format!("{}\n", escape::text(title))); + out.push_str(&format!("\n{}\n", escape::text(title))); + if mews.is_some() { + out.push_str(MEWS_HEAD_EXTRA); } + // The feed is declared here rather than linked from a page body, which is + // the only place a client reading the head can find it (6.2). The label is + // the spec example's, since the attribute names the feed rather than + // whichever page happens to carry the declaration. + if let Some(feed) = mews.and_then(|mews| mews.feed) { + out.push_str(&format!( + "\n", + escape::attr(feed) + )); + } + out.push_str("\n"); out.push_str("\n"); - blocks(&doc.blocks, mews_hosts, &mut out); + blocks(&doc.blocks, mews, &mut out); out.push_str("\n\n"); out } -fn blocks(blocks: &[Block], mews_hosts: Option<&[String]>, out: &mut String) { +fn blocks(blocks: &[Block], mews: Option>, out: &mut String) { for block in blocks { - self_block(block, mews_hosts, out); + self_block(block, mews, out); } } -fn self_block(block: &Block, mews_hosts: Option<&[String]>, out: &mut String) { +fn self_block(block: &Block, mews: Option>, out: &mut String) { match block { Block::Heading { level, inline } => { let level = (*level).clamp(1, 6); - out.push_str(&format!("{}\n", inlines(inline, mews_hosts))); - } - Block::Paragraph(inline) => { - out.push_str(&format!("

    {}

    \n", inlines(inline, mews_hosts))) + out.push_str(&format!("{}\n", inlines(inline, mews))); } + Block::Paragraph(inline) => out.push_str(&format!("

    {}

    \n", inlines(inline, mews))), Block::CodeBlock { lines, .. } => { out.push_str("
    ");
                 out.push_str(&escape::text(&lines.join("\n")));
    @@ -84,7 +98,7 @@ fn self_block(block: &Block, mews_hosts: Option<&[String]>, out: &mut String) {
             }
             Block::BlockQuote(inner) => {
                 out.push_str("
    \n"); - blocks(inner, mews_hosts, out); + blocks(inner, mews, out); out.push_str("
    \n"); } Block::List { ordered, start, items } => { @@ -100,10 +114,10 @@ fn self_block(block: &Block, mews_hosts: Option<&[String]>, out: &mut String) { out.push_str("
  • "); // A single paragraph needs no block wrapper inside the item. match item.as_slice() { - [Block::Paragraph(inline)] => out.push_str(&inlines(inline, mews_hosts)), + [Block::Paragraph(inline)] => out.push_str(&inlines(inline, mews)), blocks_in_item => { out.push('\n'); - blocks(blocks_in_item, mews_hosts, out); + blocks(blocks_in_item, mews, out); } } out.push_str("
  • \n"); @@ -115,14 +129,14 @@ fn self_block(block: &Block, mews_hosts: Option<&[String]>, out: &mut String) { if !head.is_empty() { out.push_str(""); for cell in head { - out.push_str(&format!("{}", inlines(cell, mews_hosts))); + out.push_str(&format!("{}", inlines(cell, mews))); } out.push_str("\n"); } for row in rows { out.push_str(""); for cell in row { - out.push_str(&format!("{}", inlines(cell, mews_hosts))); + out.push_str(&format!("{}", inlines(cell, mews))); } out.push_str("\n"); } @@ -134,36 +148,34 @@ fn self_block(block: &Block, mews_hosts: Option<&[String]>, out: &mut String) { // cannot make that guarantee — the markup might use an element or // attribute the profile excludes — so it is dropped instead. Block::Html(html) => { - if mews_hosts.is_none() { + if mews.is_none() { out.push_str(html.trim_end()); out.push('\n'); } } // Alignment reaches only the fixed-width text formats. Pagination is a // WML concern; a browser scrolls one document. - Block::Aligned { block, .. } => self_block(block, mews_hosts, out), + Block::Aligned { block, .. } => self_block(block, mews, out), // Gates are filtered out before rendering; keeping the content is the // harmless reading if one ever arrives here. - Block::Gated { block, .. } => self_block(block, mews_hosts, out), + Block::Gated { block, .. } => self_block(block, mews, out), Block::CardBreak { .. } => {} } } -fn inlines(inline: &[Inline], mews_hosts: Option<&[String]>) -> String { +fn inlines(inline: &[Inline], mews: Option>) -> String { let mut out = String::new(); for item in inline { match item { Inline::Text(text) => out.push_str(&escape::text(text)), Inline::Code(code) => out.push_str(&format!("{}", escape::text(code))), - Inline::Emph(inner) => { - out.push_str(&format!("{}", inlines(inner, mews_hosts))) - } + Inline::Emph(inner) => out.push_str(&format!("{}", inlines(inner, mews))), Inline::Strong(inner) => { - out.push_str(&format!("{}", inlines(inner, mews_hosts))) + out.push_str(&format!("{}", inlines(inner, mews))) } // XHTML-MP 1.0 has no , and is not in the profile, so // the text survives without its markup rather than being dropped. - Inline::Strike(inner) => out.push_str(&inlines(inner, mews_hosts)), + Inline::Strike(inner) => out.push_str(&inlines(inner, mews)), Inline::Link { href, title, label } => { let title = title .as_deref() @@ -172,14 +184,14 @@ fn inlines(inline: &[Inline], mews_hosts: Option<&[String]>) -> String { out.push_str(&format!( "{}", escape::attr(href), - inlines(label, mews_hosts) + inlines(label, mews) )); } Inline::Image { src, title, alt } => { // A Mews page's image must point at the same site and never be // a `data:` URI (SPEC.md 4.2); one that does not is dropped to // its alt text rather than rendered non-conformant. - if mews_hosts.is_some_and(|hosts| !same_site_image(src, hosts)) { + if mews.is_some_and(|mews| !same_site_image(src, mews.hosts)) { out.push_str(&escape::text(&Doc::plain_text(alt))); continue; } @@ -197,7 +209,7 @@ fn inlines(inline: &[Inline], mews_hosts: Option<&[String]>) -> String { Inline::HardBreak => out.push_str("
    \n"), // See the Block::Html arm above: unverifiable in a Mews page. Inline::Html(html) => { - if mews_hosts.is_none() { + if mews.is_none() { out.push_str(html); } } @@ -242,13 +254,50 @@ mod tests { Doc { blocks: Vec::new(), first_h1: None } } + /// A site that has opted into the profile and declares no feed. + fn mews(hosts: &[String]) -> Mews<'_> { + Mews { hosts, feed: None } + } + #[test] - fn the_prolog_is_present_only_when_asked_for() { + fn an_ordinary_page_carries_the_prolog_only_when_asked_for() { let doc = empty(); assert!(document(&doc, "T", true, None).starts_with(""), "{out}"); + } + } + + #[test] + fn the_feed_is_declared_in_the_head_when_the_site_names_one() { + let hosts = vec!["example.test".to_string()]; + let feed = Mews { hosts: &hosts, feed: Some("https://example.test/feed.xml") }; + let out = document(&empty(), "T", false, Some(feed)); + assert!( + out.contains( + "" + ), + "{out}" + ); + assert!( + out.split("").next().is_some_and(|head| head.contains("alternate")), + "{out}" + ); + + let none = document(&empty(), "T", false, Some(mews(&hosts))); + assert!(!none.contains("alternate"), "{none}"); + } + #[test] fn a_plain_page_keeps_the_1_0_doctype_and_no_mews_head() { let out = document(&empty(), "T", false, None); @@ -259,7 +308,7 @@ mod tests { #[test] fn a_mews_page_carries_the_1_2_doctype_and_the_conformance_marker() { let hosts = vec!["example.test".to_string()]; - let out = document(&empty(), "T", false, Some(&hosts)); + let out = document(&empty(), "T", false, Some(mews(&hosts))); assert!(out.contains("XHTML Mobile 1.2"), "{out}"); assert!(out.contains(""), "{out}"); assert!(out.contains(""), "{out}"); @@ -273,7 +322,7 @@ mod tests { fn a_mews_page_drops_raw_html_it_cannot_vouch_for() { let hosts = vec!["example.test".to_string()]; let doc = Doc { blocks: vec![Block::Html("
    x
    ".into())], first_h1: None }; - let out = document(&doc, "T", false, Some(&hosts)); + let out = document(&doc, "T", false, Some(mews(&hosts))); assert!(!out.contains("
    "), "{out}"); let plain = document(&doc, "T", false, None); @@ -295,7 +344,7 @@ mod tests { fn a_mews_page_keeps_a_relative_or_same_site_image() { let hosts = vec!["example.test".to_string()]; for src in ["/i.png", "i.png", "https://example.test/i.png", "//img.example.test/i.png"] { - let out = document(&image_doc(src), "T", false, Some(&hosts)); + let out = document(&image_doc(src), "T", false, Some(mews(&hosts))); assert!(out.contains(&format!("src=\"{src}\"")), "{src}: {out}"); } } @@ -304,7 +353,7 @@ mod tests { fn a_mews_page_drops_an_off_site_or_data_image_to_its_alt_text() { let hosts = vec!["example.test".to_string()]; for src in ["https://elsewhere.test/i.png", "data:image/png;base64,AAAA"] { - let out = document(&image_doc(src), "T", false, Some(&hosts)); + let out = document(&image_doc(src), "T", false, Some(mews(&hosts))); assert!(!out.contains("