itsybitsy/core/src/render.rs

279 lines
9.3 KiB
Rust

//! 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");
}
}