feat: H1-as-title — title resolution, TitleField hiding, breadcrumb filename

- Rust: extract_title now prioritizes H1 > frontmatter > filename
- Rust: add has_h1 field to VaultEntry for frontend TitleField control
- Frontend: hide TitleField + icon picker when note has H1 in body
- Frontend: breadcrumb shows filename stem instead of display title
- Frontend: new notes use untitled-{type}-{timestamp}.md, no title in frontmatter
- CSS: only hide first H1 in BlockNote when TitleField is visible
- Updated all tests for new title resolution and note creation behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test
2026-04-06 13:08:17 +02:00
parent 901326d06f
commit 377a3f8d0e
18 changed files with 195 additions and 71 deletions

View File

@@ -78,6 +78,10 @@ pub struct VaultEntry {
/// Configured via `_list_properties_display` in the type file's frontmatter.
#[serde(rename = "listPropertiesDisplay", default)]
pub list_properties_display: Vec<String>,
/// Whether the note body has an H1 heading on the first non-empty line.
/// Used by the frontend to decide whether to show the TitleField.
#[serde(rename = "hasH1")]
pub has_h1: bool,
/// File kind: "markdown", "text", or "binary".
/// Determines how the frontend renders and opens the file.
#[serde(rename = "fileKind", default = "default_file_kind")]

View File

@@ -57,6 +57,7 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data, &content);
let title = extract_title(frontmatter.title.as_deref(), &content, &filename);
let has_h1 = parsing::extract_h1_title(&content).is_some();
let snippet = extract_snippet(&content);
let word_count = count_body_words(&content);
let outgoing_links = extract_outgoing_links(&parsed.content);
@@ -116,6 +117,7 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
word_count,
outgoing_links,
properties,
has_h1,
file_kind: "markdown".to_string(),
})
}

View File

@@ -21,15 +21,40 @@ pub(super) fn slug_to_title(stem: &str) -> String {
.join(" ")
}
/// Extract the H1 title from the first non-empty line of the body (after frontmatter).
/// Returns `None` if no H1 is found on the first non-empty line.
pub(super) fn extract_h1_title(content: &str) -> Option<String> {
let body = strip_frontmatter(content);
for line in body.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some(title) = trimmed.strip_prefix("# ") {
let title = strip_markdown_chars(title).trim().to_string();
if !title.is_empty() {
return Some(title);
}
}
break; // first non-empty line is not H1
}
None
}
/// Extract the display title for a note.
/// Priority: frontmatter `title:` → filename-derived title.
/// H1 headings are treated as body content, not title source.
pub(super) fn extract_title(fm_title: Option<&str>, _content: &str, filename: &str) -> String {
/// Priority: H1 on first non-empty line → frontmatter `title:` → filename-derived title.
pub(super) fn extract_title(fm_title: Option<&str>, content: &str, filename: &str) -> String {
// 1. H1 on first non-empty line of body
if let Some(h1) = extract_h1_title(content) {
return h1;
}
// 2. frontmatter title (legacy, backward compat)
if let Some(title) = fm_title {
if !title.is_empty() {
return title.to_string();
}
}
// 3. filename slug
let stem = filename.strip_suffix(".md").unwrap_or(filename);
slug_to_title(stem)
}