feat: declare a Mews page's feed, keep its prolog, and lift link-only list items
All checks were successful
CI / check (push) Successful in 26s
CI / release (push) Successful in 1m1s

This commit is contained in:
randogoth 2026-10-11 16:18:05 +03:00
parent 3e659df9dd
commit c08b03db7b
9 changed files with 223 additions and 116 deletions

View file

@ -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. 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 ### Formats and protocols

View file

@ -55,6 +55,13 @@ pub struct SiteSpec {
/// profile's permitted-element list cannot otherwise be guaranteed. /// profile's permitted-element list cannot otherwise be guaranteed.
#[serde(default)] #[serde(default)]
pub mews_profile: bool, 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<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -177,21 +184,32 @@ impl ServerConfig {
let hosts = self.build_host_map()?; let hosts = self.build_host_map()?;
let formats = self.validate_listeners(available_formats)?; let formats = self.validate_listeners(available_formats)?;
let shared_roots = group_shared_roots(&roots); 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 }) Ok(Checked { hosts, roots: roots.clone(), shared_roots, formats })
} }
/// Sites sharing a root share one [`crate::site::Site`] and so one render /// 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 /// cache; a page rendered once there cannot differ by which name served the
/// request, so `mews_profile` must agree within a group. /// request, so every setting that changes its markup must agree within a
fn validate_shared_mews_profile(&self, shared_roots: &[Vec<String>]) -> Result<(), Error> { /// group.
fn validate_shared_markup(&self, shared_roots: &[Vec<String>]) -> 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<T: PartialEq>(
&self,
shared_roots: &[Vec<String>],
key: &str,
of: impl Fn(&SiteSpec) -> T,
) -> Result<(), Error> {
for group in shared_roots { for group in shared_roots {
let mut settings = group.iter().map(|name| self.site[name].mews_profile); let mut settings = group.iter().map(|name| of(&self.site[name]));
let first = settings.next().unwrap_or(false); let Some(first) = settings.next() else { continue };
if settings.any(|value| value != first) { if settings.any(|value| value != first) {
return Err(Error::config(format!( 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(", ") group.join(", ")
))); )));
} }

View file

@ -39,6 +39,18 @@ pub trait Renderer: Send + Sync {
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error>; fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error>;
} }
/// 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> { pub struct RenderCtx<'a> {
/// Canonical root-relative URL of this document: `/`, `/about`, `/dir/`. A /// Canonical root-relative URL of this document: `/`, `/about`, `/dir/`. A
/// format that addresses sub-documents builds their URLs from this. /// format that addresses sub-documents builds their URLs from this.
@ -48,11 +60,10 @@ pub struct RenderCtx<'a> {
pub title: &'a str, pub title: &'a str,
pub settings: &'a PageSettings, pub settings: &'a PageSettings,
pub width: Option<u16>, pub width: Option<u16>,
/// The site's own hostnames, present when it has opted into Mews Profile /// Mews Profile settings, present when the site has opted in. Threaded
/// compliance (mews.page/spec) and absent otherwise. Threaded through so a /// through so a renderer that cares can switch its markup without reaching
/// renderer that cares can switch its markup and judge an image `src` as /// into site configuration itself.
/// same-site, without reaching into site configuration itself. pub mews: Option<Mews<'a>>,
pub mews_hosts: Option<&'a [String]>,
} }
pub struct Rendered { pub struct Rendered {
@ -117,7 +128,7 @@ impl Registry {
url: &str, url: &str,
settings: &PageSettings, settings: &PageSettings,
fallback_title: &str, fallback_title: &str,
mews_hosts: Option<&[String]>, mews: Option<Mews<'_>>,
) -> Result<Page, Error> { ) -> Result<Page, Error> {
let title = settings let title = settings
.title .title
@ -142,7 +153,7 @@ impl Registry {
title: &page.title, title: &page.title,
settings, settings,
width: renderer.default_width(), width: renderer.default_width(),
mews_hosts, mews,
}; };
// Format gates are resolved here, so no renderer meets one and a // Format gates are resolved here, so no renderer meets one and a
// gated run costs nothing extra in the cache: bodies are already // gated run costs nothing extra in the cache: bodies are already
@ -222,7 +233,7 @@ mod tests {
// after gates — is visible in the body. // after gates — is visible in the body.
format!( 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(), .into_bytes(),
)) ))
@ -269,13 +280,16 @@ mod tests {
} }
#[test] #[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 formats = vec!["one".to_string()];
let hosts = vec!["example.test".to_string()]; let hosts = vec!["example.test".to_string()];
let mews = Mews { hosts: &hosts, feed: Some("https://example.test/feed.xml") };
let page = registry() let page = registry()
.page(&formats, &doc(None), "/x", &PageSettings::default(), "x", Some(&hosts)) .page(&formats, &doc(None), "/x", &PageSettings::default(), "x", Some(mews))
.unwrap(); .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() let unset = registry()
.page(&formats, &doc(None), "/x", &PageSettings::default(), "x", None) .page(&formats, &doc(None), "/x", &PageSettings::default(), "x", None)

View file

@ -16,7 +16,7 @@ use crate::error::Error;
use crate::mime; use crate::mime;
use crate::parse; use crate::parse;
use crate::path::{clean_path, url_for}; 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 /// 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 /// 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 /// differing host lists keeps only that one's, which is an accepted
/// simplification rather than a union of them all. /// simplification rather than a union of them all.
hosts: Vec<String>, hosts: Vec<String>,
/// 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<String>,
} }
impl Site { impl Site {
@ -93,6 +97,7 @@ impl Site {
formats: Vec<String>, formats: Vec<String>,
mews_profile: bool, mews_profile: bool,
hosts: Vec<String>, hosts: Vec<String>,
feed: Option<String>,
) -> Result<Self, Error> { ) -> Result<Self, Error> {
let root = let root =
root.canonicalize().map_err(|cause| Error::Io { path: root.to_path_buf(), cause })?; root.canonicalize().map_err(|cause| Error::Io { path: root.to_path_buf(), cause })?;
@ -108,6 +113,7 @@ impl Site {
formats, formats,
mews_profile, mews_profile,
hosts, hosts,
feed,
}) })
} }
@ -256,15 +262,10 @@ impl Site {
let settings = self.dir_config(dir)?.settings_for(name)?; let settings = self.dir_config(dir)?.settings_for(name)?;
let doc = parse::document(source, &self.root)?; let doc = parse::document(source, &self.root)?;
let stem = source.file_stem().and_then(|s| s.to_str()).unwrap_or_default(); 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()); let mews = self
self.registry.page( .mews_profile
&self.formats, .then_some(Mews { hosts: self.hosts.as_slice(), feed: self.feed.as_deref() });
&doc, self.registry.page(&self.formats, &doc, url, &settings, &title_from_stem(stem), mews)
url,
&settings,
&title_from_stem(stem),
mews_hosts,
)
}) })
} }
@ -300,7 +301,7 @@ mod tests {
} }
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> { fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
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 { for block in &doc.blocks {
body.push_str(&format!("{block:?}\n")); body.push_str(&format!("{block:?}\n"));
} }
@ -315,7 +316,7 @@ mod tests {
} }
fn open(root: &Path) -> Site { 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 /// 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() { fn the_mews_stylesheet_is_served_embedded_when_the_profile_is_on() {
let (dir, _site) = fixture(); let (dir, _site) = fixture();
let mews_site = 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"] { for path in ["/mews-0.1.css", "/dir/mews-0.1.css"] {
match mews_site.resolve(path).unwrap() { match mews_site.resolve(path).unwrap() {
Resolution::Found(Resource::Embedded { bytes, media_type }) => { Resolution::Found(Resource::Embedded { bytes, media_type }) => {
@ -628,10 +630,10 @@ mod tests {
let (dir, _site) = fixture(); let (dir, _site) = fixture();
let hosts = vec!["example.test".to_string()]; let hosts = vec!["example.test".to_string()];
let mews_site = 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 (_, page) = document(&mews_site, "/about");
let body = String::from_utf8(page.body("stub").unwrap().to_vec()).unwrap(); 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] #[test]
@ -639,7 +641,7 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let absent = dir.path().join("absent"); let absent = dir.path().join("absent");
assert!(matches!( assert!(matches!(
Site::new(&absent, registry(), vec![], false, Vec::new()), Site::new(&absent, registry(), vec![], false, Vec::new(), None),
Err(Error::Io { .. }) Err(Error::Io { .. })
)); ));
} }
@ -650,7 +652,7 @@ mod tests {
let file = dir.path().join("not-a-dir"); let file = dir.path().join("not-a-dir");
fs::write(&file, "x").unwrap(); fs::write(&file, "x").unwrap();
assert!(matches!( assert!(matches!(
Site::new(&file, registry(), vec![], false, Vec::new()), Site::new(&file, registry(), vec![], false, Vec::new(), None),
Err(Error::Config { .. }) Err(Error::Config { .. })
)); ));
} }
@ -712,8 +714,14 @@ mod part_tests {
let mut registry = Registry::new(); let mut registry = Registry::new();
registry.insert(Arc::new(Paginating)).unwrap(); registry.insert(Arc::new(Paginating)).unwrap();
let site = let site = Site::new(
Site::new(root, Arc::new(registry), vec!["paginating".to_string()], false, Vec::new()) root,
Arc::new(registry),
vec!["paginating".to_string()],
false,
Vec::new(),
None,
)
.unwrap(); .unwrap();
(dir, site) (dir, site)
} }

View file

@ -39,8 +39,14 @@ impl SiteSet {
for (name, spec) in &config.site { for (name, spec) in &config.site {
let hosts = spec.hosts.iter().filter_map(|host| normalize_host(host)).collect(); let hosts = spec.hosts.iter().filter_map(|host| normalize_host(host)).collect();
let site = let site = Site::new(
Site::new(&spec.root, registry.clone(), formats.clone(), spec.mews_profile, hosts)?; &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 // One `Site` per distinct root, so sites sharing a folder share its
// caches rather than each building their own. // caches rather than each building their own.
let index = match set.sites.iter().position(|open| open.root() == site.root()) { let index = match set.sites.iter().position(|open| open.root() == site.root()) {

View file

@ -113,7 +113,7 @@ impl Writer {
// number in the text to keep the sequence readable. // number in the text to keep the sequence readable.
if *ordered { if *ordered {
self.line(&format!("* {}. {text}", start + index as u64)); self.line(&format!("* {}. {text}", start + index as u64));
} else { } else if !is_only_links_item(item) {
self.line(&format!("* {text}")); self.line(&format!("* {text}"));
} }
for block in item { 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. /// Whether a paragraph holds links and nothing else worth printing.
fn is_only_links(inline: &[Inline]) -> bool { fn is_only_links(inline: &[Inline]) -> bool {
let mut saw_link = false; let mut saw_link = false;
@ -295,8 +305,7 @@ mod tests_support {
pub fn render(markdown: &str) -> String { pub fn render(markdown: &str) -> String {
let doc = parse::markdown(markdown).unwrap(); let doc = parse::markdown(markdown).unwrap();
let settings = PageSettings::default(); let settings = PageSettings::default();
let ctx = let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews: None };
RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews_hosts: None };
let out = Gemtext.render(&doc, &ctx).unwrap(); let out = Gemtext.render(&doc, &ctx).unwrap();
String::from_utf8(out.body).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"); 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 <ul><li>, 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] #[test]
fn code_blocks_become_fences_carrying_their_info_string() { fn code_blocks_become_fences_carrying_their_info_string() {
assert_eq!(render("```rust\nlet x = 1;\n```\n"), "```rust\nlet x = 1;\n```\n"); 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. // Pagination is a WML concern; a Gemini client scrolls one document.
let doc = parse::markdown("a\n\nb\n").unwrap(); let doc = parse::markdown("a\n\nb\n").unwrap();
let settings = PageSettings::default(); let settings = PageSettings::default();
let ctx = let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews: None };
RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews_hosts: None };
let plain = String::from_utf8(Gemtext.render(&doc, &ctx).unwrap().body).unwrap(); let plain = String::from_utf8(Gemtext.render(&doc, &ctx).unwrap().body).unwrap();
assert_eq!(plain, "a\n\nb\n"); assert_eq!(plain, "a\n\nb\n");
} }

View file

@ -58,8 +58,7 @@ mod tests {
/// assertions read as the content itself. /// assertions read as the content itself.
fn render_with(settings: &PageSettings, width: u16, markdown: &str) -> String { fn render_with(settings: &PageSettings, width: u16, markdown: &str) -> String {
let doc = parse::markdown(markdown).unwrap(); let doc = parse::markdown(markdown).unwrap();
let ctx = let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews: None };
RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews_hosts: None };
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap() 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. /// `parse::document`, which needs a file, so the unit tests build them.
fn render_blocks(settings: &PageSettings, width: u16, blocks: Vec<Block>) -> String { fn render_blocks(settings: &PageSettings, width: u16, blocks: Vec<Block>) -> String {
let doc = Doc { blocks, first_h1: None }; let doc = Doc { blocks, first_h1: None };
let ctx = let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews: None };
RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews_hosts: None };
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap() String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
} }
@ -269,13 +267,8 @@ mod separation_tests {
fn render(markdown: &str) -> String { fn render(markdown: &str) -> String {
let doc = parse::markdown(markdown).unwrap(); let doc = parse::markdown(markdown).unwrap();
let settings = PageSettings { margin_left: 0, margin_right: 0, ..Default::default() }; let settings = PageSettings { margin_left: 0, margin_right: 0, ..Default::default() };
let ctx = RenderCtx { let ctx =
url: "/x", RenderCtx { url: "/x", title: "T", settings: &settings, width: Some(40), mews: None };
title: "T",
settings: &settings,
width: Some(40),
mews_hosts: None,
};
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap() 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 { fn render_with(settings: &PageSettings, width: u16, markdown: &str) -> String {
let doc = parse::markdown(markdown).unwrap(); let doc = parse::markdown(markdown).unwrap();
let ctx = let ctx = RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews: None };
RenderCtx { url: "/x", title: "T", settings, width: Some(width), mews_hosts: None };
String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap() String::from_utf8(Text.render(&doc, &ctx).unwrap().body).unwrap()
} }

View file

@ -1,9 +1,9 @@
//! The WAP family: XHTML Mobile Profile, and the `text/html` body derived from it. //! 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 //! 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 //! bytes, less the XML declaration on a page that is not a Mews page, and because
//! is shared with nothing outside this family. Splitting them would duplicate the //! WML's deck configuration is shared with nothing outside this family. Splitting
//! escaper and the markup builder. //! them would duplicate the escaper and the markup builder.
#[cfg(feature = "wml")] #[cfg(feature = "wml")]
mod deck; mod deck;
@ -29,7 +29,7 @@ impl Renderer for XhtmlMp {
} }
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> { fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
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<Rendered, Error> { fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
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", title: "The Title",
settings: &settings, settings: &settings,
width: None, width: None,
mews_hosts: None, mews: None,
}; };
String::from_utf8(renderer.render(&doc, &ctx).unwrap().body).unwrap() String::from_utf8(renderer.render(&doc, &ctx).unwrap().body).unwrap()
} }
@ -184,8 +184,7 @@ mod tests {
first_h1: None, first_h1: None,
}; };
let settings = PageSettings::default(); let settings = PageSettings::default();
let ctx = let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews: None };
RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews_hosts: None };
let out = String::from_utf8(Html.render(&doc, &ctx).unwrap().body).unwrap(); let out = String::from_utf8(Html.render(&doc, &ctx).unwrap().body).unwrap();
assert!(out.contains("<pre title=\"Dragon\">/\\</pre>"), "{out}"); assert!(out.contains("<pre title=\"Dragon\">/\\</pre>"), "{out}");
} }
@ -202,8 +201,7 @@ mod tests {
first_h1: None, first_h1: None,
}; };
let settings = PageSettings::default(); let settings = PageSettings::default();
let ctx = let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews: None };
RenderCtx { url: "/x", title: "T", settings: &settings, width: None, mews_hosts: None };
let out = String::from_utf8(Html.render(&doc, &ctx).unwrap().body).unwrap(); let out = String::from_utf8(Html.render(&doc, &ctx).unwrap().body).unwrap();
assert!(out.contains("<body>\n</body>"), "{out}"); assert!(out.contains("<body>\n</body>"), "{out}");
} }
@ -226,8 +224,7 @@ mod deck_tests {
// Card dividers are comment directives, so the IR is built by hand here // Card dividers are comment directives, so the IR is built by hand here
// the way `parse::document` would produce it. // the way `parse::document` would produce it.
let doc = with_card_breaks(markdown); let doc = with_card_breaks(markdown);
let ctx = let ctx = RenderCtx { url: "/trail", title: "Trail", settings, width: None, mews: None };
RenderCtx { url: "/trail", title: "Trail", settings, width: None, mews_hosts: None };
Wml.render(&doc, &ctx).unwrap() Wml.render(&doc, &ctx).unwrap()
} }
@ -488,7 +485,7 @@ mod oracle_dump {
title: "Trail", title: "Trail",
settings: &settings, settings: &settings,
width: None, width: None,
mews_hosts: None, mews: None,
}; };
let out = Wml.render(&doc, &ctx).unwrap(); let out = Wml.render(&doc, &ctx).unwrap();
std::fs::write(format!("/tmp/mine-{name}.wml"), out.body).unwrap(); std::fs::write(format!("/tmp/mine-{name}.wml"), out.body).unwrap();

View file

@ -3,15 +3,18 @@
//! One document serves both: a WAP 2.0 client needs well-formed XML with the //! 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 //! prolog and the Mobile Profile doctype, while a browser parsing the same bytes
//! as `text/html` tolerates the leading `<?xml ...?>` as a bogus comment but //! as `text/html` tolerates the leading `<?xml ...?>` as a bogus comment but
//! logs a warning for it. So the HTML form is the same markup with that one line //! logs a warning for it. So an ordinary page's HTML form is the same markup
//! removed. //! with that one line removed. A Mews page keeps it in both forms: see
//! [`document`].
use itsybitsy_core::config::normalize_host; use itsybitsy_core::config::normalize_host;
use itsybitsy_core::ir::{Block, Doc, Inline}; use itsybitsy_core::ir::{Block, Doc, Inline};
use itsybitsy_core::render::Mews;
use crate::escape; 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 = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"; const PROLOG: &str = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n";
/// The ordinary doctype, used whenever a site has not opted into Mews Profile /// The ordinary doctype, used whenever a site has not opted into Mews Profile
@ -32,44 +35,55 @@ const MEWS_HEAD_EXTRA: &str = "<meta name=\"mews-profile\" content=\"0.1\" />\n\
<link rel=\"stylesheet\" type=\"text/css\" href=\"mews-0.1.css\" />\n"; <link rel=\"stylesheet\" type=\"text/css\" href=\"mews-0.1.css\" />\n";
/// Build the whole document. `prolog` distinguishes the two media types. /// 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` is `Some` when the site has opted into Mews Profile compliance, and
/// Mews Profile compliance, and `None` otherwise. /// `None` otherwise.
pub fn document(doc: &Doc, title: &str, prolog: bool, mews_hosts: Option<&[String]>) -> String { pub fn document(doc: &Doc, title: &str, prolog: bool, mews: Option<Mews<'_>>) -> String {
let mut out = String::new(); 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(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("<html xmlns=\"http://www.w3.org/1999/xhtml\">\n"); out.push_str("<html xmlns=\"http://www.w3.org/1999/xhtml\">\n");
if mews_hosts.is_some() { out.push_str(&format!("<head>\n<title>{}</title>\n", escape::text(title)));
out.push_str(&format!( if mews.is_some() {
"<head>\n<title>{}</title>\n{MEWS_HEAD_EXTRA}</head>\n", out.push_str(MEWS_HEAD_EXTRA);
escape::text(title)
));
} else {
out.push_str(&format!("<head><title>{}</title></head>\n", escape::text(title)));
} }
// 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!(
"<link rel=\"alternate\" type=\"application/atom+xml\" href=\"{}\" title=\"Posts\" />\n",
escape::attr(feed)
));
}
out.push_str("</head>\n");
out.push_str("<body>\n"); out.push_str("<body>\n");
blocks(&doc.blocks, mews_hosts, &mut out); blocks(&doc.blocks, mews, &mut out);
out.push_str("</body>\n</html>\n"); out.push_str("</body>\n</html>\n");
out out
} }
fn blocks(blocks: &[Block], mews_hosts: Option<&[String]>, out: &mut String) { fn blocks(blocks: &[Block], mews: Option<Mews<'_>>, out: &mut String) {
for block in blocks { 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<Mews<'_>>, out: &mut String) {
match block { match block {
Block::Heading { level, inline } => { Block::Heading { level, inline } => {
let level = (*level).clamp(1, 6); let level = (*level).clamp(1, 6);
out.push_str(&format!("<h{level}>{}</h{level}>\n", inlines(inline, mews_hosts))); out.push_str(&format!("<h{level}>{}</h{level}>\n", inlines(inline, mews)));
}
Block::Paragraph(inline) => {
out.push_str(&format!("<p>{}</p>\n", inlines(inline, mews_hosts)))
} }
Block::Paragraph(inline) => out.push_str(&format!("<p>{}</p>\n", inlines(inline, mews))),
Block::CodeBlock { lines, .. } => { Block::CodeBlock { lines, .. } => {
out.push_str("<pre>"); out.push_str("<pre>");
out.push_str(&escape::text(&lines.join("\n"))); 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) => { Block::BlockQuote(inner) => {
out.push_str("<blockquote>\n"); out.push_str("<blockquote>\n");
blocks(inner, mews_hosts, out); blocks(inner, mews, out);
out.push_str("</blockquote>\n"); out.push_str("</blockquote>\n");
} }
Block::List { ordered, start, items } => { Block::List { ordered, start, items } => {
@ -100,10 +114,10 @@ fn self_block(block: &Block, mews_hosts: Option<&[String]>, out: &mut String) {
out.push_str("<li>"); out.push_str("<li>");
// A single paragraph needs no block wrapper inside the item. // A single paragraph needs no block wrapper inside the item.
match item.as_slice() { 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 => { blocks_in_item => {
out.push('\n'); out.push('\n');
blocks(blocks_in_item, mews_hosts, out); blocks(blocks_in_item, mews, out);
} }
} }
out.push_str("</li>\n"); out.push_str("</li>\n");
@ -115,14 +129,14 @@ fn self_block(block: &Block, mews_hosts: Option<&[String]>, out: &mut String) {
if !head.is_empty() { if !head.is_empty() {
out.push_str("<tr>"); out.push_str("<tr>");
for cell in head { for cell in head {
out.push_str(&format!("<th>{}</th>", inlines(cell, mews_hosts))); out.push_str(&format!("<th>{}</th>", inlines(cell, mews)));
} }
out.push_str("</tr>\n"); out.push_str("</tr>\n");
} }
for row in rows { for row in rows {
out.push_str("<tr>"); out.push_str("<tr>");
for cell in row { for cell in row {
out.push_str(&format!("<td>{}</td>", inlines(cell, mews_hosts))); out.push_str(&format!("<td>{}</td>", inlines(cell, mews)));
} }
out.push_str("</tr>\n"); out.push_str("</tr>\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 // cannot make that guarantee — the markup might use an element or
// attribute the profile excludes — so it is dropped instead. // attribute the profile excludes — so it is dropped instead.
Block::Html(html) => { Block::Html(html) => {
if mews_hosts.is_none() { if mews.is_none() {
out.push_str(html.trim_end()); out.push_str(html.trim_end());
out.push('\n'); out.push('\n');
} }
} }
// Alignment reaches only the fixed-width text formats. Pagination is a // Alignment reaches only the fixed-width text formats. Pagination is a
// WML concern; a browser scrolls one document. // 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 // Gates are filtered out before rendering; keeping the content is the
// harmless reading if one ever arrives here. // 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 { .. } => {} Block::CardBreak { .. } => {}
} }
} }
fn inlines(inline: &[Inline], mews_hosts: Option<&[String]>) -> String { fn inlines(inline: &[Inline], mews: Option<Mews<'_>>) -> String {
let mut out = String::new(); let mut out = String::new();
for item in inline { for item in inline {
match item { match item {
Inline::Text(text) => out.push_str(&escape::text(text)), Inline::Text(text) => out.push_str(&escape::text(text)),
Inline::Code(code) => out.push_str(&format!("<code>{}</code>", escape::text(code))), Inline::Code(code) => out.push_str(&format!("<code>{}</code>", escape::text(code))),
Inline::Emph(inner) => { Inline::Emph(inner) => out.push_str(&format!("<em>{}</em>", inlines(inner, mews))),
out.push_str(&format!("<em>{}</em>", inlines(inner, mews_hosts)))
}
Inline::Strong(inner) => { Inline::Strong(inner) => {
out.push_str(&format!("<strong>{}</strong>", inlines(inner, mews_hosts))) out.push_str(&format!("<strong>{}</strong>", inlines(inner, mews)))
} }
// XHTML-MP 1.0 has no <del>, and <strike> is not in the profile, so // XHTML-MP 1.0 has no <del>, and <strike> is not in the profile, so
// the text survives without its markup rather than being dropped. // 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 } => { Inline::Link { href, title, label } => {
let title = title let title = title
.as_deref() .as_deref()
@ -172,14 +184,14 @@ fn inlines(inline: &[Inline], mews_hosts: Option<&[String]>) -> String {
out.push_str(&format!( out.push_str(&format!(
"<a href=\"{}\"{title}>{}</a>", "<a href=\"{}\"{title}>{}</a>",
escape::attr(href), escape::attr(href),
inlines(label, mews_hosts) inlines(label, mews)
)); ));
} }
Inline::Image { src, title, alt } => { Inline::Image { src, title, alt } => {
// A Mews page's image must point at the same site and never be // 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 // a `data:` URI (SPEC.md 4.2); one that does not is dropped to
// its alt text rather than rendered non-conformant. // 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))); out.push_str(&escape::text(&Doc::plain_text(alt)));
continue; continue;
} }
@ -197,7 +209,7 @@ fn inlines(inline: &[Inline], mews_hosts: Option<&[String]>) -> String {
Inline::HardBreak => out.push_str("<br/>\n"), Inline::HardBreak => out.push_str("<br/>\n"),
// See the Block::Html arm above: unverifiable in a Mews page. // See the Block::Html arm above: unverifiable in a Mews page.
Inline::Html(html) => { Inline::Html(html) => {
if mews_hosts.is_none() { if mews.is_none() {
out.push_str(html); out.push_str(html);
} }
} }
@ -242,13 +254,50 @@ mod tests {
Doc { blocks: Vec::new(), first_h1: None } 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] #[test]
fn the_prolog_is_present_only_when_asked_for() { fn an_ordinary_page_carries_the_prolog_only_when_asked_for() {
let doc = empty(); let doc = empty();
assert!(document(&doc, "T", true, None).starts_with("<?xml")); assert!(document(&doc, "T", true, None).starts_with("<?xml"));
assert!(document(&doc, "T", false, None).starts_with("<!DOCTYPE html")); assert!(document(&doc, "T", false, None).starts_with("<!DOCTYPE html"));
} }
#[test]
fn a_mews_page_carries_the_prolog_in_both_forms() {
// The text/html form is what a validator fetching like a browser is
// handed, and 3.1 is a MUST on the bytes it reads.
let hosts = vec!["example.test".to_string()];
for xml in [true, false] {
let out = document(&empty(), "T", xml, Some(mews(&hosts)));
assert!(out.starts_with("<?xml version=\"1.0\" encoding=\"utf-8\"?>"), "{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(
"<link rel=\"alternate\" type=\"application/atom+xml\" \
href=\"https://example.test/feed.xml\" title=\"Posts\" />"
),
"{out}"
);
assert!(
out.split("</head>").next().is_some_and(|head| head.contains("alternate")),
"{out}"
);
let none = document(&empty(), "T", false, Some(mews(&hosts)));
assert!(!none.contains("alternate"), "{none}");
}
#[test] #[test]
fn a_plain_page_keeps_the_1_0_doctype_and_no_mews_head() { fn a_plain_page_keeps_the_1_0_doctype_and_no_mews_head() {
let out = document(&empty(), "T", false, None); let out = document(&empty(), "T", false, None);
@ -259,7 +308,7 @@ mod tests {
#[test] #[test]
fn a_mews_page_carries_the_1_2_doctype_and_the_conformance_marker() { fn a_mews_page_carries_the_1_2_doctype_and_the_conformance_marker() {
let hosts = vec!["example.test".to_string()]; 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("XHTML Mobile 1.2"), "{out}");
assert!(out.contains("<meta name=\"mews-profile\" content=\"0.1\" />"), "{out}"); assert!(out.contains("<meta name=\"mews-profile\" content=\"0.1\" />"), "{out}");
assert!(out.contains("<meta name=\"viewport\" content=\"width=device-width\" />"), "{out}"); assert!(out.contains("<meta name=\"viewport\" content=\"width=device-width\" />"), "{out}");
@ -273,7 +322,7 @@ mod tests {
fn a_mews_page_drops_raw_html_it_cannot_vouch_for() { fn a_mews_page_drops_raw_html_it_cannot_vouch_for() {
let hosts = vec!["example.test".to_string()]; let hosts = vec!["example.test".to_string()];
let doc = Doc { blocks: vec![Block::Html("<div>x</div>".into())], first_h1: None }; let doc = Doc { blocks: vec![Block::Html("<div>x</div>".into())], first_h1: None };
let out = document(&doc, "T", false, Some(&hosts)); let out = document(&doc, "T", false, Some(mews(&hosts)));
assert!(!out.contains("<div>"), "{out}"); assert!(!out.contains("<div>"), "{out}");
let plain = document(&doc, "T", false, None); let plain = document(&doc, "T", false, None);
@ -295,7 +344,7 @@ mod tests {
fn a_mews_page_keeps_a_relative_or_same_site_image() { fn a_mews_page_keeps_a_relative_or_same_site_image() {
let hosts = vec!["example.test".to_string()]; let hosts = vec!["example.test".to_string()];
for src in ["/i.png", "i.png", "https://example.test/i.png", "//img.example.test/i.png"] { 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}"); 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() { fn a_mews_page_drops_an_off_site_or_data_image_to_its_alt_text() {
let hosts = vec!["example.test".to_string()]; let hosts = vec!["example.test".to_string()];
for src in ["https://elsewhere.test/i.png", "data:image/png;base64,AAAA"] { 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("<img"), "{src}: {out}"); assert!(!out.contains("<img"), "{src}: {out}");
assert!(out.contains("a photo"), "{src}: {out}"); assert!(out.contains("a photo"), "{src}: {out}");
} }