feat: let a site render its xhtmlmp/html output as a Mews Profile page
All checks were successful
CI / check (push) Successful in 48s
CI / release (push) Successful in 6m48s

A new mews_profile site setting switches the XHTML-MP 1.2 doctype, the
conformance marker, the viewport tag and the default stylesheet link, and
drops raw HTML passthrough and off-site or data: images that the profile's
permitted-element list cannot vouch for.
This commit is contained in:
randogoth 2026-10-11 08:22:52 +03:00
parent 1b47145522
commit c9bde15f5c
9 changed files with 357 additions and 70 deletions

View file

@ -42,6 +42,8 @@ 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. Sites sharing one root must agree on this setting, since they share one render cache.
### Formats and protocols ### Formats and protocols
| Format | Served as | Behind feature | | Format | Served as | Behind feature |

View file

@ -48,6 +48,13 @@ pub struct SiteSpec {
/// `default_site`. /// `default_site`.
#[serde(default)] #[serde(default)]
pub hosts: Vec<String>, pub hosts: Vec<String>,
/// Render this site's `xhtmlmp`/`html` output as a Mews Profile page
/// (mews.page/spec): 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, since the
/// profile's permitted-element list cannot otherwise be guaranteed.
#[serde(default)]
pub mews_profile: bool,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -169,13 +176,27 @@ impl ServerConfig {
self.reject_config_inside_root(config_path, &roots)?; self.reject_config_inside_root(config_path, &roots)?;
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);
self.validate_shared_mews_profile(&shared_roots)?;
Ok(Checked { Ok(Checked { hosts, roots: roots.clone(), shared_roots, formats })
hosts, }
roots: roots.clone(),
shared_roots: group_shared_roots(&roots), /// Sites sharing a root share one [`crate::site::Site`] and so one render
formats, /// 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<String>]) -> 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);
if settings.any(|value| value != first) {
return Err(Error::config(format!(
"sites {} share one root, so mews_profile must agree between them",
group.join(", ")
)));
}
}
Ok(())
} }
/// Canonicalise every content root, which also proves it exists and is a /// Canonicalise every content root, which also proves it exists and is a
@ -915,6 +936,19 @@ mod tests {
assert_eq!(checked.shared_roots, vec![vec!["one".to_string(), "two".to_string()]]); assert_eq!(checked.shared_roots, vec![vec!["one".to_string(), "two".to_string()]]);
} }
#[test]
fn rejects_sites_sharing_one_root_with_differing_mews_profile() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("content");
fs::create_dir_all(&root).unwrap();
let two = format!(
"{HTTP}\n[site.two]\nroot = {:?}\nhosts = [\"two.test\"]\nmews_profile = true\n",
root.to_str().unwrap()
);
let err = check(dir.path(), &two).unwrap_err();
assert!(err.to_string().contains("mews_profile must agree"), "{err}");
}
#[test] #[test]
fn rejects_a_config_file_inside_a_content_root() { fn rejects_a_config_file_inside_a_content_root() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();

View file

@ -48,6 +48,11 @@ 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
/// 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]>,
} }
pub struct Rendered { pub struct Rendered {
@ -112,6 +117,7 @@ impl Registry {
url: &str, url: &str,
settings: &PageSettings, settings: &PageSettings,
fallback_title: &str, fallback_title: &str,
mews_hosts: Option<&[String]>,
) -> Result<Page, Error> { ) -> Result<Page, Error> {
let title = settings let title = settings
.title .title
@ -131,8 +137,13 @@ impl Registry {
// caller passing an unknown id is a bug, not bad input. // caller passing an unknown id is a bug, not bad input.
Error::config(format!("no renderer provides the format '{id}'")) Error::config(format!("no renderer provides the format '{id}'"))
})?; })?;
let ctx = let ctx = RenderCtx {
RenderCtx { url, title: &page.title, settings, width: renderer.default_width() }; url,
title: &page.title,
settings,
width: renderer.default_width(),
mews_hosts,
};
// 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
// kept per format. // kept per format.
@ -209,7 +220,10 @@ mod tests {
Ok(Rendered::body( Ok(Rendered::body(
// The blocks are printed too, so what a format was handed — // The blocks are printed too, so what a format was handed —
// after gates — is visible in the body. // after gates — is visible in the body.
format!("{} {} {:?} {} {:?}", self.id, ctx.url, ctx.width, ctx.title, doc.blocks) format!(
"{} {} {:?} {} {:?} {:?}",
self.id, ctx.url, ctx.width, ctx.title, doc.blocks, ctx.mews_hosts
)
.into_bytes(), .into_bytes(),
)) ))
} }
@ -238,7 +252,7 @@ mod tests {
#[test] #[test]
fn renders_only_the_formats_asked_for() { fn renders_only_the_formats_asked_for() {
let page = registry() let page = registry()
.page(&["one".to_string()], &doc(None), "/x", &PageSettings::default(), "x") .page(&["one".to_string()], &doc(None), "/x", &PageSettings::default(), "x", None)
.unwrap(); .unwrap();
assert!(page.body("one").is_some()); assert!(page.body("one").is_some());
assert!(page.body("two").is_none(), "a format no listener serves is not rendered"); assert!(page.body("two").is_none(), "a format no listener serves is not rendered");
@ -247,26 +261,44 @@ mod tests {
#[test] #[test]
fn each_renderer_gets_its_own_declared_width() { fn each_renderer_gets_its_own_declared_width() {
let formats = vec!["one".to_string(), "two".to_string()]; let formats = vec!["one".to_string(), "two".to_string()];
let page = let page = registry()
registry().page(&formats, &doc(None), "/x", &PageSettings::default(), "x").unwrap(); .page(&formats, &doc(None), "/x", &PageSettings::default(), "x", None)
.unwrap();
assert!(String::from_utf8_lossy(page.body("one").unwrap()).contains("None")); assert!(String::from_utf8_lossy(page.body("one").unwrap()).contains("None"));
assert!(String::from_utf8_lossy(page.body("two").unwrap()).contains("Some(80)")); assert!(String::from_utf8_lossy(page.body("two").unwrap()).contains("Some(80)"));
} }
#[test]
fn mews_hosts_reaches_the_renderer_when_the_site_opted_in() {
let formats = vec!["one".to_string()];
let hosts = vec!["example.test".to_string()];
let page = registry()
.page(&formats, &doc(None), "/x", &PageSettings::default(), "x", Some(&hosts))
.unwrap();
assert!(String::from_utf8_lossy(page.body("one").unwrap()).contains("example.test"));
let unset = registry()
.page(&formats, &doc(None), "/x", &PageSettings::default(), "x", None)
.unwrap();
assert!(String::from_utf8_lossy(unset.body("one").unwrap()).contains("None"));
}
#[test] #[test]
fn the_title_falls_back_from_config_to_heading_to_file_name() { fn the_title_falls_back_from_config_to_heading_to_file_name() {
let configured = PageSettings { title: Some("Configured".into()), ..Default::default() }; let configured = PageSettings { title: Some("Configured".into()), ..Default::default() };
let formats = vec!["one".to_string()]; let formats = vec!["one".to_string()];
let reg = registry(); let reg = registry();
let from_config = reg.page(&formats, &doc(Some("Heading")), "/x", &configured, "stem"); let from_config =
reg.page(&formats, &doc(Some("Heading")), "/x", &configured, "stem", None);
assert_eq!(from_config.unwrap().title, "Configured"); assert_eq!(from_config.unwrap().title, "Configured");
let from_heading = let from_heading =
reg.page(&formats, &doc(Some("Heading")), "/x", &PageSettings::default(), "stem"); reg.page(&formats, &doc(Some("Heading")), "/x", &PageSettings::default(), "stem", None);
assert_eq!(from_heading.unwrap().title, "Heading"); assert_eq!(from_heading.unwrap().title, "Heading");
let from_stem = reg.page(&formats, &doc(None), "/x", &PageSettings::default(), "stem"); let from_stem =
reg.page(&formats, &doc(None), "/x", &PageSettings::default(), "stem", None);
assert_eq!(from_stem.unwrap().title, "stem"); assert_eq!(from_stem.unwrap().title, "stem");
} }
@ -281,7 +313,8 @@ mod tests {
first_h1: None, first_h1: None,
}; };
let formats = vec!["one".to_string(), "two".to_string()]; let formats = vec!["one".to_string(), "two".to_string()];
let page = registry().page(&formats, &gated, "/x", &PageSettings::default(), "x").unwrap(); let page =
registry().page(&formats, &gated, "/x", &PageSettings::default(), "x", None).unwrap();
// The stub prints the blocks it was handed, so presence is visible. // The stub prints the blocks it was handed, so presence is visible.
assert!(String::from_utf8_lossy(page.body("one").unwrap()).contains("secret")); assert!(String::from_utf8_lossy(page.body("one").unwrap()).contains("secret"));
assert!(!String::from_utf8_lossy(page.body("two").unwrap()).contains("secret")); assert!(!String::from_utf8_lossy(page.body("two").unwrap()).contains("secret"));
@ -290,7 +323,7 @@ mod tests {
#[test] #[test]
fn an_unknown_format_is_a_bug_not_bad_input() { fn an_unknown_format_is_a_bug_not_bad_input() {
let err = registry() let err = registry()
.page(&["absent".to_string()], &doc(None), "/x", &PageSettings::default(), "x") .page(&["absent".to_string()], &doc(None), "/x", &PageSettings::default(), "x", None)
.unwrap_err(); .unwrap_err();
assert!(err.to_string().contains("no renderer provides"), "{err}"); assert!(err.to_string().contains("no renderer provides"), "{err}");
} }

View file

@ -59,12 +59,28 @@ pub struct Site {
/// Formats every page here is rendered into: the union of what the enabled /// Formats every page here is rendered into: the union of what the enabled
/// listeners can serve, so a site with no HTTP listener never renders HTML. /// listeners can serve, so a site with no HTTP listener never renders HTML.
formats: Vec<String>, formats: Vec<String>,
/// Whether this site's `xhtmlmp`/`html` output is rendered as Mews Profile
/// pages (`SiteSpec::mews_profile`). Validation requires every site sharing
/// this root to agree, so one value is correct for all of them.
mews_profile: bool,
/// This site's own hostnames, used to judge an image `src` as same-site
/// when `mews_profile` is set. Taken from whichever configured site name
/// opened this root first; a root reached under several names with
/// differing host lists keeps only that one's, which is an accepted
/// simplification rather than a union of them all.
hosts: Vec<String>,
} }
impl Site { impl Site {
/// Open a content root, canonicalising it so containment checks have a /// Open a content root, canonicalising it so containment checks have a
/// stable base and a missing root fails now rather than per request. /// stable base and a missing root fails now rather than per request.
pub fn new(root: &Path, registry: Arc<Registry>, formats: Vec<String>) -> Result<Self, Error> { pub fn new(
root: &Path,
registry: Arc<Registry>,
formats: Vec<String>,
mews_profile: bool,
hosts: Vec<String>,
) -> 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 })?;
if !root.is_dir() { if !root.is_dir() {
@ -77,6 +93,8 @@ impl Site {
built_in: Arc::new(DirConfig::default()), built_in: Arc::new(DirConfig::default()),
registry, registry,
formats, formats,
mews_profile,
hosts,
}) })
} }
@ -213,7 +231,15 @@ 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();
self.registry.page(&self.formats, &doc, url, &settings, &title_from_stem(stem)) 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,
)
}) })
} }
@ -249,7 +275,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={}\n", ctx.title); let mut body = format!("title={}\nmews_hosts={:?}\n", ctx.title, ctx.mews_hosts);
for block in &doc.blocks { for block in &doc.blocks {
body.push_str(&format!("{block:?}\n")); body.push_str(&format!("{block:?}\n"));
} }
@ -264,7 +290,7 @@ mod tests {
} }
fn open(root: &Path) -> Site { fn open(root: &Path) -> Site {
Site::new(root, registry(), vec!["stub".to_string()]).unwrap() Site::new(root, registry(), vec!["stub".to_string()], false, Vec::new()).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
@ -550,11 +576,25 @@ mod tests {
assert!(matches!(site.resolve("/about"), Err(Error::Toml { .. }))); assert!(matches!(site.resolve("/about"), Err(Error::Toml { .. })));
} }
#[test]
fn mews_profile_and_hosts_reach_the_render_context() {
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();
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}");
}
#[test] #[test]
fn a_missing_root_is_rejected_at_construction() { fn a_missing_root_is_rejected_at_construction() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let absent = dir.path().join("absent"); let absent = dir.path().join("absent");
assert!(matches!(Site::new(&absent, registry(), vec![]), Err(Error::Io { .. }))); assert!(matches!(
Site::new(&absent, registry(), vec![], false, Vec::new()),
Err(Error::Io { .. })
));
} }
#[test] #[test]
@ -562,7 +602,10 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
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!(Site::new(&file, registry(), vec![]), Err(Error::Config { .. }))); assert!(matches!(
Site::new(&file, registry(), vec![], false, Vec::new()),
Err(Error::Config { .. })
));
} }
} }
@ -622,7 +665,9 @@ 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 = Site::new(root, Arc::new(registry), vec!["paginating".to_string()]).unwrap(); let site =
Site::new(root, Arc::new(registry), vec!["paginating".to_string()], false, Vec::new())
.unwrap();
(dir, site) (dir, site)
} }

View file

@ -38,7 +38,9 @@ impl SiteSet {
SiteSet { sites: Vec::new(), by_host: HashMap::new(), by_name: HashMap::new() }; SiteSet { sites: Vec::new(), by_host: HashMap::new(), by_name: HashMap::new() };
for (name, spec) in &config.site { for (name, spec) in &config.site {
let site = Site::new(&spec.root, registry.clone(), formats.clone())?; 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)?;
// 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

@ -295,7 +295,8 @@ 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 = RenderCtx { url: "/x", title: "T", settings: &settings, width: None }; let ctx =
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()
} }
@ -398,7 +399,8 @@ 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 = RenderCtx { url: "/x", title: "T", settings: &settings, width: None }; let ctx =
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,7 +58,8 @@ 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 = RenderCtx { url: "/x", title: "T", settings, width: Some(width) }; let ctx =
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()
} }
@ -213,7 +214,8 @@ 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 = RenderCtx { url: "/x", title: "T", settings, width: Some(width) }; let ctx =
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()
} }
@ -267,7 +269,13 @@ 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 { url: "/x", title: "T", settings: &settings, width: Some(40) }; let ctx = RenderCtx {
url: "/x",
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()
} }
@ -299,7 +307,8 @@ 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 = RenderCtx { url: "/x", title: "T", settings, width: Some(width) }; let ctx =
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

@ -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).into_bytes())) Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, true, ctx.mews_hosts).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).into_bytes())) Ok(Rendered::body(xhtmlmp::document(doc, ctx.title, false, ctx.mews_hosts).into_bytes()))
} }
} }
@ -82,7 +82,13 @@ mod tests {
fn render(renderer: &dyn Renderer, markdown: &str) -> String { fn render(renderer: &dyn Renderer, 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 = RenderCtx { url: "/x", title: "The Title", settings: &settings, width: None }; let ctx = RenderCtx {
url: "/x",
title: "The Title",
settings: &settings,
width: None,
mews_hosts: None,
};
String::from_utf8(renderer.render(&doc, &ctx).unwrap().body).unwrap() String::from_utf8(renderer.render(&doc, &ctx).unwrap().body).unwrap()
} }
@ -178,7 +184,8 @@ mod tests {
first_h1: None, first_h1: None,
}; };
let settings = PageSettings::default(); let settings = PageSettings::default();
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None }; let ctx =
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}");
} }
@ -195,7 +202,8 @@ mod tests {
first_h1: None, first_h1: None,
}; };
let settings = PageSettings::default(); let settings = PageSettings::default();
let ctx = RenderCtx { url: "/x", title: "T", settings: &settings, width: None }; let ctx =
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}");
} }
@ -218,7 +226,8 @@ 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 = RenderCtx { url: "/trail", title: "Trail", settings, width: None }; let ctx =
RenderCtx { url: "/trail", title: "Trail", settings, width: None, mews_hosts: None };
Wml.render(&doc, &ctx).unwrap() Wml.render(&doc, &ctx).unwrap()
} }
@ -474,7 +483,13 @@ mod oracle_dump {
]; ];
for (name, settings) in cases { for (name, settings) in cases {
let doc = with_card_breaks(TRAIL); let doc = with_card_breaks(TRAIL);
let ctx = RenderCtx { url: "/trail", title: "Trail", settings: &settings, width: None }; let ctx = RenderCtx {
url: "/trail",
title: "Trail",
settings: &settings,
width: None,
mews_hosts: 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

@ -6,6 +6,7 @@
//! logs a warning for it. So the HTML form is the same markup with that one line //! logs a warning for it. So the HTML form is the same markup with that one line
//! removed. //! removed.
use itsybitsy_core::config::normalize_host;
use itsybitsy_core::ir::{Block, Doc, Inline}; use itsybitsy_core::ir::{Block, Doc, Inline};
use crate::escape; use crate::escape;
@ -13,37 +14,62 @@ use crate::escape;
/// The XML declaration, which only the XHTML-MP form carries. /// The XML declaration, which only the XHTML-MP form carries.
const PROLOG: &str = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"; const PROLOG: &str = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n";
const DOCTYPE: &str = "<!DOCTYPE html PUBLIC \"-//WAPFORUM//DTD XHTML Mobile 1.0//EN\" \ /// The ordinary doctype, used whenever a site has not opted into Mews Profile
/// compliance.
const DOCTYPE_1_0: &str = "<!DOCTYPE html PUBLIC \"-//WAPFORUM//DTD XHTML Mobile 1.0//EN\" \
\"http://www.wapforum.org/DTD/xhtml-mobile10.dtd\">\n"; \"http://www.wapforum.org/DTD/xhtml-mobile10.dtd\">\n";
/// Mews Profile pages carry the XHTML-MP 1.2 doctype instead (mews.page/spec
/// 3.1), which is what a Mews DTD or validator checks a page against.
const DOCTYPE_1_2: &str = "<!DOCTYPE html PUBLIC \"-//WAPFORUM//DTD XHTML Mobile 1.2//EN\" \
\"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd\">\n";
/// The conformance marker (3.2), viewport tag (3.3) and default stylesheet
/// link (5.2), pinned to spec version 0.1: a site opts into the profile as a
/// whole, not into picking its own version or stylesheet path.
const MEWS_HEAD_EXTRA: &str = "<meta name=\"mews-profile\" content=\"0.1\" />\n\
<meta name=\"viewport\" content=\"width=device-width\" />\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.
pub fn document(doc: &Doc, title: &str, prolog: bool) -> String { /// `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 {
let mut out = String::new(); let mut out = String::new();
if prolog { if prolog {
out.push_str(PROLOG); out.push_str(PROLOG);
} }
out.push_str(DOCTYPE); out.push_str(if mews_hosts.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{MEWS_HEAD_EXTRA}</head>\n",
escape::text(title)
));
} else {
out.push_str(&format!("<head><title>{}</title></head>\n", escape::text(title))); out.push_str(&format!("<head><title>{}</title></head>\n", escape::text(title)));
}
out.push_str("<body>\n"); out.push_str("<body>\n");
blocks(&doc.blocks, &mut out); blocks(&doc.blocks, mews_hosts, &mut out);
out.push_str("</body>\n</html>\n"); out.push_str("</body>\n</html>\n");
out out
} }
fn blocks(blocks: &[Block], out: &mut String) { fn blocks(blocks: &[Block], mews_hosts: Option<&[String]>, out: &mut String) {
for block in blocks { for block in blocks {
self_block(block, out); self_block(block, mews_hosts, out);
} }
} }
fn self_block(block: &Block, out: &mut String) { fn self_block(block: &Block, mews_hosts: Option<&[String]>, 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))); out.push_str(&format!("<h{level}>{}</h{level}>\n", inlines(inline, mews_hosts)));
}
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))),
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")));
@ -58,7 +84,7 @@ fn self_block(block: &Block, out: &mut String) {
} }
Block::BlockQuote(inner) => { Block::BlockQuote(inner) => {
out.push_str("<blockquote>\n"); out.push_str("<blockquote>\n");
blocks(inner, out); blocks(inner, mews_hosts, out);
out.push_str("</blockquote>\n"); out.push_str("</blockquote>\n");
} }
Block::List { ordered, start, items } => { Block::List { ordered, start, items } => {
@ -74,10 +100,10 @@ fn self_block(block: &Block, 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)), [Block::Paragraph(inline)] => out.push_str(&inlines(inline, mews_hosts)),
blocks_in_item => { blocks_in_item => {
out.push('\n'); out.push('\n');
blocks(blocks_in_item, out); blocks(blocks_in_item, mews_hosts, out);
} }
} }
out.push_str("</li>\n"); out.push_str("</li>\n");
@ -89,47 +115,55 @@ fn self_block(block: &Block, 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))); out.push_str(&format!("<th>{}</th>", inlines(cell, mews_hosts)));
} }
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))); out.push_str(&format!("<td>{}</td>", inlines(cell, mews_hosts)));
} }
out.push_str("</tr>\n"); out.push_str("</tr>\n");
} }
out.push_str("</table>\n"); out.push_str("</table>\n");
} }
Block::Rule => out.push_str("<hr/>\n"), Block::Rule => out.push_str("<hr/>\n"),
// Raw HTML is passed through: this is the one family of formats where it // Raw HTML is ordinarily passed through, since this is the one family
// is already in the right language. // of formats where it is already in the right language. A Mews page
// cannot make that guarantee — the markup might use an element or
// attribute the profile excludes — so it is dropped instead.
Block::Html(html) => { Block::Html(html) => {
if mews_hosts.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, out), Block::Aligned { block, .. } => self_block(block, mews_hosts, 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, out), Block::Gated { block, .. } => self_block(block, mews_hosts, out),
Block::CardBreak { .. } => {} Block::CardBreak { .. } => {}
} }
} }
fn inlines(inline: &[Inline]) -> String { fn inlines(inline: &[Inline], mews_hosts: Option<&[String]>) -> 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) => out.push_str(&format!("<em>{}</em>", inlines(inner))), Inline::Emph(inner) => {
Inline::Strong(inner) => out.push_str(&format!("<strong>{}</strong>", inlines(inner))), out.push_str(&format!("<em>{}</em>", inlines(inner, mews_hosts)))
}
Inline::Strong(inner) => {
out.push_str(&format!("<strong>{}</strong>", inlines(inner, mews_hosts)))
}
// 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)), Inline::Strike(inner) => out.push_str(&inlines(inner, mews_hosts)),
Inline::Link { href, title, label } => { Inline::Link { href, title, label } => {
let title = title let title = title
.as_deref() .as_deref()
@ -138,10 +172,17 @@ fn inlines(inline: &[Inline]) -> 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) inlines(label, mews_hosts)
)); ));
} }
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 `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)) {
out.push_str(&escape::text(&Doc::plain_text(alt)));
continue;
}
let title = title let title = title
.as_deref() .as_deref()
.map(|t| format!(" title=\"{}\"", escape::attr(t))) .map(|t| format!(" title=\"{}\"", escape::attr(t)))
@ -154,20 +195,124 @@ fn inlines(inline: &[Inline]) -> String {
} }
Inline::SoftBreak => out.push('\n'), Inline::SoftBreak => out.push('\n'),
Inline::HardBreak => out.push_str("<br/>\n"), Inline::HardBreak => out.push_str("<br/>\n"),
Inline::Html(html) => out.push_str(html), // See the Block::Html arm above: unverifiable in a Mews page.
Inline::Html(html) => {
if mews_hosts.is_none() {
out.push_str(html);
}
}
} }
} }
out out
} }
/// Whether an image `src` satisfies the Mews same-site rule (SPEC.md 4.2). A
/// `data:` URI never qualifies. A relative or root-relative URL always does,
/// since it resolves against the page's own origin by definition. An absolute
/// or protocol-relative URL qualifies when its host equals one of the site's
/// own hostnames or is a subdomain of one — a looser stand-in for "the same
/// registered domain" that needs no public-suffix list, adequate for hosts
/// the site itself configured.
fn same_site_image(src: &str, hosts: &[String]) -> bool {
if src.len() >= 5 && src.as_bytes()[..5].eq_ignore_ascii_case(b"data:") {
return false;
}
let Some(raw_host) = url_host(src) else { return true };
let Some(host) = normalize_host(raw_host) else { return false };
hosts.iter().any(|allowed| host == *allowed || host.ends_with(&format!(".{allowed}")))
}
/// The host component of an absolute (`scheme://host/...`) or
/// protocol-relative (`//host/...`) URL, or `None` for anything else: a bare
/// or root-relative path has no host of its own, so it always resolves
/// against the page's own site.
fn url_host(src: &str) -> Option<&str> {
let rest = match src.split_once("://") {
Some((_scheme, rest)) => rest,
None => src.strip_prefix("//")?,
};
Some(rest.split(['/', '?', '#']).next().unwrap_or(rest))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
fn empty() -> Doc {
Doc { blocks: Vec::new(), first_h1: None }
}
#[test] #[test]
fn the_prolog_is_present_only_when_asked_for() { fn the_prolog_is_present_only_when_asked_for() {
let doc = Doc { blocks: Vec::new(), first_h1: None }; let doc = empty();
assert!(document(&doc, "T", true).starts_with("<?xml")); assert!(document(&doc, "T", true, None).starts_with("<?xml"));
assert!(document(&doc, "T", false).starts_with("<!DOCTYPE html")); assert!(document(&doc, "T", false, None).starts_with("<!DOCTYPE html"));
}
#[test]
fn a_plain_page_keeps_the_1_0_doctype_and_no_mews_head() {
let out = document(&empty(), "T", false, None);
assert!(out.contains("XHTML Mobile 1.0"), "{out}");
assert!(!out.contains("mews-profile"), "{out}");
}
#[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));
assert!(out.contains("XHTML Mobile 1.2"), "{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("<link rel=\"stylesheet\" type=\"text/css\" href=\"mews-0.1.css\" />"),
"{out}"
);
}
#[test]
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("<div>x</div>".into())], first_h1: None };
let out = document(&doc, "T", false, Some(&hosts));
assert!(!out.contains("<div>"), "{out}");
let plain = document(&doc, "T", false, None);
assert!(plain.contains("<div>x</div>"), "{plain}");
}
fn image_doc(src: &str) -> Doc {
Doc {
blocks: vec![Block::Paragraph(vec![Inline::Image {
src: src.into(),
title: None,
alt: vec![Inline::Text("a photo".into())],
}])],
first_h1: None,
}
}
#[test]
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));
assert!(out.contains(&format!("src=\"{src}\"")), "{src}: {out}");
}
}
#[test]
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));
assert!(!out.contains("<img"), "{src}: {out}");
assert!(out.contains("a photo"), "{src}: {out}");
}
}
#[test]
fn a_plain_page_keeps_every_image_regardless_of_its_host() {
let out = document(&image_doc("https://elsewhere.test/i.png"), "T", false, None);
assert!(out.contains("<img src=\"https://elsewhere.test/i.png\""), "{out}");
} }
} }