Skip to main content

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}