1use crate::home;
23
24#[cfg_attr(feature = "specta", derive(specta::Type))]
32#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
33pub struct HeadingState {
34 pub visible: bool,
36 pub text: String,
38 pub source: HeadingSource,
40}
41
42#[cfg_attr(feature = "specta", derive(specta::Type))]
45#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
46pub enum HeadingSource {
47 Filename,
49 Title,
52}
53
54#[derive(Debug, Clone, Copy)]
56pub struct HeadingInputs<'a> {
57 pub file_path: &'a str,
58 pub frontmatter_title: Option<&'a str>,
62 pub body_markdown: &'a str,
66 pub root_folder_name: Option<&'a str>,
76 pub is_translation_home: bool,
80 pub slot_only: bool,
88}
89
90pub fn filename_text(file_path: &str) -> String {
93 let path = std::path::Path::new(file_path);
94 let stem = path
95 .file_stem()
96 .and_then(|s| s.to_str())
97 .unwrap_or("Untitled");
98 let parent_name = path
99 .parent()
100 .and_then(|p| p.file_name())
101 .and_then(|s| s.to_str());
102 let is_folder_note = home::is_index_stem(stem)
103 || parent_name.is_some_and(|p| p.eq_ignore_ascii_case(stem));
104 let source_name = if is_folder_note {
105 parent_name.unwrap_or(stem)
106 } else {
107 stem
108 };
109 source_name.replace('-', " ").replace('_', " ")
110}
111
112pub fn body_starts_with_hero(body_markdown: &str) -> bool {
116 body_markdown
117 .lines()
118 .find(|line| !line.trim().is_empty())
119 .map(|line| {
120 let trimmed = line.trim_start();
121 trimmed == ":::hero"
122 || trimmed.starts_with(":::hero ")
123 || trimmed.starts_with(":::hero\t")
124 })
125 .unwrap_or(false)
126}
127
128pub fn compute(input: HeadingInputs<'_>) -> HeadingState {
130 let path = std::path::Path::new(input.file_path);
131
132 let (text, source) = match input.frontmatter_title {
135 Some(t) => (t.trim().to_string(), HeadingSource::Title),
136 None => (filename_text(input.file_path), HeadingSource::Filename),
137 };
138
139 let is_markdown = matches!(
140 path.extension()
141 .and_then(|e| e.to_str())
142 .map(|s| s.to_lowercase())
143 .as_deref(),
144 Some("md") | Some("mdx") | Some("markdown")
145 );
146
147 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
148 let parent_from_path = path
154 .parent()
155 .and_then(|p| p.file_name())
156 .and_then(|s| s.to_str())
157 .unwrap_or("");
158 let parent_name = if parent_from_path.is_empty() {
159 input.root_folder_name.unwrap_or("")
160 } else {
161 parent_from_path
162 };
163 let filename_lower = stem.to_lowercase();
164 let is_index_file =
165 home::is_home_file(&filename_lower, parent_name) || input.is_translation_home;
166
167 let empty_title = source == HeadingSource::Title && text.is_empty();
168 let hero_at_top = body_starts_with_hero(input.body_markdown);
169
170 let visible =
171 is_markdown && !is_index_file && !empty_title && !hero_at_top && !input.slot_only;
172
173 HeadingState { visible, text, source }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 fn inputs<'a>(file_path: &'a str, frontmatter_title: Option<&'a str>) -> HeadingInputs<'a> {
181 HeadingInputs {
182 file_path,
183 frontmatter_title,
184 body_markdown: "",
185 root_folder_name: None,
186 is_translation_home: false,
187 slot_only: false,
188 }
189 }
190
191 #[test]
194 fn text_article_uses_filename() {
195 assert_eq!(filename_text("posts/my-first-post.md"), "my first post");
196 }
197
198 #[test]
199 fn text_underscore_normalizes_to_space() {
200 assert_eq!(filename_text("posts/my_first_post.md"), "my first post");
201 }
202
203 #[test]
204 fn text_index_uses_parent_folder() {
205 assert_eq!(filename_text("site/index.md"), "site");
206 }
207
208 #[test]
209 fn text_readme_uses_parent_folder() {
210 assert_eq!(filename_text("docs/README.md"), "docs");
211 }
212
213 #[test]
214 fn text_self_named_folder_note_uses_parent() {
215 assert_eq!(filename_text("recipes/recipes.md"), "recipes");
216 }
217
218 #[test]
219 fn text_root_level_no_parent() {
220 assert_eq!(filename_text("about.md"), "about");
221 }
222
223 #[test]
224 fn text_cjk_filename_preserved() {
225 assert_eq!(filename_text("文字/民歌.md"), "民歌");
226 }
227
228 #[test]
229 fn text_cjk_index_uses_parent() {
230 assert_eq!(filename_text("文字/index.md"), "文字");
231 }
232
233 #[test]
236 fn visible_for_article_md() {
237 let s = compute(inputs("posts/my-first-post.md", None));
238 assert!(s.visible);
239 assert_eq!(s.text, "my first post");
240 assert!(matches!(s.source, HeadingSource::Filename));
241 }
242
243 #[test]
244 fn hidden_for_index_file() {
245 let s = compute(inputs("site/index.md", None));
246 assert!(!s.visible);
247 assert_eq!(s.text, "site");
248 }
249
250 #[test]
251 fn hidden_for_readme() {
252 let s = compute(inputs("docs/README.md", None));
253 assert!(!s.visible);
254 assert_eq!(s.text, "docs");
255 }
256
257 #[test]
258 fn hidden_for_self_named_folder_note() {
259 let s = compute(inputs("recipes/recipes.md", None));
260 assert!(!s.visible);
261 assert_eq!(s.text, "recipes");
262 }
263
264 #[test]
265 fn hidden_for_root_self_named_home_with_root_folder_name() {
266 let s = compute(HeadingInputs {
271 file_path: "刘果.md",
272 frontmatter_title: None,
273 body_markdown: "",
274 root_folder_name: Some("刘果"),
275 is_translation_home: false,
276 slot_only: false,
277 });
278 assert!(!s.visible, "root-level self-named home file must hide H1");
279 }
280
281 #[test]
282 fn root_index_md_still_hidden_without_root_folder_name() {
283 let s = compute(HeadingInputs {
287 file_path: "index.md",
288 frontmatter_title: None,
289 body_markdown: "",
290 root_folder_name: None,
291 is_translation_home: false,
292 slot_only: false,
293 });
294 assert!(!s.visible);
295 }
296
297 #[test]
298 fn hidden_for_non_markdown() {
299 let s = compute(inputs("assets/style.css", None));
300 assert!(!s.visible);
301 }
302
303 #[test]
304 fn visible_for_mdx() {
305 let s = compute(inputs("posts/article.mdx", None));
306 assert!(s.visible);
307 assert_eq!(s.text, "article");
308 }
309
310 #[test]
311 fn visible_for_root_level_article() {
312 let s = compute(inputs("about.md", None));
313 assert!(s.visible);
314 assert_eq!(s.text, "about");
315 }
316
317 #[test]
318 fn visible_for_cjk_article() {
319 let s = compute(inputs("文字/民歌.md", None));
320 assert!(s.visible);
321 assert_eq!(s.text, "民歌");
322 }
323
324 #[test]
325 fn hidden_for_cjk_index() {
326 let s = compute(inputs("文字/index.md", None));
327 assert!(!s.visible);
328 assert_eq!(s.text, "文字");
329 }
330
331 #[test]
334 fn source_is_title_when_frontmatter_title_set() {
335 let s = compute(inputs("posts/article.md", Some("Custom")));
336 assert_eq!(s.text, "Custom");
337 assert!(matches!(s.source, HeadingSource::Title));
338 assert!(s.visible);
339 }
340
341 #[test]
342 fn source_is_filename_when_title_absent() {
343 let s = compute(inputs("posts/article.md", None));
344 assert!(matches!(s.source, HeadingSource::Filename));
345 assert_eq!(s.text, "article");
346 assert!(s.visible);
347 }
348
349 #[test]
350 fn empty_title_produces_invisible_state() {
351 let s = compute(inputs("posts/article.md", Some("")));
352 assert!(matches!(s.source, HeadingSource::Title));
353 assert_eq!(s.text, "");
354 assert!(!s.visible, "title: \"\" suppresses the auto-injected H1");
355 }
356
357 #[test]
358 fn whitespace_title_produces_invisible_state() {
359 let s = compute(inputs("posts/article.md", Some(" ")));
360 assert!(matches!(s.source, HeadingSource::Title));
361 assert_eq!(s.text, "");
362 assert!(!s.visible);
363 }
364
365 #[test]
366 fn title_overrides_index_visibility_unchanged() {
367 let s = compute(inputs("site/index.md", Some("Welcome")));
368 assert!(!s.visible, "index pages still don't auto-inject");
369 assert!(matches!(s.source, HeadingSource::Title));
370 assert_eq!(s.text, "Welcome");
371 }
372
373 #[test]
374 fn title_text_is_trimmed() {
375 let s = compute(inputs("posts/article.md", Some(" Custom ")));
376 assert_eq!(s.text, "Custom");
377 assert!(s.visible);
378 }
379
380 #[test]
383 fn hero_at_top_hides_heading_when_title_absent() {
384 let s = compute(HeadingInputs {
385 file_path: "posts/article.md",
386 frontmatter_title: None,
387 body_markdown: ":::hero\nimage: x.jpg\n:::\n\nBody.",
388 root_folder_name: None,
389 is_translation_home: false,
390 slot_only: false,
391 });
392 assert!(!s.visible);
393 assert_eq!(s.text, "article");
394 }
395
396 #[test]
397 fn hero_at_top_hides_heading_when_title_set() {
398 let s = compute(HeadingInputs {
399 file_path: "posts/article.md",
400 frontmatter_title: Some("Custom"),
401 body_markdown: ":::hero\n:::\n\nBody.",
402 root_folder_name: None,
403 is_translation_home: false,
404 slot_only: false,
405 });
406 assert!(!s.visible, "hero ownership trumps title presence");
407 assert_eq!(s.text, "Custom");
408 }
409
410 #[test]
411 fn hero_only_detected_at_top_not_mid_body() {
412 let s = compute(HeadingInputs {
413 file_path: "posts/article.md",
414 frontmatter_title: None,
415 body_markdown: "Some intro paragraph.\n\n:::hero\n:::",
416 root_folder_name: None,
417 is_translation_home: false,
418 slot_only: false,
419 });
420 assert!(s.visible, "hero anywhere but at top does not own heading");
421 }
422
423 #[test]
424 fn hero_detection_skips_leading_blank_lines() {
425 let s = compute(HeadingInputs {
426 file_path: "posts/article.md",
427 frontmatter_title: None,
428 body_markdown: "\n\n\n:::hero\n:::",
429 root_folder_name: None,
430 is_translation_home: false,
431 slot_only: false,
432 });
433 assert!(!s.visible, "leading blanks before :::hero still count as 'at top'");
434 }
435
436 #[test]
439 fn hidden_when_translation_home() {
440 let s = compute(HeadingInputs {
441 file_path: "posts/article.md",
442 frontmatter_title: None,
443 body_markdown: "",
444 root_folder_name: None,
445 is_translation_home: true,
446 slot_only: false,
447 });
448 assert!(!s.visible);
449 }
450
451 #[test]
454 fn slot_only_hides_heading_regardless_of_title() {
455 let s = compute(HeadingInputs {
461 file_path: "footer.md",
462 frontmatter_title: Some("Custom"),
463 body_markdown: "[link](https://example.com)",
464 root_folder_name: None,
465 is_translation_home: false,
466 slot_only: true,
467 });
468 assert!(
469 !s.visible,
470 "slot_only must suppress the auto-injected H1 even when title: is set"
471 );
472 assert_eq!(s.text, "Custom");
474 }
475
476 #[test]
477 fn slot_only_hides_heading_when_title_absent() {
478 let s = compute(HeadingInputs {
479 file_path: "footer.md",
480 frontmatter_title: None,
481 body_markdown: "Studio · 2026",
482 root_folder_name: None,
483 is_translation_home: false,
484 slot_only: true,
485 });
486 assert!(!s.visible);
487 }
488
489 #[test]
492 fn body_starts_with_hero_basic() {
493 assert!(body_starts_with_hero(":::hero\n:::"));
494 assert!(body_starts_with_hero("\n\n:::hero\nimage: x\n:::"));
495 assert!(body_starts_with_hero(":::hero attr=value\n:::"));
496 assert!(!body_starts_with_hero("# Heading\n:::hero\n:::"));
497 assert!(!body_starts_with_hero("Some prose first.\n\n:::hero\n:::"));
498 assert!(!body_starts_with_hero(""));
499 assert!(!body_starts_with_hero("\n\n"));
500 }
501}