feat: render wml decks with paginated card sub-documents
This commit is contained in:
parent
36b868e094
commit
1de4674b09
17 changed files with 1519 additions and 5 deletions
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
192
core/src/site.rs
192
core/src/site.rs
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue