Skip to main content

moss_core/
heading.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 —
18//!     no auto-injection regardless of title.
19//!
20//! See `docs/architecture/title-rendering.md`.
21
22use crate::home;
23
24/// Resolved heading state for a single page.
25///
26/// `visible` is the gate the build pipeline checks before injecting an
27/// `<h1 class="moss-article-title">` and the editor checks before rendering
28/// its pinned heading element. `source` tells the editor where `text` came
29/// from so it can route heading-element commits between rename (Filename)
30/// and title-update (Title).
31#[cfg_attr(feature = "specta", derive(specta::Type))]
32#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
33pub struct HeadingState {
34    /// Whether the article heading should render.
35    pub visible: bool,
36    /// The visible heading text (resolved through title or filename).
37    pub text: String,
38    /// Where `text` was sourced from.
39    pub source: HeadingSource,
40}
41
42/// Where the resolved heading text came from. New variants are added when a
43/// new origin is introduced; consumers handle them exhaustively.
44#[cfg_attr(feature = "specta", derive(specta::Type))]
45#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
46pub enum HeadingSource {
47    /// Text derived from filename (or parent folder for index notes).
48    Filename,
49    /// Text from frontmatter `title:`. `Some("")` and `Some("   ")` count as
50    /// Title source with empty text — `visible` will be false in that case.
51    Title,
52}
53
54/// Inputs to the heading rule.
55#[derive(Debug, Clone, Copy)]
56pub struct HeadingInputs<'a> {
57    pub file_path: &'a str,
58    /// Frontmatter `title:` value verbatim. `None` is "field missing"
59    /// (filename-mode); `Some("")`, `Some("   ")` is "explicitly empty"
60    /// (Title source, invisible).
61    pub frontmatter_title: Option<&'a str>,
62    /// Raw markdown body (post-frontmatter). Used to detect a leading
63    /// `:::hero` block — when present, the hero owns the heading slot and
64    /// the auto-injected H1 is suppressed regardless of title.
65    pub body_markdown: &'a str,
66    /// The project's root folder name (the user's vault directory basename).
67    /// Used as a fallback for self-named-folder-note detection when the
68    /// file is at the project root: `<root>/<root>.md` and `<root>/index.md`
69    /// both render the project's homepage, but `Path::parent()` returns
70    /// the empty path for root-level files, so the path-based check can't
71    /// see the folder name. Editor and pipeline both pass the basename of
72    /// the open project folder. Pass `None` if unknown (filename
73    /// detection still catches index.md / README.md / language-suffixed
74    /// indexes).
75    pub root_folder_name: Option<&'a str>,
76    /// `true` iff the file is promoted to its folder's home via
77    /// `translationKey: home` (issue #587). Pipeline-only input; editor
78    /// passes `false`.
79    pub is_translation_home: bool,
80    /// `true` iff the file is a layout-slot source (e.g. root `footer.md`)
81    /// rather than an article. Slot files are embedded as fragments into a
82    /// surrounding layout; the auto-injected `<h1 class="moss-article-title">`
83    /// would render as an unwanted heading inside that fragment. PR7b
84    /// (moss#599) replaces the pre-2026-05-28 frontmatter-synthesis hack
85    /// (`title: ""` injected at the call site to drive `empty_title=true`)
86    /// with this structural input. Editor passes `false`.
87    pub slot_only: bool,
88}
89
90/// Compute just the visible heading text for a file path. Used by callers
91/// that need the text before the full state.
92pub 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
112/// Whether the body begins with a `:::hero` block, after any leading blank
113/// lines. The first non-blank content line must be `:::hero` followed
114/// optionally by attributes/whitespace.
115pub 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
128/// Compute the full heading state for a page.
129pub fn compute(input: HeadingInputs<'_>) -> HeadingState {
130    let path = std::path::Path::new(input.file_path);
131
132    // Resolve text + source from frontmatter.title before all other rules.
133    // Some(_) → Title source (empty allowed); None → Filename source.
134    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    // Resolve parent folder name. For nested files (`recipes/recipes.md`)
149    // we pull from the path. For ROOT-level files (`刘果.md` in a vault
150    // named `刘果`) Path::parent() returns the empty path with no
151    // file_name, so we fall back to `root_folder_name` — letting
152    // self-named-home detection work at the project root.
153    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    // ── filename_text ────────────────────────────────────────────────
192
193    #[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    // ── compute: visibility + filename mode ──────────────────────────
234
235    #[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        // Regression: a vault opened at `刘果/`, with `刘果.md` at the project
267        // root, must be detected as the home file. Path::parent() returns
268        // the empty path (no file_name), so without `root_folder_name` the
269        // path-based check misses the self-named case.
270        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        // Compatibility: even when the caller passes None for
284        // root_folder_name, recognized index stems (index.md / README.md)
285        // at the root are still detected via is_index_stem.
286        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    // ── compute: source enum / title mode ────────────────────────────
332
333    #[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    // ── compute: hero-from-body ──────────────────────────────────────
381
382    #[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    // ── compute: translation-home override ───────────────────────────
437
438    #[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    // ── compute: slot_only override ──────────────────────────────────
452
453    #[test]
454    fn slot_only_hides_heading_regardless_of_title() {
455        // PR7b (moss#599): `footer.md` flows through the normal pipeline
456        // with `slot_only = true`. The auto-injected H1 must be suppressed
457        // even when the author writes `title: "Custom"` in the
458        // frontmatter — the rendered HTML lands inside a `<footer>` slot,
459        // and an article-level heading there is structurally wrong.
460        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        // The text is preserved (chrome label / RSS still reads it).
473        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    // ── body_starts_with_hero helper ─────────────────────────────────
490
491    #[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}