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