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- = ""` 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 `` per vertebra that declares `rheo-feed-title`, and injects a `` autodiscovery tag into every page's ``. 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 `` per vertebra that declares `rheo-feed-title`, and injects a `` autodiscovery tag into every page's ``. 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 `` (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 ).
let needs_head_links = !css_paths.is_empty() || !js_paths.is_empty();
- let feed_link = ctx
- .config
- .parse_extra::()?
+ let html_cfg = ctx.config.parse_extra::()?;
+ 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,
/// `atom:author` of the feed; defaults to `"Rheo"` when absent.
feed_author: Option,
+ /// `` of the Atom feed and the autodiscovery ``.
+ /// Falls back to the HTML spine title, then the project/directory name.
+ feed_title: Option,
}
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::().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 `` and the autodiscovery `` 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("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 `` 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("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