diff --git a/.beads/.gitignore b/.beads/.gitignore deleted file mode 100644 index 1d44240..0000000 --- a/.beads/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.write.lock -*.tmp diff --git a/.beads/.sync.lock b/.beads/.sync.lock deleted file mode 100644 index e69de29..0000000 diff --git a/.gitignore b/.gitignore index f5a9677..7ac4bb7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ result result-* # Beads runtime/ephemeral files +.beads/ .beads/bd.sock .beads/beads.db .beads/beads.db-shm diff --git a/CLAUDE.md b/CLAUDE.md index 9319739..1064bc5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,7 @@ copy = ["*.txt"] # optional; glob patterns copied to every plugin output [html] feed_base_url = "https://example.com" # optional; when set, emits build/html/feed.xml (Atom) feed_author = "Jane Doe" # optional; atom:author of the feed (default "Rheo") +feed_title = "My Feed" # optional; feed and autodiscovery link title (default: spine title → project name) [html.assets] copy = ["images/**"] # optional; glob patterns copied to html output dir only @@ -82,7 +83,7 @@ Precedence: CLI flags > rheo.toml > built-in defaults. Without rheo.toml, title **Generic variable convention:** any top-level `#let rheo-<key> = "<string>"` in a vertebra is harvested during compilation. The value must be a string literal — a non-string RHS is a compile error. Plugins read these per-file with the `rheo-` prefix stripped (e.g. `rheo-feed-title` is available as `feed-title`). -**Atom feed:** set `feed_base_url` under `[html]` to enable it. When set, the HTML build emits `build/html/feed.xml` (Atom 1.0) with one `<entry>` per vertebra that declares `rheo-feed-title`, and injects a `<link rel="alternate" type="application/atom+xml">` autodiscovery tag into every page's `<head>`. Without `feed_base_url`, no feed is emitted. The feed's `atom:author` defaults to `Rheo`; set `[html] feed_author = "..."` to override it. +**Atom feed:** set `feed_base_url` under `[html]` to enable it. When set, the HTML build emits `build/html/feed.xml` (Atom 1.0) with one `<entry>` per vertebra that declares `rheo-feed-title`, and injects a `<link rel="alternate" type="application/atom+xml">` autodiscovery tag into every page's `<head>`. Without `feed_base_url`, no feed is emitted. The feed's `atom:author` defaults to `Rheo`; set `[html] feed_author = "..."` to override it. The feed's `<title>` (and autodiscovery link title) defaults to the HTML spine title, then the project/directory name; set `[html] feed_title = "..."` to override both. Feed variables: - `rheo-feed-title` — entry title; **required** for a vertebra to appear in the feed. diff --git a/crates/html/src/lib.rs b/crates/html/src/lib.rs index 5af2f11..277bc5a 100644 --- a/crates/html/src/lib.rs +++ b/crates/html/src/lib.rs @@ -162,11 +162,11 @@ impl HtmlPlugin { // serialized at most once. Head-links run before the feed link to match // the prior two-pass ordering (both insert after the last <meta>). let needs_head_links = !css_paths.is_empty() || !js_paths.is_empty(); - let feed_link = ctx - .config - .parse_extra::<HtmlConfig>()? + let html_cfg = ctx.config.parse_extra::<HtmlConfig>()?; + let feed_title = html_cfg.resolve_title(ctx.spine.title.as_deref(), &ctx.project.name); + let feed_link = html_cfg .base_url() - .map(|base| (format!("{base}/feed.xml"), ctx.project.name.clone())); + .map(|base| (format!("{base}/feed.xml"), feed_title)); let html_string = if needs_head_links || feed_link.is_some() { let mut dom = html_utils::HtmlDom::parse(&html_string)?; @@ -250,7 +250,7 @@ impl HtmlPlugin { let feed = AtomFeed { id: format!("{base}/feed.xml"), - title: ctx.project.name.clone(), + title: cfg.resolve_title(ctx.spine.title.as_deref(), &ctx.project.name), updated: Utc::now(), self_href: format!("{base}/feed.xml"), author: cfg @@ -299,6 +299,9 @@ struct HtmlConfig { feed_base_url: Option<String>, /// `atom:author` of the feed; defaults to `"Rheo"` when absent. feed_author: Option<String>, + /// `<title>` of the Atom feed and the autodiscovery `<link>`. + /// Falls back to the HTML spine title, then the project/directory name. + feed_title: Option<String>, } impl HtmlConfig { @@ -309,6 +312,15 @@ impl HtmlConfig { .as_deref() .map(|s| s.trim_end_matches('/').to_string()) } + + /// Resolve the feed title: `[html] feed_title` → spine title → project name. + fn resolve_title(&self, spine_title: Option<&str>, project_name: &str) -> String { + self.feed_title + .as_deref() + .or(spine_title) + .unwrap_or(project_name) + .to_string() + } } #[cfg(test)] @@ -378,4 +390,48 @@ mod tests { extra.insert("feed_author".to_string(), toml::Value::Integer(42)); assert!(section_with(extra).parse_extra::<HtmlConfig>().is_err()); } + + #[test] + fn test_feed_title_present() { + let mut extra = toml::Table::new(); + extra.insert( + "feed_title".to_string(), + toml::Value::String("My Feed".to_string()), + ); + assert_eq!(html_config(extra).feed_title.as_deref(), Some("My Feed")); + } + + #[test] + fn test_feed_title_absent() { + assert_eq!(html_config(toml::Table::new()).feed_title, None); + } + + #[test] + fn test_resolve_title_feed_title_set() { + let mut extra = toml::Table::new(); + extra.insert( + "feed_title".to_string(), + toml::Value::String("Feed Title".to_string()), + ); + let cfg = html_config(extra); + assert_eq!( + cfg.resolve_title(Some("Spine Title"), "project"), + "Feed Title" + ); + } + + #[test] + fn test_resolve_title_spine_fallback() { + let cfg = html_config(toml::Table::new()); + assert_eq!( + cfg.resolve_title(Some("Spine Title"), "project"), + "Spine Title" + ); + } + + #[test] + fn test_resolve_title_project_fallback() { + let cfg = html_config(toml::Table::new()); + assert_eq!(cfg.resolve_title(None, "my-project"), "my-project"); + } } diff --git a/crates/tests/tests/harness.rs b/crates/tests/tests/harness.rs index e6467e1..d4c0c34 100644 --- a/crates/tests/tests/harness.rs +++ b/crates/tests/tests/harness.rs @@ -1567,6 +1567,147 @@ fn test_atom_feed_author_configurable() { ); } +/// Integration test for the configurable `[html] feed_title` key. Asserts that +/// `feed_title` overrides both the spine title and the project name in the +/// feed's `<title>` and the autodiscovery `<link>` on each page. +#[test] +fn test_atom_feed_title_configurable() { + let dir = tempfile::tempdir().expect("Failed to create temp dir"); + let project_path = dir.path(); + let build_dir = project_path.join("build"); + + std::fs::write( + project_path.join("a.typ"), + "#let rheo-feed-title = \"Article A\"\n\n= Article A\n\nContent A.\n", + ) + .expect("Failed to write a.typ"); + + std::fs::write( + project_path.join("rheo.toml"), + format!( + "version = \"{}\"\n\ + formats = [\"html\"]\n\ + \n\ + [html.spine]\n\ + title = \"Spine Blog\"\n\ + vertebrae = [\"a.typ\"]\n\ + \n\ + [html]\n\ + feed_base_url = \"https://example.com\"\n\ + feed_title = \"Custom Feed Title\"\n", + env!("CARGO_PKG_VERSION"), + ), + ) + .expect("Failed to write rheo.toml"); + + let output = std::process::Command::new("cargo") + .args([ + "run", + "-p", + "rheo", + "--", + "compile", + project_path.to_str().unwrap(), + "--html", + "--build-dir", + build_dir.to_str().unwrap(), + ]) + .env("TYPST_IGNORE_SYSTEM_FONTS", "1") + .output() + .expect("Failed to run rheo compile"); + + assert!( + output.status.success(), + "Compilation failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let feed_path = build_dir.join("html/feed.xml"); + assert!(feed_path.exists(), "feed.xml not generated"); + let feed = std::fs::read_to_string(&feed_path).expect("Failed to read feed.xml"); + + assert!( + feed.contains("<title>Custom Feed Title"), + "feed title not set from feed_title: {feed}" + ); + assert!( + !feed.contains("Spine Blog"), + "spine title should be overridden by feed_title" + ); + + // Autodiscovery link in the HTML page should also carry the custom title. + let html_path = build_dir.join("html/a.html"); + let html = std::fs::read_to_string(&html_path).expect("Failed to read a.html"); + assert!( + html.contains("title=\"Custom Feed Title\""), + "autodiscovery link title not set from feed_title: {html}" + ); +} + +/// When `[html] feed_title` is absent but `[html.spine] title` is set, the feed +/// `` and autodiscovery `<link>` fall back to the spine title. +#[test] +fn test_atom_feed_title_spine_fallback() { + let dir = tempfile::tempdir().expect("Failed to create temp dir"); + let project_path = dir.path(); + let build_dir = project_path.join("build"); + + std::fs::write( + project_path.join("a.typ"), + "#let rheo-feed-title = \"Article A\"\n\n= Article A\n\nContent A.\n", + ) + .expect("Failed to write a.typ"); + + // No feed_title — spine title should be used instead of project name. + std::fs::write( + project_path.join("rheo.toml"), + format!( + "version = \"{}\"\n\ + formats = [\"html\"]\n\ + \n\ + [html.spine]\n\ + title = \"Spine Blog\"\n\ + vertebrae = [\"a.typ\"]\n\ + \n\ + [html]\n\ + feed_base_url = \"https://example.com\"\n", + env!("CARGO_PKG_VERSION"), + ), + ) + .expect("Failed to write rheo.toml"); + + let output = std::process::Command::new("cargo") + .args([ + "run", + "-p", + "rheo", + "--", + "compile", + project_path.to_str().unwrap(), + "--html", + "--build-dir", + build_dir.to_str().unwrap(), + ]) + .env("TYPST_IGNORE_SYSTEM_FONTS", "1") + .output() + .expect("Failed to run rheo compile"); + + assert!( + output.status.success(), + "Compilation failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let feed_path = build_dir.join("html/feed.xml"); + assert!(feed_path.exists(), "feed.xml not generated"); + let feed = std::fs::read_to_string(&feed_path).expect("Failed to read feed.xml"); + + assert!( + feed.contains("<title>Spine Blog"), + "feed title should fall back to spine title: {feed}" + ); +} + /// Regression: feed generation resolves spine vertebrae against the configured /// `content_dir`, not the project root. With `content_dir` set, the per-file /// HTML compile path used to glob from the project root and fail with