use crate::home;
#[cfg_attr(feature = "specta", derive(specta::Type))]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct HeadingState {
pub visible: bool,
pub text: String,
pub source: HeadingSource,
}
#[cfg_attr(feature = "specta", derive(specta::Type))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum HeadingSource {
Filename,
Title,
}
#[derive(Debug, Clone, Copy)]
pub struct HeadingInputs<'a> {
pub file_path: &'a str,
pub frontmatter_title: Option<&'a str>,
pub body_markdown: &'a str,
pub root_folder_name: Option<&'a str>,
pub is_home_override: bool,
pub slot_only: bool,
}
pub fn filename_text(file_path: &str) -> String {
filename_text_with_root(file_path, None)
}
pub fn filename_text_with_root(file_path: &str, root_folder_name: Option<&str>) -> String {
let path = std::path::Path::new(file_path);
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Untitled");
let parent_name = path
.parent()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
.or(root_folder_name);
let is_folder_note = home::is_index_stem(stem)
|| parent_name.is_some_and(|p| p.eq_ignore_ascii_case(stem));
let source_name = if is_folder_note {
parent_name.unwrap_or(stem)
} else {
stem
};
source_name.replace('-', " ").replace('_', " ")
}
pub fn hero_at_top_owns_title(body_markdown: &str) -> bool {
let mut lines = body_markdown
.lines()
.skip_while(|line| line.trim().is_empty());
let Some(open) = lines.next() else {
return false;
};
let trimmed = open.trim_start();
let opens_hero = trimmed == ":::hero"
|| trimmed.starts_with(":::hero ")
|| trimmed.starts_with(":::hero\t");
if !opens_hero {
return false;
}
lines
.take_while(|line| line.trim() != ":::")
.any(|line| !line.trim().is_empty())
}
pub fn compute(input: HeadingInputs<'_>) -> HeadingState {
let path = std::path::Path::new(input.file_path);
let (text, source) = match input.frontmatter_title {
Some(t) => (t.trim().to_string(), HeadingSource::Title),
None => (
filename_text_with_root(input.file_path, input.root_folder_name),
HeadingSource::Filename,
),
};
let is_markdown = matches!(
path.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase())
.as_deref(),
Some("md") | Some("mdx") | Some("markdown")
);
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let parent_from_path = path
.parent()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
.unwrap_or("");
let parent_name = if parent_from_path.is_empty() {
input.root_folder_name.unwrap_or("")
} else {
parent_from_path
};
let filename_lower = stem.to_lowercase();
let is_index_file =
home::is_home_file(&filename_lower, parent_name) || input.is_home_override;
let empty_title = source == HeadingSource::Title && text.is_empty();
let hero_at_top = hero_at_top_owns_title(input.body_markdown);
let visible =
is_markdown && !is_index_file && !empty_title && !hero_at_top && !input.slot_only;
HeadingState { visible, text, source }
}
#[cfg(test)]
mod tests {
use super::*;
fn inputs<'a>(file_path: &'a str, frontmatter_title: Option<&'a str>) -> HeadingInputs<'a> {
HeadingInputs {
file_path,
frontmatter_title,
body_markdown: "",
root_folder_name: None,
is_home_override: false,
slot_only: false,
}
}
#[test]
fn text_article_uses_filename() {
assert_eq!(filename_text("posts/my-first-post.md"), "my first post");
}
#[test]
fn text_underscore_normalizes_to_space() {
assert_eq!(filename_text("posts/my_first_post.md"), "my first post");
}
#[test]
fn text_index_uses_parent_folder() {
assert_eq!(filename_text("site/index.md"), "site");
}
#[test]
fn text_readme_uses_parent_folder() {
assert_eq!(filename_text("docs/README.md"), "docs");
}
#[test]
fn text_self_named_folder_note_uses_parent() {
assert_eq!(filename_text("recipes/recipes.md"), "recipes");
}
#[test]
fn text_root_level_no_parent() {
assert_eq!(filename_text("about.md"), "about");
}
#[test]
fn text_cjk_filename_preserved() {
assert_eq!(filename_text("文字/民歌.md"), "民歌");
}
#[test]
fn text_cjk_index_uses_parent() {
assert_eq!(filename_text("文字/index.md"), "文字");
}
#[test]
fn text_root_index_uses_root_folder_name() {
assert_eq!(
filename_text_with_root("index.md", Some("My Site")),
"My Site"
);
}
#[test]
fn text_root_index_no_root_name_falls_back_to_stem() {
assert_eq!(filename_text_with_root("index.md", None), "index");
}
#[test]
fn text_root_self_named_uses_root_folder_name() {
assert_eq!(
filename_text_with_root("刘果.md", Some("刘果")),
"刘果"
);
}
#[test]
fn text_with_root_nested_index_unaffected_by_root_name() {
assert_eq!(
filename_text_with_root("site/index.md", Some("My Site")),
"site"
);
}
#[test]
fn text_with_root_article_unaffected() {
assert_eq!(
filename_text_with_root("about.md", Some("My Site")),
"about"
);
}
#[test]
fn compute_root_index_text_is_root_folder_name() {
let s = compute(HeadingInputs {
file_path: "index.md",
frontmatter_title: None,
body_markdown: "",
root_folder_name: Some("My Site"),
is_home_override: false,
slot_only: false,
});
assert!(!s.visible, "root index still suppresses the auto H1");
assert_eq!(s.text, "My Site");
}
#[test]
fn visible_for_article_md() {
let s = compute(inputs("posts/my-first-post.md", None));
assert!(s.visible);
assert_eq!(s.text, "my first post");
assert!(matches!(s.source, HeadingSource::Filename));
}
#[test]
fn hidden_for_index_file() {
let s = compute(inputs("site/index.md", None));
assert!(!s.visible);
assert_eq!(s.text, "site");
}
#[test]
fn hidden_for_readme() {
let s = compute(inputs("docs/README.md", None));
assert!(!s.visible);
assert_eq!(s.text, "docs");
}
#[test]
fn hidden_for_self_named_folder_note() {
let s = compute(inputs("recipes/recipes.md", None));
assert!(!s.visible);
assert_eq!(s.text, "recipes");
}
#[test]
fn hidden_for_root_self_named_home_with_root_folder_name() {
let s = compute(HeadingInputs {
file_path: "刘果.md",
frontmatter_title: None,
body_markdown: "",
root_folder_name: Some("刘果"),
is_home_override: false,
slot_only: false,
});
assert!(!s.visible, "root-level self-named home file must hide H1");
}
#[test]
fn root_index_md_still_hidden_without_root_folder_name() {
let s = compute(HeadingInputs {
file_path: "index.md",
frontmatter_title: None,
body_markdown: "",
root_folder_name: None,
is_home_override: false,
slot_only: false,
});
assert!(!s.visible);
}
#[test]
fn hidden_for_non_markdown() {
let s = compute(inputs("assets/style.css", None));
assert!(!s.visible);
}
#[test]
fn visible_for_mdx() {
let s = compute(inputs("posts/article.mdx", None));
assert!(s.visible);
assert_eq!(s.text, "article");
}
#[test]
fn visible_for_root_level_article() {
let s = compute(inputs("about.md", None));
assert!(s.visible);
assert_eq!(s.text, "about");
}
#[test]
fn visible_for_cjk_article() {
let s = compute(inputs("文字/民歌.md", None));
assert!(s.visible);
assert_eq!(s.text, "民歌");
}
#[test]
fn hidden_for_cjk_index() {
let s = compute(inputs("文字/index.md", None));
assert!(!s.visible);
assert_eq!(s.text, "文字");
}
#[test]
fn source_is_title_when_frontmatter_title_set() {
let s = compute(inputs("posts/article.md", Some("Custom")));
assert_eq!(s.text, "Custom");
assert!(matches!(s.source, HeadingSource::Title));
assert!(s.visible);
}
#[test]
fn source_is_filename_when_title_absent() {
let s = compute(inputs("posts/article.md", None));
assert!(matches!(s.source, HeadingSource::Filename));
assert_eq!(s.text, "article");
assert!(s.visible);
}
#[test]
fn empty_title_produces_invisible_state() {
let s = compute(inputs("posts/article.md", Some("")));
assert!(matches!(s.source, HeadingSource::Title));
assert_eq!(s.text, "");
assert!(!s.visible, "title: \"\" suppresses the auto-injected H1");
}
#[test]
fn whitespace_title_produces_invisible_state() {
let s = compute(inputs("posts/article.md", Some(" ")));
assert!(matches!(s.source, HeadingSource::Title));
assert_eq!(s.text, "");
assert!(!s.visible);
}
#[test]
fn title_overrides_index_visibility_unchanged() {
let s = compute(inputs("site/index.md", Some("Welcome")));
assert!(!s.visible, "index pages still don't auto-inject");
assert!(matches!(s.source, HeadingSource::Title));
assert_eq!(s.text, "Welcome");
}
#[test]
fn title_text_is_trimmed() {
let s = compute(inputs("posts/article.md", Some(" Custom ")));
assert_eq!(s.text, "Custom");
assert!(s.visible);
}
#[test]
fn hero_at_top_hides_heading_when_title_absent() {
let s = compute(HeadingInputs {
file_path: "posts/article.md",
frontmatter_title: None,
body_markdown: ":::hero\nimage: x.jpg\n:::\n\nBody.",
root_folder_name: None,
is_home_override: false,
slot_only: false,
});
assert!(!s.visible);
assert_eq!(s.text, "article");
}
#[test]
fn hero_at_top_hides_heading_when_title_set() {
let s = compute(HeadingInputs {
file_path: "posts/article.md",
frontmatter_title: Some("Custom"),
body_markdown: ":::hero\n# Custom\n:::\n\nBody.",
root_folder_name: None,
is_home_override: false,
slot_only: false,
});
assert!(!s.visible, "hero ownership trumps title presence");
assert_eq!(s.text, "Custom");
}
#[test]
fn an_image_only_hero_does_not_take_the_title_with_it() {
let s = compute(HeadingInputs {
file_path: "awards/writing/s4/part-one.md",
frontmatter_title: Some("在前線,一座文學博物館的抵抗"),
body_markdown: ":::hero {image=assets/cover.jpg}\n:::\n\n正文。",
root_folder_name: None,
is_home_override: false,
slot_only: false,
});
assert!(s.visible, "an empty hero renders no title, so the page keeps its own");
assert_eq!(s.text, "在前線,一座文學博物館的抵抗");
}
#[test]
fn hero_only_detected_at_top_not_mid_body() {
let s = compute(HeadingInputs {
file_path: "posts/article.md",
frontmatter_title: None,
body_markdown: "Some intro paragraph.\n\n:::hero\n:::",
root_folder_name: None,
is_home_override: false,
slot_only: false,
});
assert!(s.visible, "hero anywhere but at top does not own heading");
}
#[test]
fn hero_detection_skips_leading_blank_lines() {
let s = compute(HeadingInputs {
file_path: "posts/article.md",
frontmatter_title: None,
body_markdown: "\n\n\n:::hero\n# Overlay\n:::",
root_folder_name: None,
is_home_override: false,
slot_only: false,
});
assert!(!s.visible, "leading blanks before :::hero still count as 'at top'");
}
#[test]
fn hidden_when_translation_home() {
let s = compute(HeadingInputs {
file_path: "posts/article.md",
frontmatter_title: None,
body_markdown: "",
root_folder_name: None,
is_home_override: true,
slot_only: false,
});
assert!(!s.visible);
}
#[test]
fn slot_only_hides_heading_regardless_of_title() {
let s = compute(HeadingInputs {
file_path: "footer.md",
frontmatter_title: Some("Custom"),
body_markdown: "[link](https://example.com)",
root_folder_name: None,
is_home_override: false,
slot_only: true,
});
assert!(
!s.visible,
"slot_only must suppress the auto-injected H1 even when title: is set"
);
assert_eq!(s.text, "Custom");
}
#[test]
fn slot_only_hides_heading_when_title_absent() {
let s = compute(HeadingInputs {
file_path: "footer.md",
frontmatter_title: None,
body_markdown: "Studio · 2026",
root_folder_name: None,
is_home_override: false,
slot_only: true,
});
assert!(!s.visible);
}
#[test]
fn a_hero_owns_the_title_slot_only_when_it_has_content_to_put_in_it() {
assert!(hero_at_top_owns_title("\n\n:::hero\nimage: x\n:::"));
assert!(hero_at_top_owns_title(":::hero {image=c.jpg}\n# Overlay title\n:::"));
assert!(hero_at_top_owns_title(":::hero\n# Overlay title"));
assert!(!hero_at_top_owns_title(":::hero\n:::"));
assert!(!hero_at_top_owns_title(":::hero {image=cover.jpg}\n:::"));
assert!(!hero_at_top_owns_title(":::hero attr=value\n\n\n:::"));
assert!(!hero_at_top_owns_title(":::hero {image=c.jpg caption=\"A street\"}\n:::"));
assert!(!hero_at_top_owns_title("# Heading\n:::hero\n# Overlay\n:::"));
assert!(!hero_at_top_owns_title("Some prose first.\n\n:::hero\n# Overlay\n:::"));
assert!(!hero_at_top_owns_title(""));
assert!(!hero_at_top_owns_title("\n\n"));
}
}