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
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 { .. })));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue