From 2d2b814e4d7b37f6fa02dfbb457fe9c8fdd932c5 Mon Sep 17 00:00:00 2001 From: Sinabina Date: Tue, 21 Jul 2026 14:18:36 -0700 Subject: [PATCH] fix(import): fall back to body H1 for title when frontmatter lacks title: (#2446) Title precedence is now frontmatter title: > body's first ATX H1 > the slug/filename-humanized fallback. Slug-based imports (contacts, calendar) carry a correct # Heading but no frontmatter title; without the H1 fallback they got junk titles humanized from the slug. The H1 scan skips h2+ and lines inside fenced code blocks, and strips closed-ATX trailing hashes. Takeover of #2495. Co-authored-by: javieraldape Co-Authored-By: Claude Fable 5 --- src/core/markdown.ts | 36 ++++++++++++++++++++++++++++++++++- test/markdown.test.ts | 44 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/core/markdown.ts b/src/core/markdown.ts index d46775310..b48549993 100644 --- a/src/core/markdown.ts +++ b/src/core/markdown.ts @@ -135,7 +135,16 @@ export function parseMarkdown( const type = coerceFrontmatterString(frontmatter.type) || ( opts?.activePack ? inferTypeFromPack(filePath, opts.activePack) : inferType(filePath) ); - const title = coerceFrontmatterString(frontmatter.title).trim() || inferTitle(filePath); + // #2446: title precedence is frontmatter `title:` > the body's first H1 > + // the slug/filename-humanized fallback. Slug-based imports (contacts, + // calendar) write a correct `# Heading` but no frontmatter title; without + // the H1 fallback they get junk titles humanized from the slug + // (`Contact 20170928 5 John Defalco`), which also breaks anything keyed on + // the title (e.g. the by-mention gazetteer's first-token bucketing). + const title = + coerceFrontmatterString(frontmatter.title).trim() || + inferTitleFromBody(body) || + inferTitle(filePath); const tags = extractTags(frontmatter); const slug = coerceFrontmatterString(frontmatter.slug) || inferSlug(filePath); @@ -602,6 +611,31 @@ function inferTypeWithPrefixes( return 'concept'; } +/** + * #2446: derive a title from the body's first ATX H1 (`# Heading`). + * + * Returns the trimmed heading text with the leading `# ` and any decorative + * trailing `#` run stripped, or '' if the body has no H1. Only a SINGLE leading + * `#` matches — `##`+ (h2 and deeper) are skipped — and lines inside a fenced + * code block (```/~~~) are ignored so a `# comment` in a shell snippet can't be + * mistaken for the page title. + */ +function inferTitleFromBody(body: string): string { + let inFence = false; + for (const raw of body.split('\n')) { + const fence = /^\s*(`{3,}|~{3,})/.exec(raw); + if (fence) { + inFence = !inFence; + continue; + } + if (inFence) continue; + // Exactly one leading `#`, then whitespace, then the heading text. + const m = /^#(?!#)\s+(.+?)\s*$/.exec(raw); + if (m) return m[1].replace(/\s+#+\s*$/, '').trim(); + } + return ''; +} + function inferTitle(filePath?: string): string { if (!filePath) return 'Untitled'; diff --git a/test/markdown.test.ts b/test/markdown.test.ts index 2e485f521..138c5d206 100644 --- a/test/markdown.test.ts +++ b/test/markdown.test.ts @@ -343,3 +343,47 @@ describe('issue #1939 — non-string frontmatter coercion', () => { expect(parsed.title).toBe('A Normal Title'); }); }); + +// issue #2446 — when frontmatter has no `title:`, prefer the body's first H1 +// over the slug/filename-humanized fallback. Slug-based imports (contacts, +// calendar) carry a correct `# Heading` but no frontmatter title; humanizing +// the slug leaks date/id tokens and loses casing (`Defalco` vs `DeFalco`). +describe('issue #2446 — body H1 fallback for missing frontmatter title', () => { + test('no frontmatter title uses the body H1, not the slug-humanized junk', () => { + const md = '---\ntype: person\n---\n\n# John DeFalco\n\nNotes about John.\n'; + const parsed = parseMarkdown(md, 'people/contact-20170928-5-john-defalco.md'); + expect(parsed.title).toBe('John DeFalco'); + // The slug-derived junk title must NOT win. + expect(parsed.title).not.toBe('Contact 20170928 5 John Defalco'); + }); + + test('no frontmatter title and no H1 falls back to the inferred slug title', () => { + const md = '---\ntype: note\n---\n\njust body prose, no heading\n'; + const parsed = parseMarkdown(md, 'people/alice-example.md'); + expect(parsed.title).toBe('Alice Example'); + }); + + test('frontmatter title wins over a body H1 (no regression)', () => { + const md = '---\ntitle: Frontmatter Wins\n---\n\n# Body Heading\n\nbody\n'; + const parsed = parseMarkdown(md, 'people/some-slug.md'); + expect(parsed.title).toBe('Frontmatter Wins'); + }); + + test('h2 is not treated as the title; first real H1 is used', () => { + const md = '---\ntype: note\n---\n\n## Subsection First\n\n# The Real Title\n\nbody\n'; + const parsed = parseMarkdown(md, 'notes/x.md'); + expect(parsed.title).toBe('The Real Title'); + }); + + test('a # inside a fenced code block is not mistaken for the title', () => { + const md = '---\ntype: note\n---\n\n```sh\n# this is a shell comment, not a heading\n```\n\n# Actual Heading\n'; + const parsed = parseMarkdown(md, 'notes/x.md'); + expect(parsed.title).toBe('Actual Heading'); + }); + + test('trailing closing hashes are stripped from the H1', () => { + const md = '---\ntype: note\n---\n\n# Closed ATX Heading #\n\nbody\n'; + const parsed = parseMarkdown(md, 'notes/x.md'); + expect(parsed.title).toBe('Closed ATX Heading'); + }); +});