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_home_override: bool,
80 pub slot_only: bool,
88}
89
90pub fn filename_text(file_path: &str) -> String {
98 filename_text_with_root(file_path, None)
99}
100
101pub fn filename_text_with_root(file_path: &str, root_folder_name: Option<&str>) -> String {
112 let path = std::path::Path::new(file_path);
113 let stem = path
114 .file_stem()
115 .and_then(|s| s.to_str())
116 .unwrap_or("Untitled");
117 let parent_name = path
122 .parent()
123 .and_then(|p| p.file_name())
124 .and_then(|s| s.to_str())
125 .or(root_folder_name);
126 let is_folder_note = home::is_index_stem(stem)
127 || parent_name.is_some_and(|p| p.eq_ignore_ascii_case(stem));
128 let source_name = if is_folder_note {
129 parent_name.unwrap_or(stem)
130 } else {
131 stem
132 };
133 source_name.replace('-', " ").replace('_', " ")
134}
135
136pub fn body_starts_with_hero(body_markdown: &str) -> bool {
140 body_markdown
141 .lines()
142 .find(|line| !line.trim().is_empty())
143 .map(|line| {
144 let trimmed = line.trim_start();
145 trimmed == ":::hero"
146 || trimmed.starts_with(":::hero ")
147 || trimmed.starts_with(":::hero\t")
148 })
149 .unwrap_or(false)
150}
151
152pub fn compute(input: HeadingInputs<'_>) -> HeadingState {
154 let path = std::path::Path::new(input.file_path);
155
156 let (text, source) = match input.frontmatter_title {
161 Some(t) => (t.trim().to_string(), HeadingSource::Title),
162 None => (
163 filename_text_with_root(input.file_path, input.root_folder_name),
164 HeadingSource::Filename,
165 ),
166 };
167
168 let is_markdown = matches!(
169 path.extension()
170 .and_then(|e| e.to_str())
171 .map(|s| s.to_lowercase())
172 .as_deref(),
173 Some("md") | Some("mdx") | Some("markdown")
174 );
175
176 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
177 let parent_from_path = path
183 .parent()
184 .and_then(|p| p.file_name())
185 .and_then(|s| s.to_str())
186 .unwrap_or("");
187 let parent_name = if parent_from_path.is_empty() {
188 input.root_folder_name.unwrap_or("")
189 } else {
190 parent_from_path
191 };
192 let filename_lower = stem.to_lowercase();
193 let is_index_file =
194 home::is_home_file(&filename_lower, parent_name) || input.is_home_override;
195
196 let empty_title = source == HeadingSource::Title && text.is_empty();
197 let hero_at_top = body_starts_with_hero(input.body_markdown);
198
199 let visible =
200 is_markdown && !is_index_file && !empty_title && !hero_at_top && !input.slot_only;
201
202 HeadingState { visible, text, source }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 fn inputs<'a>(file_path: &'a str, frontmatter_title: Option<&'a str>) -> HeadingInputs<'a> {
210 HeadingInputs {
211 file_path,
212 frontmatter_title,
213 body_markdown: "",
214 root_folder_name: None,
215 is_home_override: false,
216 slot_only: false,
217 }
218 }
219
220 #[test]
223 fn text_article_uses_filename() {
224 assert_eq!(filename_text("posts/my-first-post.md"), "my first post");
225 }
226
227 #[test]
228 fn text_underscore_normalizes_to_space() {
229 assert_eq!(filename_text("posts/my_first_post.md"), "my first post");
230 }
231
232 #[test]
233 fn text_index_uses_parent_folder() {
234 assert_eq!(filename_text("site/index.md"), "site");
235 }
236
237 #[test]
238 fn text_readme_uses_parent_folder() {
239 assert_eq!(filename_text("docs/README.md"), "docs");
240 }
241
242 #[test]
243 fn text_self_named_folder_note_uses_parent() {
244 assert_eq!(filename_text("recipes/recipes.md"), "recipes");
245 }
246
247 #[test]
248 fn text_root_level_no_parent() {
249 assert_eq!(filename_text("about.md"), "about");
250 }
251
252 #[test]
253 fn text_cjk_filename_preserved() {
254 assert_eq!(filename_text("文字/民歌.md"), "民歌");
255 }
256
257 #[test]
258 fn text_cjk_index_uses_parent() {
259 assert_eq!(filename_text("文字/index.md"), "文字");
260 }
261
262 #[test]
265 fn text_root_index_uses_root_folder_name() {
266 assert_eq!(
272 filename_text_with_root("index.md", Some("My Site")),
273 "My Site"
274 );
275 }
276
277 #[test]
278 fn text_root_index_no_root_name_falls_back_to_stem() {
279 assert_eq!(filename_text_with_root("index.md", None), "index");
281 }
282
283 #[test]
284 fn text_root_self_named_uses_root_folder_name() {
285 assert_eq!(
287 filename_text_with_root("刘果.md", Some("刘果")),
288 "刘果"
289 );
290 }
291
292 #[test]
293 fn text_with_root_nested_index_unaffected_by_root_name() {
294 assert_eq!(
297 filename_text_with_root("site/index.md", Some("My Site")),
298 "site"
299 );
300 }
301
302 #[test]
303 fn text_with_root_article_unaffected() {
304 assert_eq!(
307 filename_text_with_root("about.md", Some("My Site")),
308 "about"
309 );
310 }
311
312 #[test]
313 fn compute_root_index_text_is_root_folder_name() {
314 let s = compute(HeadingInputs {
317 file_path: "index.md",
318 frontmatter_title: None,
319 body_markdown: "",
320 root_folder_name: Some("My Site"),
321 is_home_override: false,
322 slot_only: false,
323 });
324 assert!(!s.visible, "root index still suppresses the auto H1");
325 assert_eq!(s.text, "My Site");
326 }
327
328 #[test]
331 fn visible_for_article_md() {
332 let s = compute(inputs("posts/my-first-post.md", None));
333 assert!(s.visible);
334 assert_eq!(s.text, "my first post");
335 assert!(matches!(s.source, HeadingSource::Filename));
336 }
337
338 #[test]
339 fn hidden_for_index_file() {
340 let s = compute(inputs("site/index.md", None));
341 assert!(!s.visible);
342 assert_eq!(s.text, "site");
343 }
344
345 #[test]
346 fn hidden_for_readme() {
347 let s = compute(inputs("docs/README.md", None));
348 assert!(!s.visible);
349 assert_eq!(s.text, "docs");
350 }
351
352 #[test]
353 fn hidden_for_self_named_folder_note() {
354 let s = compute(inputs("recipes/recipes.md", None));
355 assert!(!s.visible);
356 assert_eq!(s.text, "recipes");
357 }
358
359 #[test]
360 fn hidden_for_root_self_named_home_with_root_folder_name() {
361 let s = compute(HeadingInputs {
366 file_path: "刘果.md",
367 frontmatter_title: None,
368 body_markdown: "",
369 root_folder_name: Some("刘果"),
370 is_home_override: false,
371 slot_only: false,
372 });
373 assert!(!s.visible, "root-level self-named home file must hide H1");
374 }
375
376 #[test]
377 fn root_index_md_still_hidden_without_root_folder_name() {
378 let s = compute(HeadingInputs {
382 file_path: "index.md",
383 frontmatter_title: None,
384 body_markdown: "",
385 root_folder_name: None,
386 is_home_override: false,
387 slot_only: false,
388 });
389 assert!(!s.visible);
390 }
391
392 #[test]
393 fn hidden_for_non_markdown() {
394 let s = compute(inputs("assets/style.css", None));
395 assert!(!s.visible);
396 }
397
398 #[test]
399 fn visible_for_mdx() {
400 let s = compute(inputs("posts/article.mdx", None));
401 assert!(s.visible);
402 assert_eq!(s.text, "article");
403 }
404
405 #[test]
406 fn visible_for_root_level_article() {
407 let s = compute(inputs("about.md", None));
408 assert!(s.visible);
409 assert_eq!(s.text, "about");
410 }
411
412 #[test]
413 fn visible_for_cjk_article() {
414 let s = compute(inputs("文字/民歌.md", None));
415 assert!(s.visible);
416 assert_eq!(s.text, "民歌");
417 }
418
419 #[test]
420 fn hidden_for_cjk_index() {
421 let s = compute(inputs("文字/index.md", None));
422 assert!(!s.visible);
423 assert_eq!(s.text, "文字");
424 }
425
426 #[test]
429 fn source_is_title_when_frontmatter_title_set() {
430 let s = compute(inputs("posts/article.md", Some("Custom")));
431 assert_eq!(s.text, "Custom");
432 assert!(matches!(s.source, HeadingSource::Title));
433 assert!(s.visible);
434 }
435
436 #[test]
437 fn source_is_filename_when_title_absent() {
438 let s = compute(inputs("posts/article.md", None));
439 assert!(matches!(s.source, HeadingSource::Filename));
440 assert_eq!(s.text, "article");
441 assert!(s.visible);
442 }
443
444 #[test]
445 fn empty_title_produces_invisible_state() {
446 let s = compute(inputs("posts/article.md", Some("")));
447 assert!(matches!(s.source, HeadingSource::Title));
448 assert_eq!(s.text, "");
449 assert!(!s.visible, "title: \"\" suppresses the auto-injected H1");
450 }
451
452 #[test]
453 fn whitespace_title_produces_invisible_state() {
454 let s = compute(inputs("posts/article.md", Some(" ")));
455 assert!(matches!(s.source, HeadingSource::Title));
456 assert_eq!(s.text, "");
457 assert!(!s.visible);
458 }
459
460 #[test]
461 fn title_overrides_index_visibility_unchanged() {
462 let s = compute(inputs("site/index.md", Some("Welcome")));
463 assert!(!s.visible, "index pages still don't auto-inject");
464 assert!(matches!(s.source, HeadingSource::Title));
465 assert_eq!(s.text, "Welcome");
466 }
467
468 #[test]
469 fn title_text_is_trimmed() {
470 let s = compute(inputs("posts/article.md", Some(" Custom ")));
471 assert_eq!(s.text, "Custom");
472 assert!(s.visible);
473 }
474
475 #[test]
478 fn hero_at_top_hides_heading_when_title_absent() {
479 let s = compute(HeadingInputs {
480 file_path: "posts/article.md",
481 frontmatter_title: None,
482 body_markdown: ":::hero\nimage: x.jpg\n:::\n\nBody.",
483 root_folder_name: None,
484 is_home_override: false,
485 slot_only: false,
486 });
487 assert!(!s.visible);
488 assert_eq!(s.text, "article");
489 }
490
491 #[test]
492 fn hero_at_top_hides_heading_when_title_set() {
493 let s = compute(HeadingInputs {
494 file_path: "posts/article.md",
495 frontmatter_title: Some("Custom"),
496 body_markdown: ":::hero\n:::\n\nBody.",
497 root_folder_name: None,
498 is_home_override: false,
499 slot_only: false,
500 });
501 assert!(!s.visible, "hero ownership trumps title presence");
502 assert_eq!(s.text, "Custom");
503 }
504
505 #[test]
506 fn hero_only_detected_at_top_not_mid_body() {
507 let s = compute(HeadingInputs {
508 file_path: "posts/article.md",
509 frontmatter_title: None,
510 body_markdown: "Some intro paragraph.\n\n:::hero\n:::",
511 root_folder_name: None,
512 is_home_override: false,
513 slot_only: false,
514 });
515 assert!(s.visible, "hero anywhere but at top does not own heading");
516 }
517
518 #[test]
519 fn hero_detection_skips_leading_blank_lines() {
520 let s = compute(HeadingInputs {
521 file_path: "posts/article.md",
522 frontmatter_title: None,
523 body_markdown: "\n\n\n:::hero\n:::",
524 root_folder_name: None,
525 is_home_override: false,
526 slot_only: false,
527 });
528 assert!(!s.visible, "leading blanks before :::hero still count as 'at top'");
529 }
530
531 #[test]
534 fn hidden_when_translation_home() {
535 let s = compute(HeadingInputs {
536 file_path: "posts/article.md",
537 frontmatter_title: None,
538 body_markdown: "",
539 root_folder_name: None,
540 is_home_override: true,
541 slot_only: false,
542 });
543 assert!(!s.visible);
544 }
545
546 #[test]
549 fn slot_only_hides_heading_regardless_of_title() {
550 let s = compute(HeadingInputs {
556 file_path: "footer.md",
557 frontmatter_title: Some("Custom"),
558 body_markdown: "[link](https://example.com)",
559 root_folder_name: None,
560 is_home_override: false,
561 slot_only: true,
562 });
563 assert!(
564 !s.visible,
565 "slot_only must suppress the auto-injected H1 even when title: is set"
566 );
567 assert_eq!(s.text, "Custom");
569 }
570
571 #[test]
572 fn slot_only_hides_heading_when_title_absent() {
573 let s = compute(HeadingInputs {
574 file_path: "footer.md",
575 frontmatter_title: None,
576 body_markdown: "Studio · 2026",
577 root_folder_name: None,
578 is_home_override: false,
579 slot_only: true,
580 });
581 assert!(!s.visible);
582 }
583
584 #[test]
587 fn body_starts_with_hero_basic() {
588 assert!(body_starts_with_hero(":::hero\n:::"));
589 assert!(body_starts_with_hero("\n\n:::hero\nimage: x\n:::"));
590 assert!(body_starts_with_hero(":::hero attr=value\n:::"));
591 assert!(!body_starts_with_hero("# Heading\n:::hero\n:::"));
592 assert!(!body_starts_with_hero("Some prose first.\n\n:::hero\n:::"));
593 assert!(!body_starts_with_hero(""));
594 assert!(!body_starts_with_hero("\n\n"));
595 }
596}