feat: let a site render its xhtmlmp/html output as a Mews Profile page
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:
parent
1b47145522
commit
c9bde15f5c
9 changed files with 357 additions and 70 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue