feat: serve rendered pages over http, spartan and nex
This commit is contained in:
parent
46f12b10da
commit
2f38b8b929
30 changed files with 2896 additions and 90 deletions
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
279
core/src/render.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
150
core/src/site.rs
150
core/src/site.rs
|
|
@ -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 { .. })));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue