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

@ -55,6 +55,13 @@ pub struct SiteSpec {
/// profile's permitted-element list cannot otherwise be guaranteed.
#[serde(default)]
pub mews_profile: bool,
/// The site's Atom feed, declared in the head of its `xhtmlmp`/`html`
/// pages (mews.page/spec 6.2) so a client can subscribe without reading
/// the body. Absolute: a capsule rarely carries the feed its web site
/// publishes, so a root-relative path would point at a document this
/// server has no file for.
#[serde(default)]
pub feed: Option<String>,
}
#[derive(Debug, Deserialize)]
@ -177,21 +184,32 @@ impl ServerConfig {
let hosts = self.build_host_map()?;
let formats = self.validate_listeners(available_formats)?;
let shared_roots = group_shared_roots(&roots);
self.validate_shared_mews_profile(&shared_roots)?;
self.validate_shared_markup(&shared_roots)?;
Ok(Checked { hosts, roots: roots.clone(), shared_roots, formats })
}
/// Sites sharing a root share one [`crate::site::Site`] and so one render
/// cache; a page rendered once there cannot differ by which name served the
/// request, so `mews_profile` must agree within a group.
fn validate_shared_mews_profile(&self, shared_roots: &[Vec<String>]) -> Result<(), Error> {
/// request, so every setting that changes its markup must agree within a
/// 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 {
let mut settings = group.iter().map(|name| self.site[name].mews_profile);
let first = settings.next().unwrap_or(false);
let mut settings = group.iter().map(|name| of(&self.site[name]));
let Some(first) = settings.next() else { continue };
if settings.any(|value| value != first) {
return Err(Error::config(format!(
"sites {} share one root, so mews_profile must agree between them",
"sites {} share one root, so {key} must agree between them",
group.join(", ")
)));
}

View file

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

View file

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

View file

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