feat: serve rendered pages over http, spartan and nex

This commit is contained in:
randogoth 2026-10-04 19:55:21 +03:00
parent 46f12b10da
commit 2f38b8b929
30 changed files with 2896 additions and 90 deletions

View file

@ -15,22 +15,37 @@ use std::time::SystemTime;
use crate::error::Error;
/// The file state a cached value was derived from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
///
/// It can cover several files, because a rendered page depends on its source
/// *and* on its directory's config: editing either has to invalidate it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Stamp {
modified: SystemTime,
len: u64,
/// One entry per file, in the order given. `None` records a file that does
/// not exist, so creating it later still counts as a change.
parts: Vec<Option<(SystemTime, u64)>>,
}
impl Stamp {
/// Stat `path`. Fails if it cannot be read, which is also how a deleted file
/// invalidates its entry.
/// Stamp one file that must exist.
pub fn of(path: &Path) -> Result<Self, Error> {
let meta =
fs::metadata(path).map_err(|cause| Error::Io { path: path.to_path_buf(), cause })?;
let modified =
meta.modified().map_err(|cause| Error::Io { path: path.to_path_buf(), cause })?;
Ok(Stamp { modified, len: meta.len() })
Ok(Stamp { parts: vec![Some(part(path)?)] })
}
/// Stamp a required file plus dependencies that may be absent.
pub fn of_with_deps(path: &Path, deps: &[&Path]) -> Result<Self, Error> {
let mut parts = vec![Some(part(path)?)];
for dep in deps {
parts.push(part(dep).ok());
}
Ok(Stamp { parts })
}
}
fn part(path: &Path) -> Result<(SystemTime, u64), Error> {
let meta = fs::metadata(path).map_err(|cause| Error::Io { path: path.to_path_buf(), cause })?;
let modified =
meta.modified().map_err(|cause| Error::Io { path: path.to_path_buf(), cause })?;
Ok((modified, meta.len()))
}
/// A map from file path to a value derived from that file.
@ -51,15 +66,14 @@ impl<V> StatCache<V> {
/// The cached value for `path`, or `build`'s result, stored for next time.
///
/// `build` is given the stamp the value will be recorded against, so a
/// caller that depends on a second file (a page on its directory's config)
/// can combine stamps without stating the file twice.
/// The caller supplies the stamp, because only it knows which files the
/// value was derived from.
pub fn get_or_insert_with(
&self,
path: &Path,
stamp: Stamp,
build: impl FnOnce() -> Result<V, Error>,
) -> Result<Arc<V>, Error> {
let stamp = Stamp::of(path)?;
if let Some(value) = self.get(path, &stamp) {
return Ok(value);
}
@ -99,8 +113,7 @@ mod tests {
/// enough that two writes in one test can otherwise share a timestamp,
/// which is why the Python's live-reload test does the same thing.
fn bump_mtime(path: &Path) {
let stamp = Stamp::of(path).unwrap();
let later = stamp.modified + Duration::from_secs(5);
let later = SystemTime::now() + Duration::from_secs(5);
File::options()
.write(true)
.open(path)
@ -121,8 +134,8 @@ mod tests {
builds.fetch_add(1, Ordering::Relaxed);
Ok(fs::read_to_string(&path).unwrap())
};
let first = cache.get_or_insert_with(&path, build).unwrap();
let second = cache.get_or_insert_with(&path, build).unwrap();
let first = cache.get_or_insert_with(&path, Stamp::of(&path).unwrap(), build).unwrap();
let second = cache.get_or_insert_with(&path, Stamp::of(&path).unwrap(), build).unwrap();
// Identity, not just equality: the Python asserts `first is second`.
assert!(Arc::ptr_eq(&first, &second));
@ -137,12 +150,18 @@ mod tests {
let cache: StatCache<String> = StatCache::new();
let build = || Ok(fs::read_to_string(&path).unwrap());
assert_eq!(*cache.get_or_insert_with(&path, build).unwrap(), "first");
assert_eq!(
*cache.get_or_insert_with(&path, Stamp::of(&path).unwrap(), build).unwrap(),
"first"
);
fs::write(&path, "second").unwrap();
bump_mtime(&path);
assert_eq!(*cache.get_or_insert_with(&path, build).unwrap(), "second");
assert_eq!(
*cache.get_or_insert_with(&path, Stamp::of(&path).unwrap(), build).unwrap(),
"second"
);
assert_eq!(cache.len(), 1, "the entry is replaced, not added to");
}
@ -155,24 +174,60 @@ mod tests {
let cache: StatCache<String> = StatCache::new();
let build = || Ok(fs::read_to_string(&path).unwrap());
assert_eq!(*cache.get_or_insert_with(&path, build).unwrap(), "aaaaa");
assert_eq!(
*cache.get_or_insert_with(&path, Stamp::of(&path).unwrap(), build).unwrap(),
"aaaaa"
);
fs::write(&path, "bbbbb").unwrap();
bump_mtime(&path);
assert_eq!(*cache.get_or_insert_with(&path, build).unwrap(), "bbbbb");
assert_eq!(
*cache.get_or_insert_with(&path, Stamp::of(&path).unwrap(), build).unwrap(),
"bbbbb"
);
}
#[test]
fn a_missing_file_is_an_error_not_a_stale_value() {
fn a_missing_file_cannot_be_stamped_so_no_stale_value_is_served() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("page.md");
fs::write(&path, "here").unwrap();
let cache: StatCache<String> = StatCache::new();
let build = || Ok(fs::read_to_string(&path).unwrap_or_default());
cache.get_or_insert_with(&path, build).unwrap();
cache.get_or_insert_with(&path, Stamp::of(&path).unwrap(), build).unwrap();
fs::remove_file(&path).unwrap();
assert!(matches!(cache.get_or_insert_with(&path, build), Err(Error::Io { .. })));
assert!(matches!(Stamp::of(&path), Err(Error::Io { .. })));
}
#[test]
fn a_dependency_changing_invalidates_the_value() {
// A rendered page depends on its directory's config as much as on its own
// source, so editing the config has to be a change.
let dir = tempfile::tempdir().unwrap();
let page = dir.path().join("page.md");
let config = dir.path().join("dir.toml");
fs::write(&page, "body").unwrap();
fs::write(&config, "a = 1").unwrap();
let first = Stamp::of_with_deps(&page, &[&config]).unwrap();
fs::write(&config, "a = 2").unwrap();
bump_mtime(&config);
assert_ne!(first, Stamp::of_with_deps(&page, &[&config]).unwrap());
}
#[test]
fn a_dependency_appearing_invalidates_the_value() {
// An absent dependency is recorded as absent rather than skipped, so
// adding a config where there was none counts as a change.
let dir = tempfile::tempdir().unwrap();
let page = dir.path().join("page.md");
let config = dir.path().join("dir.toml");
fs::write(&page, "body").unwrap();
let without = Stamp::of_with_deps(&page, &[&config]).unwrap();
fs::write(&config, "a = 1").unwrap();
assert_ne!(without, Stamp::of_with_deps(&page, &[&config]).unwrap());
}
}

View file

@ -13,6 +13,7 @@ pub mod mime;
pub mod parse;
pub mod path;
pub mod preprocess;
pub mod render;
pub mod site;
pub mod siteset;

279
core/src/render.rs Normal file
View file

@ -0,0 +1,279 @@
//! The output-format contract, and what a rendered page holds.
//!
//! A format is a crate implementing [`Renderer`], registered at startup behind a
//! cargo feature. The trait takes a parsed [`Doc`] rather than source text so
//! that every format reads one parse: gemtext's `=>` link catalogue and the text
//! formats' `[n]` references must agree about link identity and order, and in
//! smolweb they can disagree because each library re-parses.
//!
//! A renderer must not open files or sockets. Includes and art are already
//! resolved by the time it runs, which is what keeps the root-containment check
//! in one place rather than in every format.
use std::collections::BTreeMap;
use std::sync::Arc;
use crate::config::PageSettings;
use crate::error::Error;
use crate::ir::Doc;
/// One output format.
///
/// Instances are built once at startup and shared across every connection, so a
/// renderer holds configuration, never per-document state.
pub trait Renderer: Send + Sync {
/// Stable identifier: the registry key, the `formats` entry in the server
/// config, and the `?format=` query value.
fn id(&self) -> &'static str;
/// Content-Type, charset included, for every body this renderer emits.
fn media_type(&self) -> &'static str;
/// Columns to wrap to. `None` means the client wraps, which is the right
/// answer for gemtext over Gemini and Spartan; the fixed-width formats
/// return a column count because Nex and Gopher clients do not wrap.
fn default_width(&self) -> Option<u16> {
None
}
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error>;
}
pub struct RenderCtx<'a> {
/// Canonical root-relative URL of this document: `/`, `/about`, `/dir/`. A
/// format that addresses sub-documents builds their URLs from this.
pub url: &'a str,
/// The page's title, already resolved through the whole fallback chain, so
/// no renderer repeats it.
pub title: &'a str,
pub settings: &'a PageSettings,
pub width: Option<u16>,
}
pub struct Rendered {
/// The body served at [`RenderCtx::url`].
pub body: Vec<u8>,
/// Extra documents this format addresses under `<url>/<slug>`. Empty for
/// every format that does not paginate.
pub parts: Vec<Part>,
}
impl Rendered {
/// A body with no sub-documents, which is every format but WML decks.
pub fn body(body: Vec<u8>) -> Self {
Rendered { body, parts: Vec::new() }
}
}
pub struct Part {
/// URL-safe and unique within the page; the renderer guarantees both.
pub slug: String,
pub body: Vec<u8>,
}
/// The formats this build provides, keyed by id.
#[derive(Default)]
pub struct Registry {
by_id: BTreeMap<&'static str, Arc<dyn Renderer>>,
}
impl Registry {
pub fn new() -> Self {
Registry::default()
}
/// Errors on a duplicate id, which can only be a wiring mistake.
pub fn insert(&mut self, renderer: Arc<dyn Renderer>) -> Result<(), Error> {
let id = renderer.id();
if self.by_id.insert(id, renderer).is_some() {
return Err(Error::config(format!("two renderers claim the id '{id}'")));
}
Ok(())
}
pub fn get(&self, id: &str) -> Option<&Arc<dyn Renderer>> {
self.by_id.get(id)
}
pub fn ids(&self) -> Vec<&'static str> {
self.by_id.keys().copied().collect()
}
/// Render one document into every format in `formats`.
///
/// Eager, because the result is cached per source file: a page is rendered
/// once per change rather than once per request. The format set is the union
/// of what the enabled listeners can serve, so a site with no HTTP listener
/// never renders HTML.
pub fn page(
&self,
formats: &[String],
doc: &Doc,
url: &str,
settings: &PageSettings,
fallback_title: &str,
) -> Result<Page, Error> {
let title = settings
.title
.clone()
.or_else(|| doc.first_h1.clone())
.unwrap_or_else(|| fallback_title.to_string());
let mut page = Page {
bodies: BTreeMap::new(),
parts: BTreeMap::new(),
settings: settings.clone(),
title,
};
for id in formats {
let renderer = self.get(id).ok_or_else(|| {
// Validation proves this cannot happen at startup; a later
// 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 rendered = renderer.render(doc, &ctx)?;
for part in rendered.parts {
page.parts.insert((id.clone(), part.slug), part.body);
}
page.bodies.insert(id.clone(), rendered.body);
}
Ok(page)
}
}
/// One document, rendered into every format the server can serve it as.
#[derive(Debug)]
pub struct Page {
/// Body per format id.
pub bodies: BTreeMap<String, Vec<u8>>,
/// Sub-documents, keyed by format id and slug. A format that does not
/// paginate contributes none.
pub parts: BTreeMap<(String, String), Vec<u8>>,
pub settings: PageSettings,
/// Resolved title: configured, else the first level-1 heading, else derived
/// from the file name.
pub title: String,
}
impl Page {
pub fn body(&self, format: &str) -> Option<&[u8]> {
self.bodies.get(format).map(Vec::as_slice)
}
pub fn part(&self, format: &str, slug: &str) -> Option<&[u8]> {
self.parts.get(&(format.to_string(), slug.to_string())).map(Vec::as_slice)
}
/// Whether this format addresses any sub-documents of this page.
pub fn has_parts(&self, format: &str) -> bool {
self.parts.keys().any(|(id, _)| id == format)
}
}
/// Turn a file stem into the last-resort title: `my-notes` becomes `my notes`.
pub fn title_from_stem(stem: &str) -> String {
stem.replace(['-', '_'], " ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ir::{Block, Inline};
/// Records what it was asked to render, so the contract can be tested with
/// no real format crate present.
struct Stub {
id: &'static str,
width: Option<u16>,
}
impl Renderer for Stub {
fn id(&self) -> &'static str {
self.id
}
fn media_type(&self) -> &'static str {
"text/plain; charset=utf-8"
}
fn default_width(&self) -> Option<u16> {
self.width
}
fn render(&self, _doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
Ok(Rendered::body(
format!("{} {} {:?} {}", self.id, ctx.url, ctx.width, ctx.title).into_bytes(),
))
}
}
fn registry() -> Registry {
let mut registry = Registry::new();
registry.insert(Arc::new(Stub { id: "one", width: None })).unwrap();
registry.insert(Arc::new(Stub { id: "two", width: Some(80) })).unwrap();
registry
}
fn doc(first_h1: Option<&str>) -> Doc {
Doc {
blocks: vec![Block::Paragraph(vec![Inline::Text("x".into())])],
first_h1: first_h1.map(str::to_string),
}
}
#[test]
fn rejects_two_renderers_claiming_one_id() {
let mut registry = registry();
assert!(registry.insert(Arc::new(Stub { id: "one", width: None })).is_err());
}
#[test]
fn renders_only_the_formats_asked_for() {
let page = registry()
.page(&["one".to_string()], &doc(None), "/x", &PageSettings::default(), "x")
.unwrap();
assert!(page.body("one").is_some());
assert!(page.body("two").is_none(), "a format no listener serves is not rendered");
}
#[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();
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 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");
assert_eq!(from_config.unwrap().title, "Configured");
let from_heading =
reg.page(&formats, &doc(Some("Heading")), "/x", &PageSettings::default(), "stem");
assert_eq!(from_heading.unwrap().title, "Heading");
let from_stem = reg.page(&formats, &doc(None), "/x", &PageSettings::default(), "stem");
assert_eq!(from_stem.unwrap().title, "stem");
}
#[test]
fn an_unknown_format_is_a_bug_not_bad_input() {
let err = registry()
.page(&["absent".to_string()], &doc(None), "/x", &PageSettings::default(), "x")
.unwrap_err();
assert!(err.to_string().contains("no renderer provides"), "{err}");
}
#[test]
fn a_file_stem_becomes_a_readable_title() {
assert_eq!(title_from_stem("my-notes_page"), "my notes page");
}
}

View file

@ -10,11 +10,13 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::cache::StatCache;
use crate::config::{DIR_CONFIG, DirConfig, PageSettings};
use crate::cache::{Stamp, StatCache};
use crate::config::{DIR_CONFIG, DirConfig};
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};
/// 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
@ -32,30 +34,42 @@ pub enum Resolution {
#[derive(Debug)]
pub enum Resource {
/// A Markdown document, to be rendered into the format the caller serves.
Document { url: String, source: PathBuf, settings: PageSettings },
/// A file served byte for byte.
/// A Markdown document, rendered into every format this server serves.
Document { url: String, page: Arc<Page> },
/// A file served byte for byte, streamed rather than buffered.
Raw { path: PathBuf, media_type: &'static str },
}
pub struct Site {
root: PathBuf,
dir_configs: StatCache<DirConfig>,
pages: StatCache<Page>,
/// Shared by every directory without a config of its own, so the common case
/// allocates nothing.
built_in: Arc<DirConfig>,
registry: Arc<Registry>,
/// 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>,
}
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) -> Result<Self, Error> {
pub fn new(root: &Path, registry: Arc<Registry>, formats: Vec<String>) -> Result<Self, Error> {
let root =
root.canonicalize().map_err(|cause| Error::Io { path: root.to_path_buf(), cause })?;
if !root.is_dir() {
return Err(Error::config(format!("{} is not a directory", root.display())));
}
Ok(Site { root, dir_configs: StatCache::new(), built_in: Arc::new(DirConfig::default()) })
Ok(Site {
root,
dir_configs: StatCache::new(),
pages: StatCache::new(),
built_in: Arc::new(DirConfig::default()),
registry,
formats,
})
}
pub fn root(&self) -> &Path {
@ -89,8 +103,8 @@ impl Site {
}
let Some(url) = url_for(&self.root, &source) else { return Ok(Resolution::NotFound) };
let settings = self.settings_for(&source)?;
Ok(Resolution::Found(Resource::Document { url, source, settings }))
let page = self.page(&source, &url)?;
Ok(Resolution::Found(Resource::Document { url, page }))
}
/// Resolve the way [`Site::resolve`] does, but never hand back a redirect.
@ -135,12 +149,23 @@ impl Site {
(resolved.starts_with(&self.root) && resolved.is_file()).then_some(resolved)
}
fn settings_for(&self, source: &Path) -> Result<PageSettings, Error> {
let Some(name) = source.file_name().and_then(|n| n.to_str()) else {
return Ok(PageSettings::default());
};
/// The rendered page for a source file, built on first request and again
/// whenever the file or its directory's config changes.
fn page(&self, source: &Path, url: &str) -> Result<Arc<Page>, Error> {
let dir = source.parent().unwrap_or(&self.root);
Ok(self.dir_config(dir)?.settings_for(name))
let config_path = dir.join(DIR_CONFIG);
// The config is a dependency of the render, not just of the settings, so
// editing it re-renders this page rather than only changing what the next
// fresh render would see.
let stamp = Stamp::of_with_deps(source, &[&config_path])?;
self.pages.get_or_insert_with(source, stamp, || {
let name = source.file_name().and_then(|n| n.to_str()).unwrap_or_default();
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))
})
}
fn dir_config(&self, dir: &Path) -> Result<Arc<DirConfig>, Error> {
@ -148,7 +173,7 @@ impl Site {
if !path.is_file() {
return Ok(self.built_in.clone());
}
self.dir_configs.get_or_insert_with(&path, || DirConfig::load(&path))
self.dir_configs.get_or_insert_with(&path, Stamp::of(&path)?, || DirConfig::load(&path))
}
}
@ -157,6 +182,41 @@ mod tests {
use std::fs;
use super::*;
use crate::config::PageSettings;
use crate::ir::Doc;
use crate::render::{RenderCtx, Rendered, Renderer};
/// Echoes the document's plain text, so a resolution test can assert on what
/// was rendered without depending on a real format crate.
struct Stub;
impl Renderer for Stub {
fn id(&self) -> &'static str {
"stub"
}
fn media_type(&self) -> &'static str {
"text/plain; charset=utf-8"
}
fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
let mut body = format!("title={}\n", ctx.title);
for block in &doc.blocks {
body.push_str(&format!("{block:?}\n"));
}
Ok(Rendered::body(body.into_bytes()))
}
}
fn registry() -> Arc<Registry> {
let mut registry = Registry::new();
registry.insert(Arc::new(Stub)).unwrap();
Arc::new(registry)
}
fn open(root: &Path) -> Site {
Site::new(root, registry(), vec!["stub".to_string()]).unwrap()
}
/// Mirrors smolweb's `tests/conftest.py` fixture, so its assertions port
/// across directly.
@ -169,20 +229,25 @@ mod tests {
fs::create_dir(root.join("dir")).unwrap();
fs::write(root.join("dir/index.md"), "# Nested\n\nNested body.\n").unwrap();
fs::write(root.join(".secret.md"), "# Hidden\n").unwrap();
let site = Site::new(root).unwrap();
let site = open(root);
(dir, site)
}
#[track_caller]
fn document(site: &Site, path: &str) -> (String, PathBuf, PageSettings) {
fn document(site: &Site, path: &str) -> (String, Arc<Page>) {
match site.resolve(path).unwrap() {
Resolution::Found(Resource::Document { url, source, settings }) => {
(url, source, settings)
}
Resolution::Found(Resource::Document { url, page }) => (url, page),
other => panic!("expected a document at {path}, got {other:?}"),
}
}
/// The stub's rendered body, as text.
#[track_caller]
fn rendered(site: &Site, path: &str) -> String {
let (_, page) = document(site, path);
String::from_utf8(page.body("stub").expect("the stub format is rendered").to_vec()).unwrap()
}
/// Force a modification time change: filesystem granularity is coarse
/// enough that two writes in one test can share a timestamp.
fn bump_mtime(path: &Path) {
@ -229,7 +294,7 @@ mod tests {
fs::create_dir(&root).unwrap();
std::os::unix::fs::symlink(outside.join("secret.md"), root.join("escape.md")).unwrap();
let site = Site::new(&root).unwrap();
let site = open(&root);
assert_not_found(&site, "/escape");
// The literal form is refused on the same grounds, not redirected.
assert_not_found(&site, "/escape.md");
@ -245,7 +310,7 @@ mod tests {
fs::create_dir(&root).unwrap();
std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
let site = Site::new(&root).unwrap();
let site = open(&root);
assert_not_found(&site, "/link");
}
@ -270,17 +335,18 @@ mod tests {
#[test]
fn root_serves_index() {
let (dir, site) = fixture();
let (url, source, _) = document(&site, "/");
let (url, _) = document(&site, "/");
assert_eq!(url, "/");
assert_eq!(source, dir.path().canonicalize().unwrap().join("index.md"));
assert!(rendered(&site, "/").contains("Home"));
let _ = dir;
}
#[test]
fn an_extensionless_path_serves_the_md_file() {
let (_dir, site) = fixture();
let (url, source, _) = document(&site, "/about");
let (url, _) = document(&site, "/about");
assert_eq!(url, "/about");
assert_eq!(source.file_name().unwrap(), "about.md");
assert!(rendered(&site, "/about").contains("About"));
}
#[test]
@ -289,9 +355,9 @@ mod tests {
// Both forms resolve to the same page, and neither is redirected to the
// other. That is the behaviour being ported, not an oversight.
for path in ["/dir", "/dir/"] {
let (url, source, _) = document(&site, path);
let (url, _) = document(&site, path);
assert_eq!(url, "/dir/");
assert!(source.ends_with("dir/index.md"), "{source:?}");
assert!(rendered(&site, path).contains("Nested"));
}
}
@ -399,18 +465,19 @@ mod tests {
)
.unwrap();
let (_, _, about) = document(&site, "/about");
assert_eq!(about.title.as_deref(), Some("About Us"));
assert_eq!(about.cache_control, Some(3600));
let (_, about) = document(&site, "/about");
assert_eq!(about.title, "About Us");
assert_eq!(about.settings.cache_control, Some(3600));
// index.md shares the directory defaults but has no title configured.
let (_, _, index) = document(&site, "/");
assert_eq!(index.title, None);
assert_eq!(index.cache_control, Some(3600));
// index.md shares the directory defaults; with no title configured it
// falls back to its own first heading.
let (_, index) = document(&site, "/");
assert_eq!(index.title, "Home");
assert_eq!(index.settings.cache_control, Some(3600));
// The subdirectory does not inherit: it has no config of its own.
let (_, _, nested) = document(&site, "/dir");
assert_eq!(nested, PageSettings::default());
let (_, nested) = document(&site, "/dir");
assert_eq!(nested.settings, PageSettings::default());
}
#[test]
@ -418,12 +485,12 @@ mod tests {
let (dir, site) = fixture();
let path = dir.path().join(DIR_CONFIG);
fs::write(&path, "[defaults]\ncache_control = 60\n").unwrap();
assert_eq!(document(&site, "/about").2.cache_control, Some(60));
assert_eq!(document(&site, "/about").1.settings.cache_control, Some(60));
fs::write(&path, "[defaults]\ncache_control = 120\n").unwrap();
bump_mtime(&path);
assert_eq!(document(&site, "/about").2.cache_control, Some(120));
assert_eq!(document(&site, "/about").1.settings.cache_control, Some(120));
}
#[test]
@ -437,7 +504,8 @@ mod tests {
#[test]
fn a_missing_root_is_rejected_at_construction() {
let dir = tempfile::tempdir().unwrap();
assert!(matches!(Site::new(&dir.path().join("absent")), Err(Error::Io { .. })));
let absent = dir.path().join("absent");
assert!(matches!(Site::new(&absent, registry(), vec![]), Err(Error::Io { .. })));
}
#[test]
@ -445,6 +513,6 @@ 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), Err(Error::Config { .. })));
assert!(matches!(Site::new(&file, registry(), vec![]), Err(Error::Config { .. })));
}
}

View file

@ -10,6 +10,7 @@ use std::sync::Arc;
use crate::config::{ServerConfig, normalize_host};
use crate::error::Error;
use crate::render::Registry;
use crate::site::Site;
pub struct SiteSet {
@ -25,12 +26,19 @@ impl SiteSet {
///
/// Validation has already proved the roots canonicalise and that no host is
/// claimed twice, so the only work left is opening each site once.
pub fn build(config: &ServerConfig) -> Result<Self, Error> {
///
/// `formats` is the union of what the enabled listeners can serve; every page
/// is rendered into exactly that set and no more.
pub fn build(
config: &ServerConfig,
registry: Arc<Registry>,
formats: Vec<String>,
) -> Result<Self, Error> {
let mut set =
SiteSet { sites: Vec::new(), by_host: HashMap::new(), by_name: HashMap::new() };
for (name, spec) in &config.site {
let site = Site::new(&spec.root)?;
let site = Site::new(&spec.root, registry.clone(), formats.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()) {
@ -60,6 +68,12 @@ impl SiteSet {
self.sites.get(*self.by_host.get(&host)?)
}
/// The only site, for a hostless listener in a single-site configuration.
/// Validation refuses to leave the name implicit when there is more than one.
pub fn first(&self) -> Option<&Arc<Site>> {
self.sites.first()
}
/// The site a listener named outright in its configuration.
pub fn by_name(&self, name: &str) -> Option<&Arc<Site>> {
self.sites.get(*self.by_name.get(name)?)
@ -97,7 +111,7 @@ mod tests {
dir.join(second).to_str().unwrap(),
);
let config: ServerConfig = toml::from_str(&text).unwrap();
SiteSet::build(&config).unwrap()
SiteSet::build(&config, Arc::new(Registry::new()), Vec::new()).unwrap()
}
#[test]