Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
69 changes: 58 additions & 11 deletions crates/core/src/parser/document_metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -82,14 +83,21 @@ 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.
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>,
/// 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<chrono::DateTime<chrono::Utc>>,
}

impl SyntaxSite for DocumentMetadata {
Expand Down Expand Up @@ -146,6 +154,7 @@ impl SyntaxSite for DocumentMetadata {
out.push(DocumentMetadata {
fields,
lossy_title,
date: None,
});
}
}
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -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
Expand Down
46 changes: 44 additions & 2 deletions crates/core/src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -120,4 +123,43 @@ See @h and #link(<h>)[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:?}"
);
}
}
}
68 changes: 68 additions & 0 deletions crates/core/src/util/typst_literal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ pub enum TypstLiteral {
Array(Vec<TypstLiteral>),
/// 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 {
Expand Down Expand Up @@ -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})"
),
}
}

Expand All @@ -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)
}
}
}
}
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading