From dd24e065b4cf6372d6adb0792077317acbf0b21a Mon Sep 17 00:00:00 2001 From: Lachlan Kermode Date: Tue, 21 Jul 2026 12:44:00 +0200 Subject: [PATCH 1/6] Exposes full #set document(...) metadata on spine entries Adds a DocumentMetadata AST extractor that harvests every representable named argument of the first #set document(...) rule generically into a dict, mirroring DocumentDate's syntax-tree approach rather than string-scanning. keywords round-trips as an array of strings; bracket-content titles flatten to plain text. Exposes the result as a metadata field on every rheo-context spine and spine-flat entry, alongside handle/path/title. Extends TypstLiteral with Int/Float so scalar custom fields round-trip. --- CLAUDE.md | 5 +- crates/core/src/parser/document_metadata.rs | 258 ++++++++++++++++++++ crates/core/src/parser/mod.rs | 7 + crates/core/src/reticulate/spine.rs | 82 ++++++- crates/core/src/util/typst_literal.rs | 8 + 5 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 crates/core/src/parser/document_metadata.rs diff --git a/CLAUDE.md b/CLAUDE.md index e32a0d1c..dec46092 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,8 +111,9 @@ rheo injects a per-vertebra Typst binding `rheo-context()` into every spine file Fields (the returned dictionary may gain fields later): - `handle` — this file's `:`-separated handle (its ID; the same handle used for the cross-file links above). The only per-file field. -- `spine` — the structured spine **tree**, mirroring directory/section nesting. Each node is a dict `(title, handle, path, children)`: a leaf (a vertebra) carries its own `handle`/`path`/`title`; a group node (a directory or `[[spine.section]]` with no landing file) carries `handle: none`, `path: none`, and its own group title, nesting its children. -- `spine-flat` — the flat pre-order list of every *clickable* vertebra (groups excluded), each an entry `(handle, path, title)`. +- `spine` — the structured spine **tree**, mirroring directory/section nesting. Each node is a dict `(title, handle, path, metadata, children)`: a leaf (a vertebra) carries its own `handle`/`path`/`title`/`metadata`; a group node (a directory or `[[spine.section]]` with no landing file) carries `handle: none`, `path: none`, an empty `metadata: (:)`, and its own group title, nesting its children. +- `spine-flat` — the flat pre-order list of every *clickable* vertebra (groups excluded), each an entry `(handle, path, title, metadata)`. +- `metadata` (per entry) — every representable named argument of that vertebra's `#set document(...)` rule, harvested generically into a dict: e.g. `entry.metadata.at("keywords", default: ())` or `entry.metadata.at("author", default: none)`. Strings (including bracket-content, flattened to plain text), booleans, integers, floats, and arrays thereof round-trip; non-literal arguments like `date: datetime(...)` are omitted (the document date has its own handling for the feed). An entry with no `#set document(...)` has an empty `metadata: (:)`. - `target` — the rheo output-format name (`"epub"`/`"html"`/…). Present only for formats that set one; **absent for PDF**, where documents fall back to Typst's native `target()` == `"paged"`. - `ext` — the output file extension (`"html"`/`"xhtml"`), gated like `target` (present for html/epub, absent for PDF). The value core reads to build depth-relative cross-vertebra link hrefs. diff --git a/crates/core/src/parser/document_metadata.rs b/crates/core/src/parser/document_metadata.rs new file mode 100644 index 00000000..25dcac6a --- /dev/null +++ b/crates/core/src/parser/document_metadata.rs @@ -0,0 +1,258 @@ +//! Extractor: every named argument of `#set document(...)`. + +use super::{SyntaxSite, WalkCtx}; +use crate::util::typst_literal::TypstLiteral; +use typst::syntax::{Source, SyntaxKind, SyntaxNode, ast, ast::AstNode}; + +/// A value harvested from a `#set document(...)` named argument. +/// +/// Covers the literal shapes a static AST walk can faithfully round-trip back +/// into a Typst value: strings (including bracket-content, flattened to plain +/// text), booleans, integers, floats, and arrays thereof. Non-literal arguments +/// (e.g. `date: datetime(...)`, which is a function call) are not representable +/// here and are dropped by [`MetaValue::from_expr`]; the document date has its +/// own dedicated extractor, [`DocumentDate`](super::DocumentDate). +#[derive(Debug, Clone, PartialEq)] +pub enum MetaValue { + /// A string literal, or the plain text of a bracket-content value. + Str(String), + /// A boolean literal (`true`/`false`). + Bool(bool), + /// An integer literal. + Int(i64), + /// A floating-point literal. + Float(f64), + /// An array literal, e.g. `("DiH", "MiT")` for `keywords`. + Array(Vec), +} + +impl MetaValue { + /// The inner string if this is a [`MetaValue::Str`], else `None`. + pub fn as_str(&self) -> Option<&str> { + match self { + MetaValue::Str(s) => Some(s), + _ => None, + } + } + + /// Convert one argument expression into a [`MetaValue`], returning `None` for + /// any expression kind that is not a faithfully-representable literal (e.g. a + /// `datetime(...)` call or an identifier). + fn from_expr(expr: ast::Expr) -> Option { + match expr { + ast::Expr::Str(s) => Some(MetaValue::Str(s.get().to_string())), + ast::Expr::Bool(b) => Some(MetaValue::Bool(b.get())), + ast::Expr::Int(i) => Some(MetaValue::Int(i.get())), + ast::Expr::Float(f) => Some(MetaValue::Float(f.get())), + // Bracket-content (`title: [My Title]`) flattens to its plain text, + // dropping markup markers so a spine/feed title is clean text. + ast::Expr::ContentBlock(c) => { + Some(MetaValue::Str(markup_plain_text(c.body().to_untyped()))) + } + ast::Expr::Array(a) => Some(MetaValue::Array( + a.items() + .filter_map(|item| match item { + ast::ArrayItem::Pos(e) => MetaValue::from_expr(e), + _ => None, + }) + .collect(), + )), + _ => None, + } + } + + /// Render this value as a [`TypstLiteral`], for injection into the spine's + /// `metadata` dict. + pub fn to_literal(&self) -> TypstLiteral { + match self { + MetaValue::Str(s) => TypstLiteral::str(s.as_str()), + MetaValue::Bool(b) => TypstLiteral::bool(*b), + MetaValue::Int(i) => TypstLiteral::Int(*i), + MetaValue::Float(f) => TypstLiteral::Float(*f), + MetaValue::Array(items) => { + TypstLiteral::Array(items.iter().map(MetaValue::to_literal).collect()) + } + } + } +} + +/// All named arguments of the first `#set document(...)` rule in a vertebra, +/// captured generically as `(name, value)` pairs in source order. +/// +/// A [`SyntaxSite`] capped at one site: the first such rule in the tree, read +/// via `DocumentMetadata::first(source)`. Only literal-valued arguments are +/// retained (see [`MetaValue`]); an argument whose value cannot be represented +/// as a literal is silently skipped rather than erroring, so an ordinary +/// `date: datetime(...)` argument does not abort the harvest. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct DocumentMetadata(pub Vec<(String, MetaValue)>); + +impl SyntaxSite for DocumentMetadata { + const MAX_SITES: Option = Some(1); + + /// Match a `set` rule targeting `document` and capture each of its named + /// arguments whose value is a representable literal. + fn visit( + _source: &Source, + node: &SyntaxNode, + _offset: usize, + _ctx: WalkCtx, + out: &mut Vec, + ) { + if let Some(set_rule) = node.cast::() + && let ast::Expr::Ident(target) = set_rule.target() + && target.as_str() == "document" + { + let fields = set_rule + .args() + .items() + .filter_map(|item| match item { + ast::Arg::Named(named) => MetaValue::from_expr(named.expr()) + .map(|v| (named.name().as_str().to_string(), v)), + _ => None, + }) + .collect(); + out.push(DocumentMetadata(fields)); + } + } +} + +impl DocumentMetadata { + /// The value of a named argument (e.g. `title`), if present. + pub fn get(&self, name: &str) -> Option<&MetaValue> { + self.0.iter().find(|(k, _)| k == name).map(|(_, v)| v) + } + + /// Serialize the captured metadata to a [`TypstLiteral`] dictionary for the + /// spine's `metadata` field. Empty metadata serializes to `(:)`. + pub fn to_literal(&self) -> TypstLiteral { + TypstLiteral::Dict( + self.0 + .iter() + .map(|(k, v)| (k.clone(), v.to_literal())) + .collect(), + ) + } +} + +/// Flatten a markup subtree to its plain text: concatenate every `Text`/`Space` +/// leaf, dropping markup markers (emphasis underscores, `#strong[...]`, brackets) +/// so `[Good news - #emph[Severance]]` becomes `Good news - Severance`. +fn markup_plain_text(node: &SyntaxNode) -> String { + let mut out = String::new(); + collect_text(node, &mut out); + out.trim().to_string() +} + +/// Append the text of every `Text`/`Space` leaf under `node`, in order. +fn collect_text(node: &SyntaxNode, out: &mut String) { + match node.kind() { + SyntaxKind::Text | SyntaxKind::Space => out.push_str(node.leaf_text()), + _ => { + for child in node.children() { + collect_text(child, out); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn metadata(src: &str) -> DocumentMetadata { + DocumentMetadata::first(&Source::detached(src)).unwrap_or_default() + } + + #[test] + fn test_string_title() { + let m = metadata(r#"#set document(title: "My Post")"#); + assert_eq!(m.get("title").and_then(MetaValue::as_str), Some("My Post")); + } + + #[test] + fn test_bracket_title_flattens_to_plain_text() { + let m = metadata(r#"#set document(title: [Good news - #emph[Severance]])"#); + assert_eq!( + m.get("title").and_then(MetaValue::as_str), + Some("Good news - Severance") + ); + } + + #[test] + fn test_keywords_round_trip_as_array_of_strings() { + let m = metadata(r#"#set document(title: [X], keywords: ("DiH", "MiT"))"#); + assert_eq!( + m.get("keywords"), + Some(&MetaValue::Array(vec![ + MetaValue::Str("DiH".into()), + MetaValue::Str("MiT".into()), + ])) + ); + // Serializes to a Typst array literal. + assert_eq!( + m.get("keywords").unwrap().to_literal().serialize(), + r#"("DiH", "MiT",)"# + ); + } + + #[test] + fn test_single_keyword_trailing_comma() { + let m = metadata(r#"#set document(keywords: ("DiH",))"#); + assert_eq!( + m.get("keywords"), + Some(&MetaValue::Array(vec![MetaValue::Str("DiH".into())])) + ); + } + + #[test] + fn test_int_and_bool_and_float_args() { + let m = metadata(r#"#set document(reading-time: 5, draft: true, ratio: 1.5)"#); + assert_eq!(m.get("reading-time"), Some(&MetaValue::Int(5))); + assert_eq!(m.get("draft"), Some(&MetaValue::Bool(true))); + assert_eq!(m.get("ratio"), Some(&MetaValue::Float(1.5))); + } + + #[test] + fn test_datetime_arg_skipped_not_errored() { + // `date: datetime(...)` is a function call, not a literal → dropped, but + // the other representable args are still harvested. + let m = metadata( + r#"#set document(title: [T], date: datetime(year: 2025, month: 1, day: 2), keywords: ("A",))"#, + ); + assert!(m.get("date").is_none()); + assert_eq!(m.get("title").and_then(MetaValue::as_str), Some("T")); + assert!(m.get("keywords").is_some()); + } + + #[test] + fn test_no_document_rule_is_empty() { + let m = metadata("= Heading\n\nBody text."); + assert!(m.0.is_empty()); + } + + #[test] + fn test_first_document_rule_only() { + // MAX_SITES = 1: only the first `#set document(...)` is captured. + let m = metadata( + r#"#set document(title: [First]) +#set document(title: [Second])"#, + ); + assert_eq!(m.get("title").and_then(MetaValue::as_str), Some("First")); + } + + #[test] + fn test_ignores_other_set_rules() { + let m = metadata( + r#"#set page(width: 10cm) +#set document(title: [Doc], keywords: ("K",))"#, + ); + assert_eq!(m.get("title").and_then(MetaValue::as_str), Some("Doc")); + } + + #[test] + fn test_empty_metadata_serializes_to_empty_dict() { + let m = metadata("= Heading"); + assert_eq!(m.to_literal().serialize(), "(:)"); + } +} diff --git a/crates/core/src/parser/mod.rs b/crates/core/src/parser/mod.rs index 8402e4d1..b09274eb 100644 --- a/crates/core/src/parser/mod.rs +++ b/crates/core/src/parser/mod.rs @@ -18,12 +18,14 @@ //! enforced by `extract_nodes_parses_and_traverses_once`. mod document_date; +mod document_metadata; mod imports; mod labels; mod rheo_var; mod syntax_site; pub use document_date::DocumentDate; +pub use document_metadata::{DocumentMetadata, MetaValue}; pub use imports::ImportInfo; pub use labels::{LabelRole, LabelSite, LabelSites}; pub use rheo_var::{RheoValue, RheoVar}; @@ -41,6 +43,8 @@ pub struct ExtractedNodes { pub labels: LabelSites, /// Parsed `#set document(date: datetime(...))` timestamp, if present. pub document_date: Option, + /// All representable named arguments of the first `#set document(...)` rule. + pub document_metadata: DocumentMetadata, } /// Harvest labels, `rheo-*` bindings, and the document date from `source` in a @@ -55,10 +59,12 @@ pub fn extract_nodes(source: &Source) -> ExtractedNodes { let mut labels = Vec::new(); let mut rheo_vars = Vec::new(); let mut dates = Vec::new(); + let mut metadata = Vec::new(); syntax_site::walk_once(source, &root, |s, n, o, c| { LabelSite::visit(s, n, o, c, &mut labels); RheoVar::visit(s, n, o, c, &mut rheo_vars); DocumentDate::visit(s, n, o, c, &mut dates); + DocumentMetadata::visit(s, n, o, c, &mut metadata); }); let mut label_sites = LabelSites::default(); for site in labels { @@ -71,6 +77,7 @@ pub fn extract_nodes(source: &Source) -> ExtractedNodes { rheo_vars, labels: label_sites, document_date: dates.into_iter().next(), + document_metadata: metadata.into_iter().next().unwrap_or_default(), } } diff --git a/crates/core/src/reticulate/spine.rs b/crates/core/src/reticulate/spine.rs index 6afed93c..406b8bf0 100644 --- a/crates/core/src/reticulate/spine.rs +++ b/crates/core/src/reticulate/spine.rs @@ -1,6 +1,6 @@ use crate::config::SpineSection; use crate::parser; -use crate::parser::{DocumentDate, RheoValue}; +use crate::parser::{DocumentDate, DocumentMetadata, RheoValue}; use crate::reticulate::bundle_source::BundleSource; use crate::util::path::{sanitize_handle_segment, to_forward_slash}; use crate::util::pdf::DocumentTitle; @@ -530,6 +530,9 @@ pub struct Vertebra { pub title: String, /// Parsed `#set document(date: datetime(...))` timestamp, if present. pub date: Option, + /// All representable named arguments of this vertebra's `#set document(...)` + /// rule, exposed to Typst as the spine entry's `metadata` field. + pub metadata: DocumentMetadata, /// Harvested `rheo-*` variables from this vertebra's source file. pub vars: std::collections::HashMap, /// The vertebra's raw source text, retained for the Mould stage. @@ -663,6 +666,7 @@ impl VirtualSpine { rel_path: String, title: String, date: Option, + metadata: DocumentMetadata, vars: HashMap, source: String, } @@ -703,6 +707,7 @@ impl VirtualSpine { let source_obj = Source::detached(&source); let extracted = parser::extract_nodes(&source_obj); let date = extracted.document_date; + let metadata = extracted.document_metadata; let sites = extracted.labels; let mut vars = HashMap::new(); for v in extracted.rheo_vars { @@ -731,6 +736,7 @@ impl VirtualSpine { rel_path, title, date, + metadata, vars, source, }) @@ -769,6 +775,7 @@ impl VirtualSpine { emit_handle, title: fi.title, date: fi.date, + metadata: fi.metadata, vars: fi.vars, source: fi.source, }) @@ -867,24 +874,29 @@ impl VirtualSpine { /// Serialize one [`SpineNode`] (and its descendants) to its `spine` dict shape. fn node_literal(&self, node: &SpineNode) -> TypstLiteral { - let (handle, path, title) = match node.vertebra.and_then(|i| self.vertebrae.get(i)) { - Some(v) => ( - TypstLiteral::str(v.handle.as_str()), - TypstLiteral::str(v.rel_path.as_str()), - TypstLiteral::str(v.title.as_str()), - ), - None => ( - TypstLiteral::None, - TypstLiteral::None, - TypstLiteral::str(node.title.as_deref().unwrap_or(node.segment.as_str())), - ), - }; + let (handle, path, title, metadata) = + match node.vertebra.and_then(|i| self.vertebrae.get(i)) { + Some(v) => ( + TypstLiteral::str(v.handle.as_str()), + TypstLiteral::str(v.rel_path.as_str()), + TypstLiteral::str(v.title.as_str()), + v.metadata.to_literal(), + ), + None => ( + TypstLiteral::None, + TypstLiteral::None, + TypstLiteral::str(node.title.as_deref().unwrap_or(node.segment.as_str())), + // Group nodes have no source document, so no metadata. + TypstLiteral::Dict(vec![]), + ), + }; let children = TypstLiteral::Array(node.children.iter().map(|c| self.node_literal(c)).collect()); TypstLiteral::Dict(vec![ ("title".to_string(), title), ("handle".to_string(), handle), ("path".to_string(), path), + ("metadata".to_string(), metadata), ("children".to_string(), children), ]) } @@ -901,6 +913,7 @@ impl VirtualSpine { ("handle".to_string(), TypstLiteral::str(v.handle.as_str())), ("path".to_string(), TypstLiteral::str(v.rel_path.as_str())), ("title".to_string(), TypstLiteral::str(v.title.as_str())), + ("metadata".to_string(), v.metadata.to_literal()), ]) }) .collect(), @@ -1188,6 +1201,44 @@ mod tests { assert!(!flat.contains("title: \"Chapters\"")); } + #[test] + fn spine_exposes_document_metadata_on_entries() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let content = root.join("content"); + fs::create_dir_all(&content).unwrap(); + // A post whose `#set document(...)` carries keywords (tags) and an author. + fs::write( + content.join("post.typ"), + "#set document(title: [My Post], keywords: (\"DiH\",), author: \"Jane\")\n= Body\n", + ) + .unwrap(); + // A page with no `#set document(...)`: metadata is an empty dict. + fs::write(content.join("bare.typ"), "= Bare\n").unwrap(); + + let scan = SpineScan::run(&content, &[]).unwrap(); + let layout = SpineLayout::OnePerVertebra { + ext: "html".into(), + format: "html".into(), + }; + let spine = VirtualSpine::build(scan, root, layout).unwrap(); + + // The metadata dict is exposed on both spine (tree) and spine-flat entries. + for serialized in [ + spine.spine_tree().serialize(), + spine.spine_flat().serialize(), + ] { + assert!( + serialized.contains( + "metadata: (title: \"My Post\", keywords: (\"DiH\",), author: \"Jane\")" + ), + "metadata dict missing/instructed wrong: {serialized}" + ); + // The bare page still carries a `metadata` key, as an empty dict. + assert!(serialized.contains("metadata: (:)")); + } + } + #[test] fn rheo_context_target_and_ext_present_when_some_absent_when_none() { let tmp = TempDir::new().unwrap(); @@ -1270,6 +1321,7 @@ mod tests { emit_handle: true, title: "Introduction".into(), date: None, + metadata: DocumentMetadata::default(), vars: HashMap::new(), source: String::new(), }; @@ -1305,6 +1357,7 @@ mod tests { emit_handle: true, title: "A".into(), date: None, + metadata: DocumentMetadata::default(), vars: HashMap::new(), source: String::new(), }, @@ -1316,6 +1369,7 @@ mod tests { emit_handle: true, title: "B".into(), date: None, + metadata: DocumentMetadata::default(), vars: HashMap::new(), source: String::new(), }, @@ -1353,6 +1407,7 @@ mod tests { emit_handle: true, title: "A".into(), date: None, + metadata: DocumentMetadata::default(), vars: Default::default(), source: String::new(), }, @@ -1364,6 +1419,7 @@ mod tests { emit_handle: true, title: "B".into(), date: None, + metadata: DocumentMetadata::default(), vars: Default::default(), source: String::new(), }, diff --git a/crates/core/src/util/typst_literal.rs b/crates/core/src/util/typst_literal.rs index 82fcf4f7..1537ffc2 100644 --- a/crates/core/src/util/typst_literal.rs +++ b/crates/core/src/util/typst_literal.rs @@ -15,6 +15,10 @@ pub enum TypstLiteral { Str(String), /// A boolean literal (`true`/`false`). Bool(bool), + /// An integer literal. + Int(i64), + /// A floating-point literal. + Float(f64), /// The `none` literal. None, /// An array literal, e.g. `(a, b,)`. @@ -43,6 +47,8 @@ impl TypstLiteral { match self { TypstLiteral::Str(s) => serialize_string(s), TypstLiteral::Bool(b) => b.to_string(), + TypstLiteral::Int(i) => i.to_string(), + TypstLiteral::Float(f) => f.to_string(), TypstLiteral::None => "none".to_string(), TypstLiteral::Array(items) if items.is_empty() => "()".to_string(), TypstLiteral::Array(items) => { @@ -67,6 +73,8 @@ impl TypstLiteral { match self { TypstLiteral::Str(s) => Value::Str(s.as_str().into()), TypstLiteral::Bool(b) => Value::Bool(*b), + TypstLiteral::Int(i) => Value::Int(*i), + TypstLiteral::Float(f) => Value::Float(*f), TypstLiteral::None => Value::None, TypstLiteral::Array(items) => { Value::Array(items.iter().map(TypstLiteral::to_value).collect::()) From 5ba6f6a5951fce832564a7f019dffb49e70027ea Mon Sep 17 00:00:00 2001 From: Lachlan Kermode Date: Tue, 21 Jul 2026 12:56:21 +0200 Subject: [PATCH 2/6] Documents spine metadata inference limitation Records that #set document(...) metadata is harvested by a pre-compile AST scan of each vertebra's own source, so template/module-set metadata (applied via a show rule) is missed even though Typst applies it. Adds docs/limitations.md. --- docs/limitations.md | 57 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/limitations.md diff --git a/docs/limitations.md b/docs/limitations.md new file mode 100644 index 00000000..fb794470 --- /dev/null +++ b/docs/limitations.md @@ -0,0 +1,57 @@ +# Spine inference + +## `#set document(...)` metadata is harvested by a pre-compile AST scan + +rheo reads each vertebra's `title`, `date`, and generic `metadata` (every named +argument of `#set document(...)`) by statically parsing the vertebra's **own +source text** before compilation — `DocumentMetadata`/`DocumentDate` in +`crates/core/src/parser/`. This happens pre-compile by necessity: the spine +(handles, titles, `@handle` display text, nav order, Atom feed) is an *input* to +the bundle source rheo generates, so it must exist before Typst runs. + +Because it is a static scan of literal syntax — not evaluation — it only sees a +`#set document(...)` rule written literally in the vertebra file itself. Typst, +however, applies document set rules no matter where they are evaluated: set rules +propagate up to the document element regardless of nesting. Verified with typst +0.15.0 — all of these produce the given compiled document title, but only the +last is visible to rheo's harvest: + +| How the title is set | Compiled `document.title` | Harvested by rheo? | +| --- | --- | --- | +| In an imported module fn, applied via `#show: book` | `FromModule` | **No** | +| Inside `#show: doc => { set document(...); doc }` | `FromShow` | **No** | +| In a code block *in the same file* — `#{ set document(...) }` | `FromCodeBlock` | Yes | +| Literal top-level — `#set document(title: "…")` | `Literal` | Yes | + +### What this means + +A vertebra that outsources its metadata to a shared template — e.g. + +```typst +#import "template.typ": book +#show: book // book(doc) internally derives + calls `set document(title: …)` +``` + +compiles to output with the correct title, but rheo's spine/feed will fall back +to the filename-cased title (and miss any template-set `keywords`/`author`), +because the `#set document(...)` lives in the module, not the vertebra's own +source. The reliable rule today: **put a literal `#set document(...)` in each +vertebra's own source** if you want its metadata reflected in the spine. + +### Narrower gaps in the same scan + +- **Only the first `#set document(...)` rule** is read. Typst accumulates + multiple rules; rheo takes the first in source order, so keys set by a later + rule are missed. +- **Non-literal argument values are dropped**, not errored: `title: my-var` + (an identifier), `title: "a" + "b"`, `title: upper("x")`, `keywords: ..spread`, + and `if`/`context` expressions all yield no harvested value for that field. +- **Content that isn't plain text** (math, images, raw blocks inside a bracket + value) flattens to nothing for those spans; only `Text`/`Space` leaves survive. + +### The proper fix (not yet done) + +Reading `document.title` from the *compiled* output (Typst introspection) would +close all of the above, but requires a pre-pass that compiles each vertebra +standalone purely to read its resolved metadata, then builds the spine, then +compiles the bundle — a two-pass design with real cost. From 4143310e9ff727ab180f572e24e678232d622275 Mon Sep 17 00:00:00 2001 From: Lachlan Kermode Date: Tue, 21 Jul 2026 13:14:04 +0200 Subject: [PATCH 3/6] Trims document metadata to the literal kinds Typst allows Typst's document element is not extensible: #set document(...) rejects any argument outside title/author/keywords/date, so Bool/Int/Float MetaValue kinds were unreachable through a compilable rule. Reduces MetaValue to Str and Array, reverts the Int/Float additions to TypstLiteral, and corrects the rheo-context metadata docs to name the reachable fields. --- CLAUDE.md | 2 +- crates/core/src/parser/document_metadata.rs | 36 ++++++++------------- crates/core/src/util/typst_literal.rs | 8 ----- 3 files changed, 14 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dec46092..c0a55890 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,7 @@ Fields (the returned dictionary may gain fields later): - `handle` — this file's `:`-separated handle (its ID; the same handle used for the cross-file links above). The only per-file field. - `spine` — the structured spine **tree**, mirroring directory/section nesting. Each node is a dict `(title, handle, path, metadata, children)`: a leaf (a vertebra) carries its own `handle`/`path`/`title`/`metadata`; a group node (a directory or `[[spine.section]]` with no landing file) carries `handle: none`, `path: none`, an empty `metadata: (:)`, and its own group title, nesting its children. - `spine-flat` — the flat pre-order list of every *clickable* vertebra (groups excluded), each an entry `(handle, path, title, metadata)`. -- `metadata` (per entry) — every representable named argument of that vertebra's `#set document(...)` rule, harvested generically into a dict: e.g. `entry.metadata.at("keywords", default: ())` or `entry.metadata.at("author", default: none)`. Strings (including bracket-content, flattened to plain text), booleans, integers, floats, and arrays thereof round-trip; non-literal arguments like `date: datetime(...)` are omitted (the document date has its own handling for the feed). An entry with no `#set document(...)` has an empty `metadata: (:)`. +- `metadata` (per entry) — the named arguments of that vertebra's `#set document(...)` rule, harvested into a dict: e.g. `entry.metadata.at("keywords", default: ())` or `entry.metadata.at("author", default: none)`. Typst's `document` element is not extensible, so the reachable fields are `title`, `author`, and `keywords` — all strings (bracket-content is flattened to plain text) or arrays of strings. `date: datetime(...)` is a non-literal call and is omitted (the document date has its own handling for the feed). An entry with no `#set document(...)` has an empty `metadata: (:)`. - `target` — the rheo output-format name (`"epub"`/`"html"`/…). Present only for formats that set one; **absent for PDF**, where documents fall back to Typst's native `target()` == `"paged"`. - `ext` — the output file extension (`"html"`/`"xhtml"`), gated like `target` (present for html/epub, absent for PDF). The value core reads to build depth-relative cross-vertebra link hrefs. diff --git a/crates/core/src/parser/document_metadata.rs b/crates/core/src/parser/document_metadata.rs index 25dcac6a..aeb0b833 100644 --- a/crates/core/src/parser/document_metadata.rs +++ b/crates/core/src/parser/document_metadata.rs @@ -6,22 +6,16 @@ use typst::syntax::{Source, SyntaxKind, SyntaxNode, ast, ast::AstNode}; /// A value harvested from a `#set document(...)` named argument. /// -/// Covers the literal shapes a static AST walk can faithfully round-trip back -/// into a Typst value: strings (including bracket-content, flattened to plain -/// text), booleans, integers, floats, and arrays thereof. Non-literal arguments -/// (e.g. `date: datetime(...)`, which is a function call) are not representable -/// here and are dropped by [`MetaValue::from_expr`]; the document date has its -/// own dedicated extractor, [`DocumentDate`](super::DocumentDate). +/// Typst's `document` element is not extensible: a `#set document(...)` rule only +/// accepts its defined parameters — `title` (content/str), `author` and +/// `keywords` (str or array of str), and `date` (a `datetime(...)` call, skipped +/// here as a non-literal and handled by [`DocumentDate`](super::DocumentDate)). +/// So the only literal shapes reachable through a compilable rule are strings +/// (including bracket-content, flattened to plain text) and arrays of them. #[derive(Debug, Clone, PartialEq)] pub enum MetaValue { /// A string literal, or the plain text of a bracket-content value. Str(String), - /// A boolean literal (`true`/`false`). - Bool(bool), - /// An integer literal. - Int(i64), - /// A floating-point literal. - Float(f64), /// An array literal, e.g. `("DiH", "MiT")` for `keywords`. Array(Vec), } @@ -41,9 +35,6 @@ impl MetaValue { fn from_expr(expr: ast::Expr) -> Option { match expr { ast::Expr::Str(s) => Some(MetaValue::Str(s.get().to_string())), - ast::Expr::Bool(b) => Some(MetaValue::Bool(b.get())), - ast::Expr::Int(i) => Some(MetaValue::Int(i.get())), - ast::Expr::Float(f) => Some(MetaValue::Float(f.get())), // Bracket-content (`title: [My Title]`) flattens to its plain text, // dropping markup markers so a spine/feed title is clean text. ast::Expr::ContentBlock(c) => { @@ -66,9 +57,6 @@ impl MetaValue { pub fn to_literal(&self) -> TypstLiteral { match self { MetaValue::Str(s) => TypstLiteral::str(s.as_str()), - MetaValue::Bool(b) => TypstLiteral::bool(*b), - MetaValue::Int(i) => TypstLiteral::Int(*i), - MetaValue::Float(f) => TypstLiteral::Float(*f), MetaValue::Array(items) => { TypstLiteral::Array(items.iter().map(MetaValue::to_literal).collect()) } @@ -206,11 +194,13 @@ mod tests { } #[test] - fn test_int_and_bool_and_float_args() { - let m = metadata(r#"#set document(reading-time: 5, draft: true, ratio: 1.5)"#); - assert_eq!(m.get("reading-time"), Some(&MetaValue::Int(5))); - assert_eq!(m.get("draft"), Some(&MetaValue::Bool(true))); - assert_eq!(m.get("ratio"), Some(&MetaValue::Float(1.5))); + fn test_non_literal_scalar_args_skipped() { + // Typst's document element rejects such args at compile time; even if + // present in source, non-string scalars are not harvested. + let m = metadata(r#"#set document(title: [T], count: 5, flag: true)"#); + assert!(m.get("count").is_none()); + assert!(m.get("flag").is_none()); + assert_eq!(m.get("title").and_then(MetaValue::as_str), Some("T")); } #[test] diff --git a/crates/core/src/util/typst_literal.rs b/crates/core/src/util/typst_literal.rs index 1537ffc2..82fcf4f7 100644 --- a/crates/core/src/util/typst_literal.rs +++ b/crates/core/src/util/typst_literal.rs @@ -15,10 +15,6 @@ pub enum TypstLiteral { Str(String), /// A boolean literal (`true`/`false`). Bool(bool), - /// An integer literal. - Int(i64), - /// A floating-point literal. - Float(f64), /// The `none` literal. None, /// An array literal, e.g. `(a, b,)`. @@ -47,8 +43,6 @@ impl TypstLiteral { match self { TypstLiteral::Str(s) => serialize_string(s), TypstLiteral::Bool(b) => b.to_string(), - TypstLiteral::Int(i) => i.to_string(), - TypstLiteral::Float(f) => f.to_string(), TypstLiteral::None => "none".to_string(), TypstLiteral::Array(items) if items.is_empty() => "()".to_string(), TypstLiteral::Array(items) => { @@ -73,8 +67,6 @@ impl TypstLiteral { match self { TypstLiteral::Str(s) => Value::Str(s.as_str().into()), TypstLiteral::Bool(b) => Value::Bool(*b), - TypstLiteral::Int(i) => Value::Int(*i), - TypstLiteral::Float(f) => Value::Float(*f), TypstLiteral::None => Value::None, TypstLiteral::Array(items) => { Value::Array(items.iter().map(TypstLiteral::to_value).collect::()) From 57b5325515591a38a8c2b739e9303c965e043718 Mon Sep 17 00:00:00 2001 From: Lachlan Kermode Date: Tue, 21 Jul 2026 13:30:01 +0200 Subject: [PATCH 4/6] Sources vertebra title from #set document metadata, not a text scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces DocumentTitle::extract() — a raw-text scan that only understood the bracket title form and mis-parsed a string-form title (grabbing the first #link[...] body as the title) — with the AST-based DocumentMetadata title. String and bracket forms both work, smart quotes survive, and the empty/absent case falls back to the filename. Removes the dead scanner and its tests. Also gates metadata harvesting to file scope, so a set document(...) inside a #let helper body (applied elsewhere, not in the defining file) is not mistaken for this vertebra's own title. --- crates/core/src/parser/document_metadata.rs | 61 ++++++-- crates/core/src/reticulate/spine.rs | 14 +- crates/core/src/util/pdf.rs | 147 +------------------- docs/limitations.md | 21 ++- 4 files changed, 85 insertions(+), 158 deletions(-) diff --git a/crates/core/src/parser/document_metadata.rs b/crates/core/src/parser/document_metadata.rs index aeb0b833..5bb26d9b 100644 --- a/crates/core/src/parser/document_metadata.rs +++ b/crates/core/src/parser/document_metadata.rs @@ -78,16 +78,22 @@ pub struct DocumentMetadata(pub Vec<(String, MetaValue)>); impl SyntaxSite for DocumentMetadata { const MAX_SITES: Option = Some(1); - /// Match a `set` rule targeting `document` and capture each of its named - /// arguments whose value is a representable literal. + /// Match a top-level `set` rule targeting `document` and capture each of its + /// named arguments whose value is a representable literal. + /// + /// Gated on `ctx.file_scope` so a `set document(...)` buried in a function + /// body or closure — e.g. a `#let template(doc) = { set document(...); .. }` + /// helper that this vertebra defines but never invokes — is not harvested: + /// that rule only applies where the function is *called*, not here. fn visit( _source: &Source, node: &SyntaxNode, _offset: usize, - _ctx: WalkCtx, + ctx: WalkCtx, out: &mut Vec, ) { - if let Some(set_rule) = node.cast::() + if ctx.file_scope + && let Some(set_rule) = node.cast::() && let ast::Expr::Ident(target) = set_rule.target() && target.as_str() == "document" { @@ -123,19 +129,23 @@ impl DocumentMetadata { } } -/// Flatten a markup subtree to its plain text: concatenate every `Text`/`Space` -/// leaf, dropping markup markers (emphasis underscores, `#strong[...]`, brackets) -/// so `[Good news - #emph[Severance]]` becomes `Good news - Severance`. +/// Flatten a markup subtree to its plain text: concatenate every textual leaf, +/// dropping markup markers (emphasis underscores, `#strong[...]`, brackets) so +/// `[Good news - #emph[Severance]]` becomes `Good news - Severance`. Smart quotes +/// are kept as their source character, so `[She said "hi"]` keeps its quotes. fn markup_plain_text(node: &SyntaxNode) -> String { let mut out = String::new(); collect_text(node, &mut out); out.trim().to_string() } -/// Append the text of every `Text`/`Space` leaf under `node`, in order. +/// Append the text of every textual leaf (`Text`/`Space`/`SmartQuote`) under +/// `node`, in order, recursing through wrapper nodes (emphasis, strong, …). fn collect_text(node: &SyntaxNode, out: &mut String) { match node.kind() { - SyntaxKind::Text | SyntaxKind::Space => out.push_str(node.leaf_text()), + SyntaxKind::Text | SyntaxKind::Space | SyntaxKind::SmartQuote => { + out.push_str(node.leaf_text()) + } _ => { for child in node.children() { collect_text(child, out); @@ -245,4 +255,37 @@ mod tests { let m = metadata("= Heading"); assert_eq!(m.to_literal().serialize(), "(:)"); } + + #[test] + fn test_set_rule_inside_function_body_not_harvested() { + // A `set document(...)` inside a `#let` helper only applies where the + // helper is invoked, not in the file that merely defines it. + let m = metadata( + r#"#let template(doc) = { + set document(title: "From Template") + doc +} += Heading"#, + ); + assert!(m.get("title").is_none()); + assert!(m.0.is_empty()); + } + + #[test] + fn test_top_level_set_rule_still_harvested() { + let m = metadata(r#"#set document(title: "Top Level")"#); + assert_eq!( + m.get("title").and_then(MetaValue::as_str), + Some("Top Level") + ); + } + + #[test] + fn test_smart_quotes_survive_bracket_title() { + let m = metadata(r#"#set document(title: [She said "hello"])"#); + assert_eq!( + m.get("title").and_then(MetaValue::as_str), + Some("She said \"hello\"") + ); + } } diff --git a/crates/core/src/reticulate/spine.rs b/crates/core/src/reticulate/spine.rs index 406b8bf0..c4907de0 100644 --- a/crates/core/src/reticulate/spine.rs +++ b/crates/core/src/reticulate/spine.rs @@ -701,7 +701,6 @@ impl VirtualSpine { .unwrap_or_default() .to_string_lossy() .to_string(); - let title = DocumentTitle::from_source(&source, &stem).extract(); let rel_path = to_forward_slash(file.strip_prefix(project_root).unwrap_or(file)); let source_obj = Source::detached(&source); @@ -709,6 +708,19 @@ impl VirtualSpine { let date = extracted.document_date; let metadata = extracted.document_metadata; let sites = extracted.labels; + + // Title from the harvested `#set document(title: …)` value — a + // real AST read that understands both string and bracket-content + // forms — falling back to the filename, title-cased. (Sourcing + // this from metadata rather than a raw-text scan is what fixes + // the string-form / link-text mis-parse.) + let title = metadata + .get("title") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| DocumentTitle::to_readable_name(&stem)); let mut vars = HashMap::new(); for v in extracted.rheo_vars { match v.value { diff --git a/crates/core/src/util/pdf.rs b/crates/core/src/util/pdf.rs index 205eb7d0..a5f856b3 100644 --- a/crates/core/src/util/pdf.rs +++ b/crates/core/src/util/pdf.rs @@ -1,5 +1,4 @@ -/// PDF utility functions shared across rheo core and plugins -use crate::util::constants::TYPST_LABEL_PATTERN; +//! PDF utility functions shared across rheo core and plugins. /// Sanitize a filename to create a valid Typst label name. /// @@ -16,82 +15,14 @@ pub fn sanitize_label_name(name: &str) -> String { .collect() } -/// Document title extractor that parses Typst source for title metadata. +/// Filename-to-title helper. /// -/// Provides methods for extracting document titles from Typst source code or -/// generating readable titles from filenames. -pub struct DocumentTitle { - source: String, - fallback_filename: String, -} +/// Document titles are read from `#set document(title: …)` via the AST-based +/// [`DocumentMetadata`](crate::parser::DocumentMetadata) extractor; this type +/// only carries the filename fallback used when a vertebra sets no title. +pub struct DocumentTitle; impl DocumentTitle { - /// Create a DocumentTitle from source code and a fallback filename. - /// - /// # Arguments - /// * `source` - Typst source code to extract title from - /// * `fallback` - Filename to use if no title is found in source - pub fn from_source(source: impl Into, fallback: impl Into) -> Self { - Self { - source: source.into(), - fallback_filename: fallback.into(), - } - } - - /// Extract the document title. - /// - /// Searches for `#set document(title: [...])` in the source and extracts the content. - /// Falls back to the filename converted to title case if no title is found. - pub fn extract(&self) -> String { - // Find the start of the title parameter - if let Some(title_start) = self.source.find("#set document(") { - let after_doc = &self.source[title_start..]; - if let Some(title_pos) = after_doc.find("title:") { - let after_title = &after_doc[title_pos + 6..]; // Skip "title:" - - // Find the opening bracket for the title - // PDF metadata uses bracket-delimited format: /Title [(title text)] - if let Some(bracket_start) = after_title.find('[') { - let title_content = &after_title[bracket_start + 1..]; - - // Count brackets to find the matching closing bracket - // Handles nested brackets like: [(Chapter [1])] - // Algorithm: - // 1. Start with depth=1 (for the opening bracket we just found) - // 2. Scan forward, incrementing depth for '[', decrementing for ']' - // 3. When depth reaches 0, we've found the matching closing bracket - let mut depth = 1; - let mut end_pos = 0; - - for (i, ch) in title_content.chars().enumerate() { - if ch == '[' { - depth += 1; // Found nested opening bracket - } else if ch == ']' { - depth -= 1; // Found closing bracket - if depth == 0 { - // This is the matching closing bracket - end_pos = i; - break; - } - } - } - - if end_pos > 0 { - let title = &title_content[..end_pos]; - // Strip Typst markup for plain text - let cleaned = strip_typst_markup(title); - if !cleaned.trim().is_empty() { - return cleaned; - } - } - } - } - } - - // Fallback: use filename, convert to title case - Self::to_readable_name(&self.fallback_filename) - } - /// Convert a filename to a readable title. /// /// Transforms a filename stem into a human-readable title by replacing @@ -118,20 +49,6 @@ impl DocumentTitle { } } -/// Strip basic Typst markup to get plain text. -/// -/// Removes common Typst markup patterns like #emph[...], #strong[...], -/// and italic markers (_) to extract plain text from formatted content. -fn strip_typst_markup(text: &str) -> String { - // Remove #emph[...], #strong[...], etc. - let result = TYPST_LABEL_PATTERN.replace_all(text, "$1"); - - // Remove underscores (italic markers) - let result = result.replace('_', ""); - - result.trim().to_string() -} - #[cfg(test)] mod tests { use super::*; @@ -161,56 +78,4 @@ mod tests { ); assert_eq!(DocumentTitle::to_readable_name("single"), "Single"); } - - #[test] - fn test_extract_document_title_from_metadata() { - let source = r#"#set document(title: [My Great Title]) - -= Chapter 1 -Content here."#; - - let title = DocumentTitle::from_source(source, "fallback").extract(); - assert_eq!(title, "My Great Title"); - } - - #[test] - fn test_extract_document_title_fallback() { - let source = r#"= Chapter 1 -Content here."#; - - let title = DocumentTitle::from_source(source, "my-chapter").extract(); - assert_eq!(title, "My Chapter"); - } - - #[test] - fn test_extract_document_title_with_markup() { - let source = r#"#set document(title: [Good news about hell - #emph[Severance]])"#; - - let title = DocumentTitle::from_source(source, "fallback").extract(); - // Should strip #emph and underscores - // Note: complex nested bracket handling is limited by regex - assert!(title.contains("Good news")); - assert!(title.contains("Severance")); - } - - #[test] - fn test_extract_document_title_empty() { - let source = r#"#set document(title: []) - -Content"#; - - let title = DocumentTitle::from_source(source, "default-name").extract(); - // Empty title should fall back to filename - assert_eq!(title, "Default Name"); - } - - #[test] - fn test_extract_document_title_complex() { - let source = r#"#set document(title: [Half Loop - _Severance_ [s1/e2]], author: [Test])"#; - - let title = DocumentTitle::from_source(source, "fallback").extract(); - // Should extract title and strip markup - assert!(title.contains("Half Loop")); - assert!(title.contains("Severance")); - } } diff --git a/docs/limitations.md b/docs/limitations.md index fb794470..77fa476f 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -10,18 +10,25 @@ source text** before compilation — `DocumentMetadata`/`DocumentDate` in the bundle source rheo generates, so it must exist before Typst runs. Because it is a static scan of literal syntax — not evaluation — it only sees a -`#set document(...)` rule written literally in the vertebra file itself. Typst, -however, applies document set rules no matter where they are evaluated: set rules -propagate up to the document element regardless of nesting. Verified with typst -0.15.0 — all of these produce the given compiled document title, but only the -last is visible to rheo's harvest: +`#set document(...)` rule written at the **top (file-scope) level of the vertebra +file itself**. Typst, however, applies document set rules no matter where they are +evaluated: set rules propagate up to the document element regardless of nesting. +Verified with typst 0.15.0 — all of these produce the given compiled document +title, but only the last is visible to rheo's harvest: | How the title is set | Compiled `document.title` | Harvested by rheo? | | --- | --- | --- | | In an imported module fn, applied via `#show: book` | `FromModule` | **No** | | Inside `#show: doc => { set document(...); doc }` | `FromShow` | **No** | -| In a code block *in the same file* — `#{ set document(...) }` | `FromCodeBlock` | Yes | -| Literal top-level — `#set document(title: "…")` | `Literal` | Yes | +| Inside a `#let` helper's body (even if applied) | — | **No** | +| In a code block — `#{ set document(...) }` | `FromCodeBlock` | **No** | +| Literal top-level — `#set document(title: "…")` | `Literal` | **Yes** | + +Harvesting is gated to file scope (the same rule `rheo-*` variables use): a set +rule nested in a closure, code block, or `#let` binding is skipped, because such a +rule may apply only where a helper is *invoked* (a different file), not where it is +defined. The cost is that a top-level `#{ set document(...) }` code block — an +unusual way to write what `#set document(...)` expresses directly — is also missed. ### What this means From 87444aa5eabf8f498ca3434c5efb9885841f92ad Mon Sep 17 00:00:00 2001 From: Lachlan Kermode Date: Tue, 21 Jul 2026 13:38:40 +0200 Subject: [PATCH 5/6] Documents that spine titles/metadata are plain text, not content Notes in docs/limitations.md that harvested titles and metadata values are plain strings: inline formatting markers are stripped and non-textual spans (math, images, raw) drop entirely, since these feed plain-text sinks (nav, @handle, RSS, PDF Info). --- docs/limitations.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/limitations.md b/docs/limitations.md index 77fa476f..b274d78d 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -53,8 +53,25 @@ vertebra's own source** if you want its metadata reflected in the spine. - **Non-literal argument values are dropped**, not errored: `title: my-var` (an identifier), `title: "a" + "b"`, `title: upper("x")`, `keywords: ..spread`, and `if`/`context` expressions all yield no harvested value for that field. -- **Content that isn't plain text** (math, images, raw blocks inside a bracket - value) flattens to nothing for those spans; only `Text`/`Space` leaves survive. + +### Harvested titles/values are plain text, not content + +Every harvested value is a plain string, not Typst content — the spine `title` +and each `metadata` value feed plain-text sinks (nav labels, `@handle` display +text, the Atom/RSS ``, PDF Info `/Title`), none of which render markup. +When a bracket value carries formatting, only its `Text`/`Space`/`SmartQuote` +leaves survive; everything else is flattened: + +- **Inline formatting markers are stripped**, keeping the words: + `[_italic_ text]` → `italic text`, `[*bold* text]` → `bold text`, + `[Good news — #emph[Severance]]` → `Good news — Severance`. +- **Spans that aren't textual leaves drop entirely**, not just their markers: + `[Chapter $x^2$]` → `Chapter` (math gone), `[See #image("x.png")]` → `See`, + inline raw `` [`code`] `` contributes nothing. + +There is no way to carry a *formatted* title through the spine; if a vertebra +needs to display rich text, author it in the body/heading as content, separate +from the `#set document(title: …)` value. ### The proper fix (not yet done) From 8ced38879ff69e45bf3a87af9fae3505c3fe6617 Mon Sep 17 00:00:00 2001 From: Lachlan Kermode <lachie@ohrg.org> Date: Tue, 21 Jul 2026 13:53:05 +0200 Subject: [PATCH 6/6] Warns when a document title loses content in plain-text flattening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rheo builds spine titles by flattening #set document(title: …) content to a plain string. When a bracket title carries styling (_x_, *x*) or sophisticated content (math, images, raw), that flattening is lossy. The extractor now flags a lossy title (keeping both the original and stripped forms) and VirtualSpine::build emits a tracing::warn! naming the file and showing both, so the loss is not silent. --- crates/core/src/parser/document_metadata.rs | 155 ++++++++++++++++---- crates/core/src/reticulate/spine.rs | 12 ++ docs/limitations.md | 5 + 3 files changed, 141 insertions(+), 31 deletions(-) diff --git a/crates/core/src/parser/document_metadata.rs b/crates/core/src/parser/document_metadata.rs index 5bb26d9b..89670cd0 100644 --- a/crates/core/src/parser/document_metadata.rs +++ b/crates/core/src/parser/document_metadata.rs @@ -38,7 +38,7 @@ impl MetaValue { // Bracket-content (`title: [My Title]`) flattens to its plain text, // dropping markup markers so a spine/feed title is clean text. ast::Expr::ContentBlock(c) => { - Some(MetaValue::Str(markup_plain_text(c.body().to_untyped()))) + Some(MetaValue::Str(markup_plain_text(c.body().to_untyped()).0)) } ast::Expr::Array(a) => Some(MetaValue::Array( a.items() @@ -64,16 +64,33 @@ impl MetaValue { } } +/// A document title whose bracket content lost information when flattened to the +/// plain string rheo uses in the spine — carrying both forms so the caller can +/// warn the author. +#[derive(Debug, Clone, PartialEq)] +pub struct LossyTitle { + /// The original title content as written (markup intact), e.g. `_Italic_ Title`. + pub raw: String, + /// The plain-text form kept in the spine, e.g. `Italic Title`. + pub stripped: String, +} + /// All named arguments of the first `#set document(...)` rule in a vertebra, /// captured generically as `(name, value)` pairs in source order. /// -/// A [`SyntaxSite`] capped at one site: the first such rule in the tree, read -/// via `DocumentMetadata::first(source)`. Only literal-valued arguments are -/// retained (see [`MetaValue`]); an argument whose value cannot be represented -/// as a literal is silently skipped rather than erroring, so an ordinary -/// `date: datetime(...)` argument does not abort the harvest. +/// Built from the first such rule in the tree (see [`SyntaxSite::first`]). Only +/// literal-valued arguments are retained (see [`MetaValue`]); an argument whose +/// value cannot be represented as a literal is silently skipped rather than +/// erroring, so an ordinary `date: datetime(...)` argument does not abort the +/// harvest. #[derive(Debug, Clone, Default, PartialEq)] -pub struct DocumentMetadata(pub Vec<(String, MetaValue)>); +pub struct DocumentMetadata { + /// The harvested `(name, value)` pairs, in source order. + pub fields: Vec<(String, MetaValue)>, + /// Set when the `title` was bracket content that lost information (styling or + /// sophisticated content) in the flattening to plain text. + pub lossy_title: Option<LossyTitle>, +} impl SyntaxSite for DocumentMetadata { const MAX_SITES: Option<usize> = Some(1); @@ -97,16 +114,39 @@ impl SyntaxSite for DocumentMetadata { && let ast::Expr::Ident(target) = set_rule.target() && target.as_str() == "document" { - let fields = set_rule - .args() - .items() - .filter_map(|item| match item { - ast::Arg::Named(named) => MetaValue::from_expr(named.expr()) - .map(|v| (named.name().as_str().to_string(), v)), - _ => None, - }) - .collect(); - out.push(DocumentMetadata(fields)); + let mut fields = Vec::new(); + let mut lossy_title = None; + for item in set_rule.args().items() { + let ast::Arg::Named(named) = item else { + continue; + }; + let name = named.name().as_str().to_string(); + let expr = named.expr(); + + // A bracket-content title flattens to plain text; if that drops + // any styling or sophisticated content, keep both forms so the + // caller can warn the author. + if name == "title" + && let ast::Expr::ContentBlock(c) = expr + { + let body = c.body().to_untyped(); + let (stripped, lossy) = markup_plain_text(body); + if lossy { + lossy_title = Some(LossyTitle { + raw: body.full_text().trim().to_string(), + stripped, + }); + } + } + + if let Some(v) = MetaValue::from_expr(expr) { + fields.push((name, v)); + } + } + out.push(DocumentMetadata { + fields, + lossy_title, + }); } } } @@ -114,14 +154,14 @@ impl SyntaxSite for DocumentMetadata { impl DocumentMetadata { /// The value of a named argument (e.g. `title`), if present. pub fn get(&self, name: &str) -> Option<&MetaValue> { - self.0.iter().find(|(k, _)| k == name).map(|(_, v)| v) + self.fields.iter().find(|(k, _)| k == name).map(|(_, v)| v) } /// Serialize the captured metadata to a [`TypstLiteral`] dictionary for the /// spine's `metadata` field. Empty metadata serializes to `(:)`. pub fn to_literal(&self) -> TypstLiteral { TypstLiteral::Dict( - self.0 + self.fields .iter() .map(|(k, v)| (k.clone(), v.to_literal())) .collect(), @@ -129,26 +169,41 @@ impl DocumentMetadata { } } -/// Flatten a markup subtree to its plain text: concatenate every textual leaf, -/// dropping markup markers (emphasis underscores, `#strong[...]`, brackets) so -/// `[Good news - #emph[Severance]]` becomes `Good news - Severance`. Smart quotes -/// are kept as their source character, so `[She said "hi"]` keeps its quotes. -fn markup_plain_text(node: &SyntaxNode) -> String { +/// Flatten a markup subtree to its plain text and report whether anything was +/// lost: concatenate every textual leaf, dropping markup markers (emphasis +/// underscores, `#strong[...]`, brackets) so `[Good news - #emph[Severance]]` +/// becomes `Good news - Severance`. Smart quotes are kept as their source +/// character, so `[She said "hi"]` keeps its quotes. +/// +/// The returned bool is `true` when the content held anything beyond plain text +/// — styling (`_x_`, `*x*`) or sophisticated content (`$math$`, images, raw) — +/// i.e. the plain string is a lossy rendering of what the author wrote. +fn markup_plain_text(node: &SyntaxNode) -> (String, bool) { let mut out = String::new(); - collect_text(node, &mut out); - out.trim().to_string() + let mut lossy = false; + collect_text(node, &mut out, &mut lossy); + (out.trim().to_string(), lossy) } /// Append the text of every textual leaf (`Text`/`Space`/`SmartQuote`) under -/// `node`, in order, recursing through wrapper nodes (emphasis, strong, …). -fn collect_text(node: &SyntaxNode, out: &mut String) { +/// `node`, in order, recursing through wrapper nodes. `Markup`/`ContentBlock` +/// are transparent structure; encountering any *other* non-textual node (an +/// emphasis/strong wrapper, an equation, raw, an element call, …) means the +/// plain-text form drops something, so `lossy` is set. +fn collect_text(node: &SyntaxNode, out: &mut String, lossy: &mut bool) { match node.kind() { SyntaxKind::Text | SyntaxKind::Space | SyntaxKind::SmartQuote => { out.push_str(node.leaf_text()) } + SyntaxKind::Markup | SyntaxKind::ContentBlock => { + for child in node.children() { + collect_text(child, out, lossy); + } + } _ => { + *lossy = true; for child in node.children() { - collect_text(child, out); + collect_text(child, out, lossy); } } } @@ -228,7 +283,7 @@ mod tests { #[test] fn test_no_document_rule_is_empty() { let m = metadata("= Heading\n\nBody text."); - assert!(m.0.is_empty()); + assert!(m.fields.is_empty()); } #[test] @@ -268,7 +323,7 @@ mod tests { = Heading"#, ); assert!(m.get("title").is_none()); - assert!(m.0.is_empty()); + assert!(m.fields.is_empty()); } #[test] @@ -288,4 +343,42 @@ mod tests { Some("She said \"hello\"") ); } + + #[test] + fn test_lossy_title_flags_styling() { + let m = metadata(r#"#set document(title: [_Italic_ Title])"#); + assert_eq!( + m.lossy_title, + Some(LossyTitle { + raw: "_Italic_ Title".to_string(), + stripped: "Italic Title".to_string(), + }) + ); + } + + #[test] + fn test_lossy_title_flags_sophisticated_content() { + let m = metadata(r#"#set document(title: [Chapter $x^2$])"#); + let lossy = m.lossy_title.expect("math title should be lossy"); + assert_eq!(lossy.stripped, "Chapter"); + } + + #[test] + fn test_plain_bracket_title_not_lossy() { + let m = metadata(r#"#set document(title: [Hello World])"#); + assert!(m.lossy_title.is_none()); + } + + #[test] + fn test_string_title_not_lossy() { + let m = metadata(r#"#set document(title: "Plain String")"#); + assert!(m.lossy_title.is_none()); + } + + #[test] + fn test_smart_quote_title_not_lossy() { + // Quotes are preserved, so nothing is lost. + let m = metadata(r#"#set document(title: [She said "hi"])"#); + assert!(m.lossy_title.is_none()); + } } diff --git a/crates/core/src/reticulate/spine.rs b/crates/core/src/reticulate/spine.rs index c4907de0..19214364 100644 --- a/crates/core/src/reticulate/spine.rs +++ b/crates/core/src/reticulate/spine.rs @@ -709,6 +709,18 @@ impl VirtualSpine { let metadata = extracted.document_metadata; let sites = extracted.labels; + // A styled or otherwise non-plain document title cannot survive + // the flattening to the plain string rheo uses in the spine; warn + // the author, showing both the original and the stripped form. + if let Some(lossy) = &metadata.lossy_title { + tracing::warn!( + file = %file.display(), + original = %lossy.raw, + stripped = %lossy.stripped, + "Warning: Rheo uses document titles as unique identifiers, and does not retain styling or sophisticated forms of Typst content when constructing spines." + ); + } + // Title from the harvested `#set document(title: …)` value — a // real AST read that understands both string and bracket-content // forms — falling back to the filename, title-cased. (Sourcing diff --git a/docs/limitations.md b/docs/limitations.md index b274d78d..135deceb 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -73,6 +73,11 @@ There is no way to carry a *formatted* title through the spine; if a vertebra needs to display rich text, author it in the body/heading as content, separate from the `#set document(title: …)` value. +rheo does not fail on this, but it **warns**: whenever a bracket title loses +anything in the flattening (styling or non-text content), the build prints a +warning naming the file and showing both the original and the stripped title, so +the loss is never silent. + ### The proper fix (not yet done) Reading `document.title` from the *compiled* output (Typst introspection) would