//! 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 { None } fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result; } 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, } pub struct Rendered { /// The body served at [`RenderCtx::url`]. pub body: Vec, /// Extra documents this format addresses under `/`. Empty for /// every format that does not paginate. pub parts: Vec, } impl Rendered { /// A body with no sub-documents, which is every format but WML decks. pub fn body(body: Vec) -> 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, } /// The formats this build provides, keyed by id. #[derive(Default)] pub struct Registry { by_id: BTreeMap<&'static str, Arc>, } 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) -> 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> { 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 { 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>, /// Sub-documents, keyed by format id and slug. A format that does not /// paginate contributes none. pub parts: BTreeMap<(String, String), Vec>, 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, } 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 { self.width } fn render(&self, _doc: &Doc, ctx: &RenderCtx<'_>) -> Result { 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"); } }