moss_core/heading/state.rs
1//! Article heading rule — single source of truth for the auto-injected
2//! `<h1 class="moss-article-title">` and the editor's pinned heading element.
3//!
4//! Both consumers — the build pipeline (`src-tauri/src/build/markdown/pipeline.rs`)
5//! and the editor command `compute_heading_state`
6//! (`src-tauri/src/editor/commands.rs`) — feed the same inputs into [`compute`]
7//! and act on the same answer. Without this module the two paths silently
8//! desync the next time the rule changes.
9//!
10//! The rule:
11//! - Markdown files only.
12//! - Index/folder pages never get the auto-injected H1.
13//! - Frontmatter `title:` drives the heading text and source:
14//! - missing → filename-mode (text from filename, source = Filename)
15//! - non-empty → title-mode (text from title:, source = Title, visible)
16//! - empty `""` or whitespace-only → title-mode + invisible (explicit no-heading)
17//! - A `:::hero` block at the top of the body owns the title slot — no
18//! auto-injection regardless of title — but only if the block has
19//! something in it. An image-only hero renders no title of its own, so
20//! letting it claim the slot loses the title outright.
21//!
22//! See `docs/reference/title-rendering.md`.
23
24use crate::home;
25
26/// Resolved heading state for a single page.
27///
28/// `visible` is the gate the build pipeline checks before injecting an
29/// `<h1 class="moss-article-title">` and the editor checks before rendering
30/// its pinned heading element. `source` tells the editor where `text` came
31/// from so it can route heading-element commits between rename (Filename)
32/// and title-update (Title).
33#[cfg_attr(feature = "specta", derive(specta::Type))]
34#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35pub struct HeadingState {
36 /// Whether the article heading should render.
37 pub visible: bool,
38 /// The visible heading text (resolved through title or filename).
39 pub text: String,
40 /// Where `text` was sourced from.
41 pub source: HeadingSource,
42}
43
44/// Where the resolved heading text came from. New variants are added when a
45/// new origin is introduced; consumers handle them exhaustively.
46#[cfg_attr(feature = "specta", derive(specta::Type))]
47#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
48pub enum HeadingSource {
49 /// Text derived from filename (or parent folder for index notes).
50 Filename,
51 /// Text from frontmatter `title:`. `Some("")` and `Some(" ")` count as
52 /// Title source with empty text — `visible` will be false in that case.
53 Title,
54}
55
56/// Inputs to the heading rule.
57#[derive(Debug, Clone, Copy)]
58pub struct HeadingInputs<'a> {
59 pub file_path: &'a str,
60 /// Frontmatter `title:` value verbatim. `None` is "field missing"
61 /// (filename-mode); `Some("")`, `Some(" ")` is "explicitly empty"
62 /// (Title source, invisible).
63 pub frontmatter_title: Option<&'a str>,
64 /// Raw markdown body (post-frontmatter). Used to detect a leading
65 /// `:::hero` block — when present, the hero owns the heading slot and
66 /// the auto-injected H1 is suppressed regardless of title.
67 pub body_markdown: &'a str,
68 /// The project's root folder name (the user's vault directory basename).
69 /// Used as a fallback for self-named-folder-note detection when the
70 /// file is at the project root: `<root>/<root>.md` and `<root>/index.md`
71 /// both render the project's homepage, but `Path::parent()` returns
72 /// the empty path for root-level files, so the path-based check can't
73 /// see the folder name. Editor and pipeline both pass the basename of
74 /// the open project folder. Pass `None` if unknown (filename
75 /// detection still catches index.md / README.md / language-suffixed
76 /// indexes).
77 pub root_folder_name: Option<&'a str>,
78 /// `true` iff the file is promoted to its folder's home via the
79 /// `home: true` frontmatter marker (issue #587). Pipeline-only input;
80 /// editor passes `false`.
81 pub is_home_override: bool,
82 /// `true` iff the file is a layout-slot source (e.g. root `footer.md`)
83 /// rather than an article. Slot files are embedded as fragments into a
84 /// surrounding layout; the auto-injected `<h1 class="moss-article-title">`
85 /// would render as an unwanted heading inside that fragment. PR7b
86 /// (moss#599) replaces the pre-2026-05-28 frontmatter-synthesis hack
87 /// (`title: ""` injected at the call site to drive `empty_title=true`)
88 /// with this structural input. Editor passes `false`.
89 pub slot_only: bool,
90}
91
92/// Compute just the visible heading text for a file path. Used by callers
93/// that need the text before the full state.
94///
95/// Not root-aware: a root-level index-stem (`index.md` with no path parent)
96/// resolves to the stem itself ("index"). Use [`filename_text_with_root`]
97/// when the project's root folder name is available so the site root's home
98/// page reads the folder name instead. See #775.
99pub fn filename_text(file_path: &str) -> String {
100 filename_text_with_root(file_path, None)
101}
102
103/// Like [`filename_text`], but root-aware: for an index-stem / self-named
104/// folder note at the project root (where `Path::parent()` has no
105/// `file_name`), the resolved text is `root_folder_name` rather than the bare
106/// stem. This is the fix for #775 — a root `index.md` home page must read the
107/// folder name in `<title>`/chrome, not "index".
108///
109/// Mirrors the parent-name resolution in [`compute`] (`heading/state.rs` root
110/// fallback) so the text answer and the visibility answer agree at the root.
111/// No title-casing: hyphens/underscores become spaces, everything else is
112/// verbatim (same rule as the nested case).
113pub fn filename_text_with_root(file_path: &str, root_folder_name: Option<&str>) -> String {
114 let path = std::path::Path::new(file_path);
115 let stem = path
116 .file_stem()
117 .and_then(|s| s.to_str())
118 .unwrap_or("Untitled");
119 // Parent folder name from the path, falling back to the project root
120 // folder name for root-level files (whose `Path::parent()` yields the
121 // empty path with no `file_name`). This is what lets a root `index.md`
122 // or self-named `<root>/<root>.md` resolve to the folder name.
123 let parent_name = path
124 .parent()
125 .and_then(|p| p.file_name())
126 .and_then(|s| s.to_str())
127 .or(root_folder_name);
128 let is_folder_note = home::is_index_stem(stem)
129 || parent_name.is_some_and(|p| p.eq_ignore_ascii_case(stem));
130 let source_name = if is_folder_note {
131 parent_name.unwrap_or(stem)
132 } else {
133 stem
134 };
135 source_name.replace('-', " ").replace('_', " ")
136}
137
138/// Whether a `:::hero` at the top of the body takes over the page's title
139/// slot — meaning moss must NOT inject its own `<h1>` above it.
140///
141/// Two conditions, and the second one is the whole point:
142///
143/// 1. the first non-blank line opens a hero (`:::hero`, optionally followed by
144/// attributes), and
145/// 2. **the block has something inside it.**
146///
147/// Without (2) this returned `true` for `:::hero {image=cover.jpg}` / `:::` —
148/// an image-only cover, which is the obvious way to write "full-bleed cover
149/// photo" and by far the commonest hero there is. moss suppressed the title,
150/// and the hero renderer draws only the image, the overlay and the caption; it
151/// never consults `doc.title`. So the slot was handed to a component that put
152/// nothing in it and the title vanished from the page — silently, because
153/// `<title>`, the OG tags and RSS all resolve the same text by other paths.
154/// One site adopted full-bleed covers in two commits and lost the visible
155/// heading on 87 pages at once.
156///
157/// "Something inside" rather than "a heading inside" is deliberate: any
158/// overlay content means the author put *something* in the title slot, and
159/// widening the test to look for `#` would change what already-shipped sites
160/// render. This is the narrowest rule that fixes the empty case, so no hero
161/// that renders anything today changes behaviour.
162///
163/// Attributes on the opening line do not count as content — `caption=` and
164/// `image=` live there, and neither is a title.
165pub fn hero_at_top_owns_title(body_markdown: &str) -> bool {
166 let mut lines = body_markdown
167 .lines()
168 .skip_while(|line| line.trim().is_empty());
169
170 let Some(open) = lines.next() else {
171 return false;
172 };
173 let trimmed = open.trim_start();
174 let opens_hero = trimmed == ":::hero"
175 || trimmed.starts_with(":::hero ")
176 || trimmed.starts_with(":::hero\t");
177 if !opens_hero {
178 return false;
179 }
180
181 // Stop at the block's own fence. An unterminated hero runs to the end of
182 // the body, which is what the renderer does with it too.
183 lines
184 .take_while(|line| line.trim() != ":::")
185 .any(|line| !line.trim().is_empty())
186}
187
188/// Compute the full heading state for a page.
189pub fn compute(input: HeadingInputs<'_>) -> HeadingState {
190 let path = std::path::Path::new(input.file_path);
191
192 // Resolve text + source from frontmatter.title before all other rules.
193 // Some(_) → Title source (empty allowed); None → Filename source.
194 // Filename mode is root-aware: a root index-stem / self-named home
195 // resolves to the project's folder name, not the bare "index" stem (#775).
196 let (text, source) = match input.frontmatter_title {
197 Some(t) => (t.trim().to_string(), HeadingSource::Title),
198 None => (
199 filename_text_with_root(input.file_path, input.root_folder_name),
200 HeadingSource::Filename,
201 ),
202 };
203
204 let is_markdown = matches!(
205 path.extension()
206 .and_then(|e| e.to_str())
207 .map(|s| s.to_lowercase())
208 .as_deref(),
209 Some("md") | Some("mdx") | Some("markdown")
210 );
211
212 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
213 // Resolve parent folder name. For nested files (`recipes/recipes.md`)
214 // we pull from the path. For ROOT-level files (`刘果.md` in a vault
215 // named `刘果`) Path::parent() returns the empty path with no
216 // file_name, so we fall back to `root_folder_name` — letting
217 // self-named-home detection work at the project root.
218 let parent_from_path = path
219 .parent()
220 .and_then(|p| p.file_name())
221 .and_then(|s| s.to_str())
222 .unwrap_or("");
223 let parent_name = if parent_from_path.is_empty() {
224 input.root_folder_name.unwrap_or("")
225 } else {
226 parent_from_path
227 };
228 let filename_lower = stem.to_lowercase();
229 let is_index_file =
230 home::is_home_file(&filename_lower, parent_name) || input.is_home_override;
231
232 let empty_title = source == HeadingSource::Title && text.is_empty();
233 let hero_at_top = hero_at_top_owns_title(input.body_markdown);
234
235 let visible =
236 is_markdown && !is_index_file && !empty_title && !hero_at_top && !input.slot_only;
237
238 HeadingState { visible, text, source }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 fn inputs<'a>(file_path: &'a str, frontmatter_title: Option<&'a str>) -> HeadingInputs<'a> {
246 HeadingInputs {
247 file_path,
248 frontmatter_title,
249 body_markdown: "",
250 root_folder_name: None,
251 is_home_override: false,
252 slot_only: false,
253 }
254 }
255
256 // ── filename_text ────────────────────────────────────────────────
257
258 #[test]
259 fn text_article_uses_filename() {
260 assert_eq!(filename_text("posts/my-first-post.md"), "my first post");
261 }
262
263 #[test]
264 fn text_underscore_normalizes_to_space() {
265 assert_eq!(filename_text("posts/my_first_post.md"), "my first post");
266 }
267
268 #[test]
269 fn text_index_uses_parent_folder() {
270 assert_eq!(filename_text("site/index.md"), "site");
271 }
272
273 #[test]
274 fn text_readme_uses_parent_folder() {
275 assert_eq!(filename_text("docs/README.md"), "docs");
276 }
277
278 #[test]
279 fn text_self_named_folder_note_uses_parent() {
280 assert_eq!(filename_text("recipes/recipes.md"), "recipes");
281 }
282
283 #[test]
284 fn text_root_level_no_parent() {
285 assert_eq!(filename_text("about.md"), "about");
286 }
287
288 #[test]
289 fn text_cjk_filename_preserved() {
290 assert_eq!(filename_text("文字/民歌.md"), "民歌");
291 }
292
293 #[test]
294 fn text_cjk_index_uses_parent() {
295 assert_eq!(filename_text("文字/index.md"), "文字");
296 }
297
298 // ── filename_text_with_root: root-aware home title (#775) ─────────
299
300 #[test]
301 fn text_root_index_uses_root_folder_name() {
302 // Bug #775: a root `index.md` has no path parent, so the bare
303 // `filename_text` resolves it to the stem "index". With the root
304 // folder name threaded in, the resolved text must be the folder
305 // name (no title-casing — matches filename_text's hyphen/underscore
306 // → space rule and otherwise-verbatim behavior).
307 assert_eq!(
308 filename_text_with_root("index.md", Some("My Site")),
309 "My Site"
310 );
311 }
312
313 #[test]
314 fn text_root_index_no_root_name_falls_back_to_stem() {
315 // Without a root folder name, behavior is unchanged: the stem.
316 assert_eq!(filename_text_with_root("index.md", None), "index");
317 }
318
319 #[test]
320 fn text_root_self_named_uses_root_folder_name() {
321 // `刘果.md` at the root of a vault named `刘果`.
322 assert_eq!(
323 filename_text_with_root("刘果.md", Some("刘果")),
324 "刘果"
325 );
326 }
327
328 #[test]
329 fn text_with_root_nested_index_unaffected_by_root_name() {
330 // A nested index still resolves to its path parent, ignoring the
331 // root folder name entirely.
332 assert_eq!(
333 filename_text_with_root("site/index.md", Some("My Site")),
334 "site"
335 );
336 }
337
338 #[test]
339 fn text_with_root_article_unaffected() {
340 // A root-level article is not a home file — stem wins regardless of
341 // the root folder name.
342 assert_eq!(
343 filename_text_with_root("about.md", Some("My Site")),
344 "about"
345 );
346 }
347
348 #[test]
349 fn compute_root_index_text_is_root_folder_name() {
350 // The full state path: a root `index.md` with a known root folder
351 // name resolves its chrome text to the folder name, NOT "index".
352 let s = compute(HeadingInputs {
353 file_path: "index.md",
354 frontmatter_title: None,
355 body_markdown: "",
356 root_folder_name: Some("My Site"),
357 is_home_override: false,
358 slot_only: false,
359 });
360 assert!(!s.visible, "root index still suppresses the auto H1");
361 assert_eq!(s.text, "My Site");
362 }
363
364 // ── compute: visibility + filename mode ──────────────────────────
365
366 #[test]
367 fn visible_for_article_md() {
368 let s = compute(inputs("posts/my-first-post.md", None));
369 assert!(s.visible);
370 assert_eq!(s.text, "my first post");
371 assert!(matches!(s.source, HeadingSource::Filename));
372 }
373
374 #[test]
375 fn hidden_for_index_file() {
376 let s = compute(inputs("site/index.md", None));
377 assert!(!s.visible);
378 assert_eq!(s.text, "site");
379 }
380
381 #[test]
382 fn hidden_for_readme() {
383 let s = compute(inputs("docs/README.md", None));
384 assert!(!s.visible);
385 assert_eq!(s.text, "docs");
386 }
387
388 #[test]
389 fn hidden_for_self_named_folder_note() {
390 let s = compute(inputs("recipes/recipes.md", None));
391 assert!(!s.visible);
392 assert_eq!(s.text, "recipes");
393 }
394
395 #[test]
396 fn hidden_for_root_self_named_home_with_root_folder_name() {
397 // Regression: a vault opened at `刘果/`, with `刘果.md` at the project
398 // root, must be detected as the home file. Path::parent() returns
399 // the empty path (no file_name), so without `root_folder_name` the
400 // path-based check misses the self-named case.
401 let s = compute(HeadingInputs {
402 file_path: "刘果.md",
403 frontmatter_title: None,
404 body_markdown: "",
405 root_folder_name: Some("刘果"),
406 is_home_override: false,
407 slot_only: false,
408 });
409 assert!(!s.visible, "root-level self-named home file must hide H1");
410 }
411
412 #[test]
413 fn root_index_md_still_hidden_without_root_folder_name() {
414 // Compatibility: even when the caller passes None for
415 // root_folder_name, recognized index stems (index.md / README.md)
416 // at the root are still detected via is_index_stem.
417 let s = compute(HeadingInputs {
418 file_path: "index.md",
419 frontmatter_title: None,
420 body_markdown: "",
421 root_folder_name: None,
422 is_home_override: false,
423 slot_only: false,
424 });
425 assert!(!s.visible);
426 }
427
428 #[test]
429 fn hidden_for_non_markdown() {
430 let s = compute(inputs("assets/style.css", None));
431 assert!(!s.visible);
432 }
433
434 #[test]
435 fn visible_for_mdx() {
436 let s = compute(inputs("posts/article.mdx", None));
437 assert!(s.visible);
438 assert_eq!(s.text, "article");
439 }
440
441 #[test]
442 fn visible_for_root_level_article() {
443 let s = compute(inputs("about.md", None));
444 assert!(s.visible);
445 assert_eq!(s.text, "about");
446 }
447
448 #[test]
449 fn visible_for_cjk_article() {
450 let s = compute(inputs("文字/民歌.md", None));
451 assert!(s.visible);
452 assert_eq!(s.text, "民歌");
453 }
454
455 #[test]
456 fn hidden_for_cjk_index() {
457 let s = compute(inputs("文字/index.md", None));
458 assert!(!s.visible);
459 assert_eq!(s.text, "文字");
460 }
461
462 // ── compute: source enum / title mode ────────────────────────────
463
464 #[test]
465 fn source_is_title_when_frontmatter_title_set() {
466 let s = compute(inputs("posts/article.md", Some("Custom")));
467 assert_eq!(s.text, "Custom");
468 assert!(matches!(s.source, HeadingSource::Title));
469 assert!(s.visible);
470 }
471
472 #[test]
473 fn source_is_filename_when_title_absent() {
474 let s = compute(inputs("posts/article.md", None));
475 assert!(matches!(s.source, HeadingSource::Filename));
476 assert_eq!(s.text, "article");
477 assert!(s.visible);
478 }
479
480 #[test]
481 fn empty_title_produces_invisible_state() {
482 let s = compute(inputs("posts/article.md", Some("")));
483 assert!(matches!(s.source, HeadingSource::Title));
484 assert_eq!(s.text, "");
485 assert!(!s.visible, "title: \"\" suppresses the auto-injected H1");
486 }
487
488 #[test]
489 fn whitespace_title_produces_invisible_state() {
490 let s = compute(inputs("posts/article.md", Some(" ")));
491 assert!(matches!(s.source, HeadingSource::Title));
492 assert_eq!(s.text, "");
493 assert!(!s.visible);
494 }
495
496 #[test]
497 fn title_overrides_index_visibility_unchanged() {
498 let s = compute(inputs("site/index.md", Some("Welcome")));
499 assert!(!s.visible, "index pages still don't auto-inject");
500 assert!(matches!(s.source, HeadingSource::Title));
501 assert_eq!(s.text, "Welcome");
502 }
503
504 #[test]
505 fn title_text_is_trimmed() {
506 let s = compute(inputs("posts/article.md", Some(" Custom ")));
507 assert_eq!(s.text, "Custom");
508 assert!(s.visible);
509 }
510
511 // ── compute: hero-from-body ──────────────────────────────────────
512
513 #[test]
514 fn hero_at_top_hides_heading_when_title_absent() {
515 let s = compute(HeadingInputs {
516 file_path: "posts/article.md",
517 frontmatter_title: None,
518 body_markdown: ":::hero\nimage: x.jpg\n:::\n\nBody.",
519 root_folder_name: None,
520 is_home_override: false,
521 slot_only: false,
522 });
523 assert!(!s.visible);
524 assert_eq!(s.text, "article");
525 }
526
527 #[test]
528 fn hero_at_top_hides_heading_when_title_set() {
529 let s = compute(HeadingInputs {
530 file_path: "posts/article.md",
531 frontmatter_title: Some("Custom"),
532 body_markdown: ":::hero\n# Custom\n:::\n\nBody.",
533 root_folder_name: None,
534 is_home_override: false,
535 slot_only: false,
536 });
537 assert!(!s.visible, "hero ownership trumps title presence");
538 assert_eq!(s.text, "Custom");
539 }
540
541 #[test]
542 fn an_image_only_hero_does_not_take_the_title_with_it() {
543 // The 87-page regression: a full-bleed cover is written as a hero with
544 // nothing inside, the hero renderer draws only the image, and moss used
545 // to suppress the title anyway — so the page rendered with no visible
546 // heading at all while `<title>`, OG and RSS all still had one.
547 let s = compute(HeadingInputs {
548 file_path: "awards/writing/s4/part-one.md",
549 frontmatter_title: Some("在前線,一座文學博物館的抵抗"),
550 body_markdown: ":::hero {image=assets/cover.jpg}\n:::\n\n正文。",
551 root_folder_name: None,
552 is_home_override: false,
553 slot_only: false,
554 });
555 assert!(s.visible, "an empty hero renders no title, so the page keeps its own");
556 assert_eq!(s.text, "在前線,一座文學博物館的抵抗");
557 }
558
559 #[test]
560 fn hero_only_detected_at_top_not_mid_body() {
561 let s = compute(HeadingInputs {
562 file_path: "posts/article.md",
563 frontmatter_title: None,
564 body_markdown: "Some intro paragraph.\n\n:::hero\n:::",
565 root_folder_name: None,
566 is_home_override: false,
567 slot_only: false,
568 });
569 assert!(s.visible, "hero anywhere but at top does not own heading");
570 }
571
572 #[test]
573 fn hero_detection_skips_leading_blank_lines() {
574 let s = compute(HeadingInputs {
575 file_path: "posts/article.md",
576 frontmatter_title: None,
577 body_markdown: "\n\n\n:::hero\n# Overlay\n:::",
578 root_folder_name: None,
579 is_home_override: false,
580 slot_only: false,
581 });
582 assert!(!s.visible, "leading blanks before :::hero still count as 'at top'");
583 }
584
585 // ── compute: translation-home override ───────────────────────────
586
587 #[test]
588 fn hidden_when_translation_home() {
589 let s = compute(HeadingInputs {
590 file_path: "posts/article.md",
591 frontmatter_title: None,
592 body_markdown: "",
593 root_folder_name: None,
594 is_home_override: true,
595 slot_only: false,
596 });
597 assert!(!s.visible);
598 }
599
600 // ── compute: slot_only override ──────────────────────────────────
601
602 #[test]
603 fn slot_only_hides_heading_regardless_of_title() {
604 // PR7b (moss#599): `footer.md` flows through the normal pipeline
605 // with `slot_only = true`. The auto-injected H1 must be suppressed
606 // even when the author writes `title: "Custom"` in the
607 // frontmatter — the rendered HTML lands inside a `<footer>` slot,
608 // and an article-level heading there is structurally wrong.
609 let s = compute(HeadingInputs {
610 file_path: "footer.md",
611 frontmatter_title: Some("Custom"),
612 body_markdown: "[link](https://example.com)",
613 root_folder_name: None,
614 is_home_override: false,
615 slot_only: true,
616 });
617 assert!(
618 !s.visible,
619 "slot_only must suppress the auto-injected H1 even when title: is set"
620 );
621 // The text is preserved (chrome label / RSS still reads it).
622 assert_eq!(s.text, "Custom");
623 }
624
625 #[test]
626 fn slot_only_hides_heading_when_title_absent() {
627 let s = compute(HeadingInputs {
628 file_path: "footer.md",
629 frontmatter_title: None,
630 body_markdown: "Studio · 2026",
631 root_folder_name: None,
632 is_home_override: false,
633 slot_only: true,
634 });
635 assert!(!s.visible);
636 }
637
638 // ── hero_at_top_owns_title helper ─────────────────────────────────
639
640 #[test]
641 fn a_hero_owns_the_title_slot_only_when_it_has_content_to_put_in_it() {
642 // Has content → owns the slot, so moss injects nothing.
643 assert!(hero_at_top_owns_title("\n\n:::hero\nimage: x\n:::"));
644 assert!(hero_at_top_owns_title(":::hero {image=c.jpg}\n# Overlay title\n:::"));
645 // Unterminated: the renderer treats the rest of the body as inside, so
646 // this does too.
647 assert!(hero_at_top_owns_title(":::hero\n# Overlay title"));
648
649 // Empty → renders an image and nothing else, so the page keeps its own
650 // title. This is the 87-page regression; see the doc on the function.
651 assert!(!hero_at_top_owns_title(":::hero\n:::"));
652 assert!(!hero_at_top_owns_title(":::hero {image=cover.jpg}\n:::"));
653 assert!(!hero_at_top_owns_title(":::hero attr=value\n\n\n:::"));
654 // Attributes on the opening line are not content — `caption=` and
655 // `image=` live there and neither is a title.
656 assert!(!hero_at_top_owns_title(":::hero {image=c.jpg caption=\"A street\"}\n:::"));
657
658 // Not at the top → the hero is a body block like any other.
659 assert!(!hero_at_top_owns_title("# Heading\n:::hero\n# Overlay\n:::"));
660 assert!(!hero_at_top_owns_title("Some prose first.\n\n:::hero\n# Overlay\n:::"));
661 assert!(!hero_at_top_owns_title(""));
662 assert!(!hero_at_top_owns_title("\n\n"));
663 }
664}