feat: render wml decks with paginated card sub-documents

This commit is contained in:
randogoth 2026-10-04 21:04:49 +03:00
parent 36b868e094
commit 1de4674b09
17 changed files with 1519 additions and 5 deletions

View file

@ -357,6 +357,63 @@ fn apply(keys: &PageKeys, settings: &mut PageSettings) -> Result<(), Error> {
if let Some(value) = keys.wrap_code_blocks {
settings.wrap_code_blocks = value;
}
if let Some(value) = keys.split_level {
if value > 6 {
return Err(Error::config(format!("split_level = {value} is above heading level 6")));
}
settings.split_level = value;
}
if let Some(value) = keys.split_on_rule {
settings.split_on_rule = value;
}
if let Some(value) = keys.max_card_bytes {
settings.max_card_bytes = value;
}
if let Some(value) = keys.menu {
settings.menu = value;
}
if let Some(value) = &keys.menu_style {
settings.menu_style = match value.as_str() {
"links" => MenuStyle::Links,
"select" => MenuStyle::Select,
other => {
return Err(Error::config(format!(
"menu_style = '{other}' is not one of links or select"
)));
}
};
}
if let Some(value) = keys.deck_per_card {
settings.deck_per_card = value;
}
if let Some(value) = keys.template_nav {
settings.template_nav = value;
}
for (configured, target) in [
(&keys.nav_next_label, &mut settings.nav_next_label),
(&keys.nav_prev_label, &mut settings.nav_prev_label),
(&keys.nav_back_label, &mut settings.nav_back_label),
] {
if let Some(value) = configured {
*target = value.clone();
}
}
if let Some(value) = &keys.home_label {
// An empty label means no softkey, rather than one with no text on it.
settings.home_label = (!value.is_empty()).then(|| value.clone());
}
if let Some(value) = &keys.images {
settings.images = match value.as_str() {
"keep" => Images::Keep,
"alt" => Images::Alt,
"drop" => Images::Drop,
other => {
return Err(Error::config(format!(
"images = '{other}' is not one of keep, alt or drop"
)));
}
};
}
let levels = [
&keys.h1_style,
@ -480,6 +537,49 @@ pub struct PageKeys {
/// Wrap over-long code lines. Off by default, because wrapping code changes
/// what it says; on, it is better than losing the end of the line.
pub wrap_code_blocks: Option<bool>,
// WML decks. Only the WML format reads these; it is the one output that
// paginates, because a WAP 1.x handset has a per-card byte budget.
/// Heading level that starts a new card, or 0 to disable. Ignored when the
/// document carries explicit card dividers.
pub split_level: Option<u8>,
/// Treat a thematic break as a card divider.
pub split_on_rule: Option<bool>,
/// Bytes one card may occupy, navigation included, or 0 for no limit.
pub max_card_bytes: Option<u32>,
/// Hub-and-spoke with a menu card, rather than cards chained in sequence.
pub menu: Option<bool>,
/// `links` for one link per line, `select` for a keypad-pickable list.
pub menu_style: Option<String>,
/// Give each card its own deck, addressable under the page's own URL.
pub deck_per_card: Option<bool>,
/// Hoist navigation shared by every card into a `<template>`.
pub template_nav: Option<bool>,
pub nav_next_label: Option<String>,
pub nav_prev_label: Option<String>,
pub nav_back_label: Option<String>,
/// Adds an options softkey returning to the menu card.
pub home_label: Option<String>,
/// `keep`, `alt` or `drop`.
pub images: Option<String>,
}
/// How a menu card lists the cards it links to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MenuStyle {
/// One link per line.
Links,
/// A `<select>`, which a numeric keypad can pick from directly.
Select,
}
/// What to do with an image in a format that may not be able to show one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Images {
Keep,
/// Replace it with its alt text.
Alt,
Drop,
}
/// How a heading is decorated in the fixed-width formats.
@ -557,6 +657,18 @@ pub struct PageSettings {
pub hyphenate: bool,
pub hyphen_lang: String,
pub wrap_code_blocks: bool,
pub split_level: u8,
pub split_on_rule: bool,
pub max_card_bytes: u32,
pub menu: bool,
pub menu_style: MenuStyle,
pub deck_per_card: bool,
pub template_nav: bool,
pub nav_next_label: String,
pub nav_prev_label: String,
pub nav_back_label: String,
pub home_label: Option<String>,
pub images: Images,
}
impl Default for PageSettings {
@ -580,6 +692,21 @@ impl Default for PageSettings {
hyphenate: false,
hyphen_lang: "en-us".to_string(),
wrap_code_blocks: false,
// Explicit dividers and thematic breaks split a deck; heading-based
// splitting is opt-in, since most pages are not written as screens.
split_level: 0,
split_on_rule: true,
// The classic WAP 1.x deck budget.
max_card_bytes: 1400,
menu: true,
menu_style: MenuStyle::Links,
deck_per_card: false,
template_nav: true,
nav_next_label: "More".to_string(),
nav_prev_label: "Prev".to_string(),
nav_back_label: "Back".to_string(),
home_label: None,
images: Images::Keep,
}
}
}

View file

@ -38,6 +38,14 @@ pub enum Resource {
Document { url: String, page: Arc<Page> },
/// A file served byte for byte, streamed rather than buffered.
Raw { path: PathBuf, media_type: &'static str },
/// A sub-document of a page, addressed under the page's own URL.
///
/// Only a paginating format produces these, so whether this slug exists
/// depends on which format the caller serves. A caller that cannot serve it
/// redirects to `parent_url` instead of substituting something else, which
/// keeps the rule format-agnostic: core does not know which formats
/// paginate.
Part { parent_url: String, slug: String, page: Arc<Page> },
}
pub struct Site {
@ -86,7 +94,7 @@ impl Site {
/// | `/img.png` | the file itself, by media type |
pub fn resolve(&self, url_path: &str) -> Result<Resolution, Error> {
let Some(clean) = clean_path(url_path) else { return Ok(Resolution::NotFound) };
let Some(source) = self.file_for(&clean) else { return Ok(Resolution::NotFound) };
let Some(source) = self.file_for(&clean) else { return self.part_for(&clean) };
// `/foo.md` always redirects to its extensionless form, so one
// root-relative link works identically from every protocol.
@ -107,6 +115,44 @@ impl Site {
Ok(Resolution::Found(Resource::Document { url, page }))
}
/// Interpret the last path segment as a sub-document of its parent page.
///
/// A real file or directory always wins over this reading, which is why it is
/// only tried once resolution has otherwise failed. Requiring an actual
/// separator matters: splitting a bare top-level segment would leave an empty
/// parent, and that would make every unresolvable top-level path look like a
/// sub-document of the root index.
fn part_for(&self, clean: &str) -> Result<Resolution, Error> {
let Some((parent, slug)) = clean.rsplit_once('/') else { return Ok(Resolution::NotFound) };
if parent.is_empty() || slug.is_empty() {
return Ok(Resolution::NotFound);
}
let Some(source) = self.file_for(parent) else { return Ok(Resolution::NotFound) };
if source.extension().and_then(|e| e.to_str()) != Some("md") {
return Ok(Resolution::NotFound);
}
let Some(parent_url) = url_for(&self.root, &source) else {
return Ok(Resolution::NotFound);
};
let page = self.page(&source, &parent_url)?;
// A page that addresses no sub-documents at all has no such URL space:
// `/about/whatever` is not a sub-document just because `/about` exists.
if page.parts.is_empty() {
return Ok(Resolution::NotFound);
}
if page.parts.keys().any(|(_, known)| known == slug) {
return Ok(Resolution::Found(Resource::Part {
parent_url,
slug: slug.to_string(),
page,
}));
}
// The URL space exists here, just not under this name, so the parent is
// the right place to send the caller.
Ok(Resolution::Redirect(parent_url))
}
/// Resolve the way [`Site::resolve`] does, but never hand back a redirect.
///
/// Nex and Gopher have no redirect status, so there is nothing to bounce a
@ -117,6 +163,9 @@ impl Site {
for _ in 0..MAX_FLAT_HOPS {
match self.resolve(&target)? {
Resolution::Redirect(location) => target = location,
// A sub-document belongs to a format this caller does not serve,
// so its parent is what there is to send back.
Resolution::Found(Resource::Part { parent_url, .. }) => target = parent_url,
settled => return Ok(settled),
}
}
@ -516,3 +565,144 @@ mod tests {
assert!(matches!(Site::new(&file, registry(), vec![]), Err(Error::Config { .. })));
}
}
#[cfg(test)]
mod part_tests {
//! Ported from smolweb's `TestWmlCardUrls`. A stub renderer stands in for a
//! paginating format, so these rules are tested without the WML crate: core
//! does not know which formats paginate, which is the point.
use std::fs;
use super::*;
use crate::ir::Doc;
use crate::render::{Part, RenderCtx, Rendered, Renderer};
/// Addresses one sub-document per `<!-- card -->` divider in the source.
struct Paginating;
impl Renderer for Paginating {
fn id(&self) -> &'static str {
"paginating"
}
fn media_type(&self) -> &'static str {
"text/plain; charset=utf-8"
}
fn render(&self, doc: &Doc, _ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
let parts = doc
.blocks
.iter()
.filter_map(|block| match block {
crate::ir::Block::CardBreak { title } => title.as_deref(),
_ => None,
})
.map(|title| Part {
slug: title.to_ascii_lowercase(),
body: format!("card {title}").into_bytes(),
})
.collect();
Ok(Rendered { body: b"whole".to_vec(), parts })
}
}
fn fixture() -> (tempfile::TempDir, Site) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::write(root.join("index.md"), "# Home\n").unwrap();
// Paginates: two dividers, so two sub-documents.
fs::write(
root.join("trail.md"),
"Intro.\n\n<!-- card Weather -->\n\nCold.\n\n<!-- card Status -->\n\nOpen.\n",
)
.unwrap();
// Does not paginate: no dividers, so no sub-document URL space at all.
fs::write(root.join("about.md"), "# About\n\nBody.\n").unwrap();
let mut registry = Registry::new();
registry.insert(Arc::new(Paginating)).unwrap();
let site = Site::new(root, Arc::new(registry), vec!["paginating".to_string()]).unwrap();
(dir, site)
}
#[track_caller]
fn part(site: &Site, path: &str) -> (String, String, Arc<Page>) {
match site.resolve(path).unwrap() {
Resolution::Found(Resource::Part { parent_url, slug, page }) => {
(parent_url, slug, page)
}
other => panic!("expected a sub-document at {path}, got {other:?}"),
}
}
#[test]
fn a_sub_path_resolves_to_the_pages_sub_document() {
let (_dir, site) = fixture();
let (parent_url, slug, page) = part(&site, "/trail/weather");
assert_eq!(parent_url, "/trail");
assert_eq!(slug, "weather");
assert_eq!(page.part("paginating", "weather"), Some(b"card Weather".as_slice()));
}
#[test]
fn an_unknown_sub_path_redirects_to_the_parent() {
// The URL space exists here, just not under that name.
let (_dir, site) = fixture();
match site.resolve("/trail/nonexistent").unwrap() {
Resolution::Redirect(location) => assert_eq!(location, "/trail"),
other => panic!("expected a redirect, got {other:?}"),
}
}
#[test]
fn a_sub_path_of_a_page_that_does_not_paginate_is_not_found() {
// about.md has no dividers, so it addresses nothing: `/about/whatever` is
// not a sub-document just because `/about` exists.
let (_dir, site) = fixture();
assert!(matches!(site.resolve("/about/whatever").unwrap(), Resolution::NotFound));
}
#[test]
fn a_bare_unresolvable_segment_is_not_a_sub_document_of_the_root() {
// The regression this guards: splitting a bare top-level segment leaves an
// empty parent, which would resolve to the root index.
let (_dir, site) = fixture();
assert!(matches!(site.resolve("/totally-unresolvable").unwrap(), Resolution::NotFound));
}
#[test]
fn a_real_file_wins_over_the_sub_document_reading() {
let (dir, site) = fixture();
fs::create_dir(dir.path().join("trail")).unwrap();
fs::write(dir.path().join("trail/weather.md"), "# Real file\n").unwrap();
match site.resolve("/trail/weather").unwrap() {
Resolution::Found(Resource::Document { url, .. }) => {
assert_eq!(url, "/trail/weather")
}
other => panic!("expected the real file, got {other:?}"),
}
}
#[test]
fn resolve_flat_lands_on_the_parent_rather_than_the_sub_document() {
// Nex and Gopher have no redirect status and serve one format, so a
// sub-document they cannot address has to resolve to real content.
let (_dir, site) = fixture();
for path in ["/trail/weather", "/trail/nonexistent"] {
match site.resolve_flat(path).unwrap() {
Resolution::Found(Resource::Document { url, .. }) => {
assert_eq!(url, "/trail", "for {path}");
}
other => panic!("expected the parent document for {path}, got {other:?}"),
}
}
}
#[test]
fn a_deeper_sub_path_is_not_found() {
let (_dir, site) = fixture();
assert!(matches!(site.resolve("/trail/weather/more").unwrap(), Resolution::NotFound));
}
}