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

@ -48,6 +48,13 @@ pub struct SiteSpec {
/// `default_site`.
#[serde(default)]
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)]
@ -169,13 +176,27 @@ impl ServerConfig {
self.reject_config_inside_root(config_path, &roots)?;
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)?;
Ok(Checked {
hosts,
roots: roots.clone(),
shared_roots: group_shared_roots(&roots),
formats,
})
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<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
@ -915,6 +936,19 @@ mod tests {
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]
fn rejects_a_config_file_inside_a_content_root() {
let dir = tempfile::tempdir().unwrap();

View file

@ -48,6 +48,11 @@ pub struct RenderCtx<'a> {
pub title: &'a str,
pub settings: &'a PageSettings,
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 {
@ -112,6 +117,7 @@ impl Registry {
url: &str,
settings: &PageSettings,
fallback_title: &str,
mews_hosts: Option<&[String]>,
) -> Result<Page, Error> {
let title = settings
.title
@ -131,8 +137,13 @@ impl Registry {
// caller passing an unknown id is a bug, not bad input.
Error::config(format!("no renderer provides the format '{id}'"))
})?;
let ctx =
RenderCtx { url, title: &page.title, settings, width: renderer.default_width() };
let ctx = RenderCtx {
url,
title: &page.title,
settings,
width: renderer.default_width(),
mews_hosts,
};
// Format gates are resolved here, so no renderer meets one and a
// gated run costs nothing extra in the cache: bodies are already
// kept per format.
@ -209,8 +220,11 @@ mod tests {
Ok(Rendered::body(
// The blocks are printed too, so what a format was handed —
// after gates — is visible in the body.
format!("{} {} {:?} {} {:?}", self.id, ctx.url, ctx.width, ctx.title, doc.blocks)
.into_bytes(),
format!(
"{} {} {:?} {} {:?} {:?}",
self.id, ctx.url, ctx.width, ctx.title, doc.blocks, ctx.mews_hosts
)
.into_bytes(),
))
}
}
@ -238,7 +252,7 @@ mod tests {
#[test]
fn renders_only_the_formats_asked_for() {
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();
assert!(page.body("one").is_some());
assert!(page.body("two").is_none(), "a format no listener serves is not rendered");
@ -247,26 +261,44 @@ mod tests {
#[test]
fn each_renderer_gets_its_own_declared_width() {
let formats = vec!["one".to_string(), "two".to_string()];
let page =
registry().page(&formats, &doc(None), "/x", &PageSettings::default(), "x").unwrap();
let page = registry()
.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("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]
fn the_title_falls_back_from_config_to_heading_to_file_name() {
let configured = PageSettings { title: Some("Configured".into()), ..Default::default() };
let formats = vec!["one".to_string()];
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");
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");
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");
}
@ -281,7 +313,8 @@ mod tests {
first_h1: None,
};
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.
assert!(String::from_utf8_lossy(page.body("one").unwrap()).contains("secret"));
assert!(!String::from_utf8_lossy(page.body("two").unwrap()).contains("secret"));
@ -290,7 +323,7 @@ mod tests {
#[test]
fn an_unknown_format_is_a_bug_not_bad_input() {
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();
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
/// listeners can serve, so a site with no HTTP listener never renders HTML.
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 {
/// Open a content root, canonicalising it so containment checks have a
/// 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 =
root.canonicalize().map_err(|cause| Error::Io { path: root.to_path_buf(), cause })?;
if !root.is_dir() {
@ -77,6 +93,8 @@ impl Site {
built_in: Arc::new(DirConfig::default()),
registry,
formats,
mews_profile,
hosts,
})
}
@ -213,7 +231,15 @@ 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();
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> {
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 {
body.push_str(&format!("{block:?}\n"));
}
@ -264,7 +290,7 @@ mod tests {
}
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
@ -550,11 +576,25 @@ mod tests {
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]
fn a_missing_root_is_rejected_at_construction() {
let dir = tempfile::tempdir().unwrap();
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]
@ -562,7 +602,10 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("not-a-dir");
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();
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)
}

View file

@ -38,7 +38,9 @@ impl SiteSet {
SiteSet { sites: Vec::new(), by_host: HashMap::new(), by_name: HashMap::new() };
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
// caches rather than each building their own.
let index = match set.sites.iter().position(|open| open.root() == site.root()) {