feat: declare a Mews page's feed, keep its prolog, and lift link-only list items
This commit is contained in:
parent
3e659df9dd
commit
c08b03db7b
9 changed files with 223 additions and 116 deletions
|
|
@ -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(", ")
|
||||
)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue