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 —
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 the
77    /// `home: true` frontmatter marker (issue #587). Pipeline-only input;
78    /// editor passes `false`.
79    pub is_home_override: 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.
92///
93/// Not root-aware: a root-level index-stem (`index.md` with no path parent)
94/// resolves to the stem itself ("index"). Use [`filename_text_with_root`]
95/// when the project's root folder name is available so the site root's home
96/// page reads the folder name instead. See #775.
97pub fn filename_text(file_path: &str) -> String {
98    filename_text_with_root(file_path, None)
99}
100
101/// Like [`filename_text`], but root-aware: for an index-stem / self-named
102/// folder note at the project root (where `Path::parent()` has no
103/// `file_name`), the resolved text is `root_folder_name` rather than the bare
104/// stem. This is the fix for #775 — a root `index.md` home page must read the
105/// folder name in `<title>`/chrome, not "index".
106///
107/// Mirrors the parent-name resolution in [`compute`] (`heading/state.rs` root
108/// fallback) so the text answer and the visibility answer agree at the root.
109/// No title-casing: hyphens/underscores become spaces, everything else is
110/// verbatim (same rule as the nested case).
111pub 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    // Parent folder name from the path, falling back to the project root
118    // folder name for root-level files (whose `Path::parent()` yields the
119    // empty path with no `file_name`). This is what lets a root `index.md`
120    // or self-named `<root>/<root>.md` resolve to the folder name.
121    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
136/// Whether the body begins with a `:::hero` block, after any leading blank
137/// lines. The first non-blank content line must be `:::hero` followed
138/// optionally by attributes/whitespace.
139pub 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
152/// Compute the full heading state for a page.
153pub fn compute(input: HeadingInputs<'_>) -> HeadingState {
154    let path = std::path::Path::new(input.file_path);
155
156    // Resolve text + source from frontmatter.title before all other rules.
157    // Some(_) → Title source (empty allowed); None → Filename source.
158    // Filename mode is root-aware: a root index-stem / self-named home
159    // resolves to the project's folder name, not the bare "index" stem (#775).
160    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    // Resolve parent folder name. For nested files (`recipes/recipes.md`)
178    // we pull from the path. For ROOT-level files (`刘果.md` in a vault
179    // named `刘果`) Path::parent() returns the empty path with no
180    // file_name, so we fall back to `root_folder_name` — letting
181    // self-named-home detection work at the project root.
182    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    // ── filename_text ────────────────────────────────────────────────
221
222    #[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    // ── filename_text_with_root: root-aware home title (#775) ─────────
263
264    #[test]
265    fn text_root_index_uses_root_folder_name() {
266        // Bug #775: a root `index.md` has no path parent, so the bare
267        // `filename_text` resolves it to the stem "index". With the root
268        // folder name threaded in, the resolved text must be the folder
269        // name (no title-casing — matches filename_text's hyphen/underscore
270        // → space rule and otherwise-verbatim behavior).
271        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        // Without a root folder name, behavior is unchanged: the stem.
280        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        // `刘果.md` at the root of a vault named `刘果`.
286        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        // A nested index still resolves to its path parent, ignoring the
295        // root folder name entirely.
296        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        // A root-level article is not a home file — stem wins regardless of
305        // the root folder name.
306        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        // The full state path: a root `index.md` with a known root folder
315        // name resolves its chrome text to the folder name, NOT "index".
316        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    // ── compute: visibility + filename mode ──────────────────────────
329
330    #[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        // Regression: a vault opened at `刘果/`, with `刘果.md` at the project
362        // root, must be detected as the home file. Path::parent() returns
363        // the empty path (no file_name), so without `root_folder_name` the
364        // path-based check misses the self-named case.
365        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        // Compatibility: even when the caller passes None for
379        // root_folder_name, recognized index stems (index.md / README.md)
380        // at the root are still detected via is_index_stem.
381        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    // ── compute: source enum / title mode ────────────────────────────
427
428    #[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    // ── compute: hero-from-body ──────────────────────────────────────
476
477    #[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    // ── compute: translation-home override ───────────────────────────
532
533    #[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    // ── compute: slot_only override ──────────────────────────────────
547
548    #[test]
549    fn slot_only_hides_heading_regardless_of_title() {
550        // PR7b (moss#599): `footer.md` flows through the normal pipeline
551        // with `slot_only = true`. The auto-injected H1 must be suppressed
552        // even when the author writes `title: "Custom"` in the
553        // frontmatter — the rendered HTML lands inside a `<footer>` slot,
554        // and an article-level heading there is structurally wrong.
555        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        // The text is preserved (chrome label / RSS still reads it).
568        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    // ── body_starts_with_hero helper ─────────────────────────────────
585
586    #[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}