From 9fa93fd2588be82e4fca10d95af646801d537567 Mon Sep 17 00:00:00 2001 From: Lachlan Kermode Date: Sat, 25 Jul 2026 09:35:31 +0200 Subject: [PATCH 1/2] Documents Typst-native Atom feed spike findings (rheo-z23) --- docs/spikes/typst-native-feed.md | 120 +++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 docs/spikes/typst-native-feed.md diff --git a/docs/spikes/typst-native-feed.md b/docs/spikes/typst-native-feed.md new file mode 100644 index 00000000..ffe05fde --- /dev/null +++ b/docs/spikes/typst-native-feed.md @@ -0,0 +1,120 @@ +# Spike: Typst-native Atom feed generation (rheo-z23) + +**Verdict: PARTIAL — mechanically possible, but a net regression. Recommendation: do NOT replace the Rust feed.** + +Timeboxed investigation into replacing rheo's Rust-side Atom feed +(`crates/html/src/feed.rs`) with a Typst-native feed derived from +`rheo-context().spine-flat`, mirroring the `exemel`/`atom.typ` approach used by +[wensimehrp.github.io](https://github.com/wensimehrp/wensimehrp.github.io). + +## Q2 — the file-write mechanism (the interesting result) + +Typst can, in fact, emit an arbitrary side file into rheo's build **without** +`typst query` or a second compile. The `bundle` target — which rheo already +drives (`world.compile_bundle()` → `compile::export_bundle` → +`typst_bundle::VirtualFs`) — accepts a top-level `asset()` element: + +- `typst-bundle-0.15.x/src/lib.rs`: `BundleFile::Asset(Bytes)` — *"Raw file + data, resulting from an `asset` element."* `collect()` accepts `AssetElem` at + the bundle top level alongside `DocumentElem`, and `bundle_impl()` writes each + asset's bytes into the `VirtualFs` at its `VirtualPath`. +- This is exactly what the reference's `#asset("atom.xml", …)` relies on. + +rheo synthesizes the bundle main itself — `reticulate/bundle_source.rs` +(`BundleSource`, a list of `#document(…)[…]` blocks) via `VirtualSpine::mould()` +(`reticulate/mould.rs`). So rheo could append one top-level +`#context asset("feed.xml", )` statement to that synthesized main and +the bytes would land in the `VirtualFs` next to the HTML pages, flushed to +`build/html/feed.xml` by the existing bundle writer (`build.rs:323`). **The +mechanism is real and fits the current architecture** — no new subprocess, no +new compile pass. + +## Q1 — feasibility of the data + +Sufficient. `rheo-context().spine-flat` already carries per-entry `handle`, +`path`, `title`, and `metadata`. Since **rheo-wdq landed**, `metadata.date` is +now a real Typst `datetime` (see `crates/core/CLAUDE.md` rheo-context section) — +so a Typst feed reads the true document date instead of the Rust path's +chrono-reparse. Feed ``/``/``/`<updated>`/`<author>` are all +derivable Typst-side. XML encoding is a package concern (`exemel`, or hand-rolled +string building with `&`/`<`/`>` escaping). + +## Q3 — per-entry `<content>` rendered HTML (the blocker) + +**This is why it's only PARTIAL.** The current Rust feed emits full +`<content type="html">` for every entry — the page's rendered `<main>` / body, +extracted from already-compiled HTML (`feed.rs:141-145` → +`util::html::feed_content_inner_html`, resolution `first <main>` → `.rheo-feed-content` +→ whole `<body>`). + +A Typst-native feed **cannot reproduce this**. In the bundle each page is a +separate `document()`; the top-level `asset()` statement sees spine *metadata* +only, never another document's *rendered HTML string*. Typst has no primitive to +serialize a sibling document's HTML body into an embeddable string. The reference +confirms the ceiling: `wensimehrp/atom.typ` sets **`content: none`** and ships +only a summary/description — it does not include rendered body HTML at all. + +So going Typst-native means regressing `<content>` from full rendered HTML to +summary-only (or dropping it). That is the feature the Rust path is *uniquely* +well-placed to provide: `compile()` already holds every page's compiled HTML in +`outputs: &[CastVertebra]` (`crates/html/src/lib.rs:116`), so extraction is free +and lossless there. + +## Q4 — what would move vs stay Rust + +| Concern | Today | Typst-native | +| --- | --- | --- | +| Feed skeleton (id/title/updated/self link/author) | Rust `AtomFeed` | Typst template ✓ | +| Per-entry title/updated/link/id | Rust, from `CastVertebra` | Typst, from `spine-flat` + `metadata.date` ✓ | +| Per-entry `<content>` rendered HTML | Rust, extracted from compiled body | **regresses to summary-only** ✗ | +| XML serialization | `atom_syndication` crate | `exemel` package (new dep) | +| `feed_base_url` / `feed_author` / `feed_title` (`[html]` TOML) | Rust config | must be plumbed into Typst via `sys.inputs.rheo-context` (new surface) | +| `rheo-feed-title` / `-updated` / `-exclude` overrides | harvested Rust-side (`output.vars`) | spine metadata carries **no** `rheo-*` vars today → would need new plumbing, or read `#set document` only (partial) | +| Autodiscovery `<link>` in every page `<head>` | Rust DOM post-process (`html.rs:inject_feed_link`, `lib.rs:157`) | stays Rust regardless | + +## Code delta estimate + +- **Removed:** most of `feed.rs` (~339 lines incl. tests; ~150 non-test) and the + `atom_syndication` dep. +- **Added:** a feed Typst template (`@rheo/feed` or injected), a new `exemel` + dependency, `sys.inputs` plumbing for feed config + `rheo-feed-*` var + surfacing into spine metadata, and Rust glue to inject the top-level + `asset("feed.xml", …)` into `BundleSource`. +- Autodiscovery-link injection stays Rust either way. Net LOC is roughly a wash, + and it **adds** cross-cutting surface (a package dep + `sys.inputs` config) to + **lose** the `<content>` feature. + +## Recommendation + +**Keep the Rust feed.** The one genuinely novel finding — that `asset()` on the +bundle target gives Typst a real file-write hook inside rheo — does not overcome +the `<content>` regression, and the reference project itself concedes that point +(`content: none`). The Rust path sits exactly where all rendered HTML already +lives, which is its decisive advantage. + +Revisit only if a future goal is **author-customizable feed templates**; then a +summary-only Typst feed could ship as an opt-in alongside (not replacing) the +Rust one, using the `asset()` mechanism documented above. + +## Throwaway PoC (illustrative — not wired) + +Minimal top-level statement rheo could append to the synthesized bundle main to +prove the mechanism (summary-only, no `<content>`): + +```typst +#context { + let entries = rheo-context().spine-flat.map(e => { + let updated = e.metadata.at("date", default: none) + "<entry><title>" + e.title + "" + + "" + + "" + base + "/" + e.path + "" + + (if updated != none { "" + updated.display("[year]-[month]-[day]T00:00:00Z") + "" } else { "" }) + + "" + }).join() + asset("feed.xml", + bytes("" + entries + "")) +} +``` + +(`base` would come from `sys.inputs.rheo-context` config plumbing that does not +exist yet. Escaping and RFC-3339 formatting are elided for brevity.) From 3047efeff69324884e4d0a18589f2a834e1efc7b Mon Sep 17 00:00:00 2001 From: Lachlan Kermode Date: Sat, 25 Jul 2026 09:35:31 +0200 Subject: [PATCH 2/2] Harvests #set document date into rheo-context metadata as datetime --- CLAUDE.md | 2 +- crates/core/src/parser/document_metadata.rs | 69 +++++++++++++++++---- crates/core/src/parser/mod.rs | 46 +++++++++++++- crates/core/src/util/typst_literal.rs | 68 ++++++++++++++++++++ 4 files changed, 171 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c0a55890..a425d355 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) — 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: (:)`. +- `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`, `keywords` — all strings (bracket-content is flattened to plain text) or arrays of strings — and `date`, a real Typst `datetime(...)` value when `#set document(date: datetime(...))` is present and resolvable (`entry.metadata.at("date", default: none)`). An entry with no `#set document(...)`, or whose `date` is `none`/`auto`/`datetime.today()`/malformed, has no `date` key (and an entry with no `#set document(...)` at all 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 89670cd0..e09a45de 100644 --- a/crates/core/src/parser/document_metadata.rs +++ b/crates/core/src/parser/document_metadata.rs @@ -9,7 +9,8 @@ use typst::syntax::{Source, SyntaxKind, SyntaxNode, ast, ast::AstNode}; /// 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)). +/// here as a non-literal and handled by [`DocumentDate`](super::DocumentDate), +/// which [`DocumentMetadata::to_literal`] later folds back in as a `date` key). /// 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)] @@ -82,7 +83,10 @@ pub struct LossyTitle { /// 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. +/// harvest. The `date` itself is filled in separately from +/// [`DocumentDate`](super::DocumentDate), which parses that same +/// `datetime(...)` call, and is folded back into [`to_literal`](Self::to_literal) +/// as a `date` key. #[derive(Debug, Clone, Default, PartialEq)] pub struct DocumentMetadata { /// The harvested `(name, value)` pairs, in source order. @@ -90,6 +94,10 @@ pub struct DocumentMetadata { /// Set when the `title` was bracket content that lost information (styling or /// sophisticated content) in the flattening to plain text. pub lossy_title: Option, + /// The `#set document(date: datetime(...))` timestamp, harvested separately + /// by [`DocumentDate`](super::DocumentDate) and threaded in by the caller + /// (see `extract_nodes`). `None` when absent, `none`/`auto`, or malformed. + pub date: Option>, } impl SyntaxSite for DocumentMetadata { @@ -146,6 +154,7 @@ impl SyntaxSite for DocumentMetadata { out.push(DocumentMetadata { fields, lossy_title, + date: None, }); } } @@ -158,14 +167,31 @@ impl DocumentMetadata { } /// Serialize the captured metadata to a [`TypstLiteral`] dictionary for the - /// spine's `metadata` field. Empty metadata serializes to `(:)`. + /// spine's `metadata` field. Empty metadata serializes to `(:)`. When + /// [`date`](Self::date) is set, it is appended as a `date` key holding a real + /// Typst `datetime(...)` literal. pub fn to_literal(&self) -> TypstLiteral { - TypstLiteral::Dict( - self.fields - .iter() - .map(|(k, v)| (k.clone(), v.to_literal())) - .collect(), - ) + use chrono::{Datelike, Timelike}; + + let mut pairs: Vec<(String, TypstLiteral)> = self + .fields + .iter() + .map(|(k, v)| (k.clone(), v.to_literal())) + .collect(); + if let Some(date) = self.date { + pairs.push(( + "date".to_string(), + TypstLiteral::Datetime { + year: date.year(), + month: date.month() as u8, + day: date.day() as u8, + hour: date.hour() as u8, + minute: date.minute() as u8, + second: date.second() as u8, + }, + )); + } + TypstLiteral::Dict(pairs) } } @@ -270,12 +296,15 @@ mod tests { #[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. + // `date: datetime(...)` is a function call, not a `MetaValue` literal → not + // harvested into `fields`; the real date is threaded in separately by the + // caller from `DocumentDate` (see `extract_nodes` and its tests in + // `parser::mod`). 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!(m.date.is_none()); assert_eq!(m.get("title").and_then(MetaValue::as_str), Some("T")); assert!(m.get("keywords").is_some()); } @@ -311,6 +340,24 @@ mod tests { assert_eq!(m.to_literal().serialize(), "(:)"); } + #[test] + fn test_date_serializes_to_datetime_literal() { + use chrono::TimeZone; + let mut m = metadata(r#"#set document(title: [T])"#); + m.date = Some(chrono::Utc.with_ymd_and_hms(2025, 3, 9, 14, 30, 5).unwrap()); + assert_eq!( + m.to_literal().serialize(), + r#"(title: "T", date: datetime(year: 2025, month: 3, day: 9, hour: 14, minute: 30, second: 5))"# + ); + } + + #[test] + fn test_no_date_omits_date_key() { + let m = metadata(r#"#set document(title: [T])"#); + assert!(m.date.is_none()); + assert_eq!(m.to_literal().serialize(), r#"(title: "T")"#); + } + #[test] fn test_set_rule_inside_function_body_not_harvested() { // A `set document(...)` inside a `#let` helper only applies where the diff --git a/crates/core/src/parser/mod.rs b/crates/core/src/parser/mod.rs index b09274eb..afdb8896 100644 --- a/crates/core/src/parser/mod.rs +++ b/crates/core/src/parser/mod.rs @@ -73,11 +73,14 @@ pub fn extract_nodes(source: &Source) -> ExtractedNodes { LabelRole::Reference => label_sites.references.push(site), } } + let document_date = dates.into_iter().next(); + let mut document_metadata = metadata.into_iter().next().unwrap_or_default(); + document_metadata.date = document_date.map(|d| d.0); ExtractedNodes { rheo_vars, labels: label_sites, - document_date: dates.into_iter().next(), - document_metadata: metadata.into_iter().next().unwrap_or_default(), + document_date, + document_metadata, } } @@ -120,4 +123,43 @@ See @h and #link()[here]."#, assert_eq!(extracted.rheo_vars[0].key, "feed-title"); assert_eq!(extracted.rheo_vars[1].key, "feed-updated"); } + + #[test] + fn test_document_date_threaded_into_metadata() { + use chrono::Datelike; + let source = Source::detached( + r#"#set document(title: [T], date: datetime(year: 2025, month: 1, day: 2))"#, + ); + let extracted = extract_nodes(&source); + let date = extracted + .document_metadata + .date + .expect("date should be threaded into metadata"); + assert_eq!((date.year(), date.month(), date.day()), (2025, 1, 2)); + assert_eq!( + extracted.document_metadata.to_literal().serialize(), + r#"(title: "T", date: datetime(year: 2025, month: 1, day: 2, hour: 0, minute: 0, second: 0))"# + ); + } + + #[test] + fn test_document_date_none_auto_today_partial_omit_date_key() { + for src in [ + r#"#set document(title: [T], date: none)"#, + r#"#set document(title: [T], date: auto)"#, + r#"#set document(title: [T], date: datetime.today())"#, + r#"#set document(title: [T], date: datetime(year: 2025, month: 1))"#, + ] { + let extracted = extract_nodes(&Source::detached(src)); + assert!( + extracted.document_metadata.date.is_none(), + "expected no date for {src:?}" + ); + assert_eq!( + extracted.document_metadata.to_literal().serialize(), + r#"(title: "T")"#, + "expected no date key for {src:?}" + ); + } + } } diff --git a/crates/core/src/util/typst_literal.rs b/crates/core/src/util/typst_literal.rs index 82fcf4f7..00959da1 100644 --- a/crates/core/src/util/typst_literal.rs +++ b/crates/core/src/util/typst_literal.rs @@ -21,6 +21,15 @@ pub enum TypstLiteral { Array(Vec), /// A dictionary literal with identifier keys, e.g. `(k: v)`. Dict(Vec<(String, TypstLiteral)>), + /// A `datetime(...)` literal. + Datetime { + year: i32, + month: u8, + day: u8, + hour: u8, + minute: u8, + second: u8, + }, } impl TypstLiteral { @@ -57,6 +66,16 @@ impl TypstLiteral { .collect(); format!("({})", inner.join(", ")) } + TypstLiteral::Datetime { + year, + month, + day, + hour, + minute, + second, + } => format!( + "datetime(year: {year}, month: {month}, day: {day}, hour: {hour}, minute: {minute}, second: {second})" + ), } } @@ -78,6 +97,23 @@ impl TypstLiteral { } Value::Dict(dict) } + TypstLiteral::Datetime { + year, + month, + day, + hour, + minute, + second, + } => { + let dt = typst::foundations::Datetime::from_ymd_hms( + *year, *month, *day, *hour, *minute, *second, + ) + .unwrap_or_else(|| { + typst::foundations::Datetime::from_ymd_hms(1970, 1, 1, 0, 0, 0) + .expect("1970-01-01 00:00:00 is always a valid datetime") + }); + Value::Datetime(dt) + } } } } @@ -155,6 +191,38 @@ mod tests { ); } + #[test] + fn datetime_serializes_to_datetime_call() { + let dt = TypstLiteral::Datetime { + year: 2025, + month: 3, + day: 9, + hour: 14, + minute: 30, + second: 5, + }; + assert_eq!( + dt.serialize(), + "datetime(year: 2025, month: 3, day: 9, hour: 14, minute: 30, second: 5)" + ); + } + + #[test] + fn datetime_to_value_builds_value_datetime() { + use typst::foundations::Value; + let dt = TypstLiteral::Datetime { + year: 2025, + month: 3, + day: 9, + hour: 14, + minute: 30, + second: 5, + }; + let Value::Datetime(_) = dt.to_value() else { + panic!("expected Value::Datetime"); + }; + } + #[test] fn to_value_builds_nested_dict() { use typst::foundations::Value;