From cb2e28f3d95fcf292a9d4a9df2f4d6628e12f393 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Thu, 9 Apr 2026 12:51:06 +0200 Subject: [PATCH] fix: hide legacy title chrome for frontmatter titles --- .../editor-content/editorContentState.test.ts | 20 +++++ .../editor-content/editorContentState.ts | 80 ++++++++++++++++--- src/hooks/useEditorSaveWithLinks.ts | 6 +- src/utils/noteTitle.test.ts | 44 +++++++++- src/utils/noteTitle.ts | 63 ++++++++++++--- .../test-vault/quarter/spring-2026.md | 10 +++ tests/smoke/h1-title-decoupled.spec.ts | 7 +- 7 files changed, 199 insertions(+), 31 deletions(-) create mode 100644 tests/fixtures/test-vault/quarter/spring-2026.md diff --git a/src/components/editor-content/editorContentState.test.ts b/src/components/editor-content/editorContentState.test.ts index dcf39109..a51f3757 100644 --- a/src/components/editor-content/editorContentState.test.ts +++ b/src/components/editor-content/editorContentState.test.ts @@ -62,6 +62,26 @@ describe('deriveEditorContentState', () => { content: '---\ntitle: Legacy Project\n---\nBody without a heading', }) + expect(state.hasH1).toBe(false) + expect(state.showTitleSection).toBe(false) + }) + + it('hides the legacy title section when a frontmatter title drives the display title', () => { + const state = deriveState({ + entry: baseEntry, + content: '---\ntitle: Spring 2026\nstatus: Active\n---\n## Goals', + }) + + expect(state.hasH1).toBe(false) + expect(state.showTitleSection).toBe(false) + }) + + it('keeps the title section when the document title still comes from the filename', () => { + const state = deriveState({ + entry: baseEntry, + content: '---\nstatus: Active\n---\nBody without a heading', + }) + expect(state.hasH1).toBe(false) expect(state.showTitleSection).toBe(true) }) diff --git a/src/components/editor-content/editorContentState.ts b/src/components/editor-content/editorContentState.ts index 5d7fee13..e4332c4e 100644 --- a/src/components/editor-content/editorContentState.ts +++ b/src/components/editor-content/editorContentState.ts @@ -1,5 +1,5 @@ import type { NoteStatus, VaultEntry } from '../../types' -import { extractH1TitleFromContent } from '../../utils/noteTitle' +import { contentDefinesDisplayTitle, extractH1TitleFromContent } from '../../utils/noteTitle' import { countWords } from '../../utils/wikilinks' export interface EditorContentTab { @@ -14,6 +14,19 @@ interface EditorContentStateInput { activeStatus: NoteStatus } +interface TitleSectionState { + hasDisplayTitle: boolean + hasH1: boolean +} + +interface VisibilityState { + effectiveRawMode: boolean + isDeletedPreview: boolean + isNonMarkdownText: boolean + showEditor: boolean + showTitleSection: boolean +} + export interface EditorContentState { freshEntry: VaultEntry | undefined isArchived: boolean @@ -36,10 +49,53 @@ function contentHasTopLevelH1(activeTab: EditorContentTab | null): boolean { return activeTab ? extractH1TitleFromContent(activeTab.content) !== null : false } +function contentDefinesTitle(activeTab: EditorContentTab | null): boolean { + return activeTab ? contentDefinesDisplayTitle(activeTab.content) : false +} + function resolveHasH1(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): boolean { return contentHasTopLevelH1(activeTab) || freshEntry?.hasH1 === true || activeTab?.entry.hasH1 === true } +function resolveHasDisplayTitle(activeTab: EditorContentTab | null, hasH1: boolean): boolean { + return hasH1 || contentDefinesTitle(activeTab) +} + +function deriveTitleSectionState(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): TitleSectionState { + const hasH1 = resolveHasH1(activeTab, freshEntry) + return { + hasDisplayTitle: resolveHasDisplayTitle(activeTab, hasH1), + hasH1, + } +} + +function deriveVisibilityState(input: { + activeStatus: NoteStatus + activeTab: EditorContentTab | null + freshEntry: VaultEntry | undefined + hasDisplayTitle: boolean + rawMode: boolean +}): VisibilityState { + const { + activeStatus, + activeTab, + freshEntry, + hasDisplayTitle, + rawMode, + } = input + const isDeletedPreview = !!activeTab && !freshEntry + const isNonMarkdownText = activeTab?.entry.fileKind === 'text' + const effectiveRawMode = rawMode || isNonMarkdownText + + return { + isDeletedPreview, + isNonMarkdownText, + effectiveRawMode, + showEditor: !effectiveRawMode, + showTitleSection: !isDeletedPreview && !hasDisplayTitle && !isUnsavedUntitledDraft(activeTab, activeStatus), + } +} + function isUnsavedUntitledDraft(activeTab: EditorContentTab | null, activeStatus: NoteStatus): boolean { if (!activeTab) return false if (!activeTab.entry.filename.startsWith('untitled-')) return false @@ -53,22 +109,20 @@ export function deriveEditorContentState({ activeStatus, }: EditorContentStateInput): EditorContentState { const freshEntry = findFreshEntry(activeTab, entries) - const isDeletedPreview = !!activeTab && !freshEntry - const hasH1 = resolveHasH1(activeTab, freshEntry) - const isNonMarkdownText = activeTab?.entry.fileKind === 'text' - const effectiveRawMode = rawMode || isNonMarkdownText - const showEditor = !effectiveRawMode - const showTitleSection = !isDeletedPreview && !hasH1 && !isUnsavedUntitledDraft(activeTab, activeStatus) + const titleState = deriveTitleSectionState(activeTab, freshEntry) + const visibilityState = deriveVisibilityState({ + activeStatus, + activeTab, + freshEntry, + hasDisplayTitle: titleState.hasDisplayTitle, + rawMode, + }) return { freshEntry, isArchived: freshEntry?.archived ?? activeTab?.entry.archived ?? false, - hasH1, - isDeletedPreview, - isNonMarkdownText, - effectiveRawMode, - showEditor, - showTitleSection, + hasH1: titleState.hasH1, + ...visibilityState, path: activeTab?.entry.path ?? '', wordCount: activeTab ? countWords(activeTab.content) : 0, } diff --git a/src/hooks/useEditorSaveWithLinks.ts b/src/hooks/useEditorSaveWithLinks.ts index f63feafa..27ae99f5 100644 --- a/src/hooks/useEditorSaveWithLinks.ts +++ b/src/hooks/useEditorSaveWithLinks.ts @@ -36,11 +36,11 @@ export function useEditorSaveWithLinks(config: { const filename = path.split('/').pop() ?? path const fmPatch = { ...frontmatterPatch, - ...deriveDisplayTitleState( + ...deriveDisplayTitleState({ content, filename, - typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null, - ), + frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null, + }), } const fmKey = JSON.stringify(fmPatch) if (fmKey !== prevFmKeyRef.current) { diff --git a/src/utils/noteTitle.test.ts b/src/utils/noteTitle.test.ts index ace59d2a..b8ef2489 100644 --- a/src/utils/noteTitle.test.ts +++ b/src/utils/noteTitle.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest' -import { deriveDisplayTitleState, extractH1TitleFromContent, filenameStemToTitle } from './noteTitle' +import { + contentDefinesDisplayTitle, + deriveDisplayTitleState, + extractFrontmatterTitleFromContent, + extractH1TitleFromContent, + filenameStemToTitle, +} from './noteTitle' describe('filenameStemToTitle', () => { it('converts kebab-case filenames into title case', () => { @@ -23,10 +29,32 @@ describe('extractH1TitleFromContent', () => { }) }) +describe('extractFrontmatterTitleFromContent', () => { + it('extracts the frontmatter title when present', () => { + const content = '---\ntitle: Legacy Title\nstatus: Active\n---\n## Body' + expect(extractFrontmatterTitleFromContent(content)).toBe('Legacy Title') + }) + + it('returns null when the frontmatter title is missing', () => { + expect(extractFrontmatterTitleFromContent('---\nstatus: Active\n---\n## Body')).toBeNull() + }) +}) + +describe('contentDefinesDisplayTitle', () => { + it('returns true when the document title comes from frontmatter', () => { + const content = '---\ntitle: Spring 2026\n---\n## Goals' + expect(contentDefinesDisplayTitle(content)).toBe(true) + }) + + it('returns false when title still comes from the filename', () => { + expect(contentDefinesDisplayTitle('Body only')).toBe(false) + }) +}) + describe('deriveDisplayTitleState', () => { it('prefers H1 over frontmatter title and filename', () => { const content = '---\ntitle: Legacy Title\n---\n# Updated Title\n\nBody' - expect(deriveDisplayTitleState(content, 'legacy-title.md', 'Legacy Title')).toEqual({ + expect(deriveDisplayTitleState({ content, filename: 'legacy-title.md', frontmatterTitle: 'Legacy Title' })).toEqual({ title: 'Updated Title', hasH1: true, }) @@ -34,14 +62,22 @@ describe('deriveDisplayTitleState', () => { it('falls back to frontmatter title when no H1 is present', () => { const content = '---\ntitle: Legacy Title\n---\nBody' - expect(deriveDisplayTitleState(content, 'legacy-title.md', 'Legacy Title')).toEqual({ + expect(deriveDisplayTitleState({ content, filename: 'legacy-title.md', frontmatterTitle: 'Legacy Title' })).toEqual({ title: 'Legacy Title', hasH1: false, }) }) + it('reads the frontmatter title from content when no explicit title is passed', () => { + const content = '---\ntitle: Spring 2026\n---\n## Goals' + expect(deriveDisplayTitleState({ content, filename: 'spring-2026.md' })).toEqual({ + title: 'Spring 2026', + hasH1: false, + }) + }) + it('falls back to filename title when there is no H1 or frontmatter title', () => { - expect(deriveDisplayTitleState('Body only', 'renamed-note.md')).toEqual({ + expect(deriveDisplayTitleState({ content: 'Body only', filename: 'renamed-note.md' })).toEqual({ title: 'Renamed Note', hasH1: false, }) diff --git a/src/utils/noteTitle.ts b/src/utils/noteTitle.ts index e48e891a..58d4939d 100644 --- a/src/utils/noteTitle.ts +++ b/src/utils/noteTitle.ts @@ -1,5 +1,22 @@ +import { parseFrontmatter } from './frontmatter' import { splitFrontmatter } from './wikilinks' +interface ResolvedContentTitle { + source: 'h1' | 'frontmatter' + title: string +} + +interface DisplayTitleInput { + content: string + filename: string + frontmatterTitle?: string | null +} + +interface DisplayTitleState { + title: string + hasH1: boolean +} + function replaceWikilinkAliases(text: string): string { return text.replace(/\[\[[^|\]]+\|([^\]]+)\]\]/g, '$1') } @@ -49,20 +66,46 @@ export function extractH1TitleFromContent(content: string): string | null { return null } -export function deriveDisplayTitleState( - content: string, - filename: string, - frontmatterTitle?: string | null, -): { title: string, hasH1: boolean } { +export function extractFrontmatterTitleFromContent(content: string): string | null { + const title = parseFrontmatter(content).title + if (typeof title !== 'string') return null + const trimmed = title.trim() + return trimmed || null +} + +function resolveContentTitle(content: string, frontmatterTitle?: string | null): ResolvedContentTitle | null { const h1Title = extractH1TitleFromContent(content) if (h1Title) { - return { title: h1Title, hasH1: true } + return { title: h1Title, source: 'h1' } } - const trimmedFrontmatterTitle = frontmatterTitle?.trim() - if (trimmedFrontmatterTitle) { - return { title: trimmedFrontmatterTitle, hasH1: false } + const resolvedFrontmatterTitle = frontmatterTitle?.trim() || extractFrontmatterTitleFromContent(content) + if (resolvedFrontmatterTitle) { + return { title: resolvedFrontmatterTitle, source: 'frontmatter' } } - return { title: filenameStemToTitle(filename), hasH1: false } + return null +} + +export function contentDefinesDisplayTitle(content: string): boolean { + return resolveContentTitle(content) !== null +} + +export function deriveDisplayTitleState({ + content, + filename, + frontmatterTitle, +}: DisplayTitleInput): DisplayTitleState { + const resolvedTitle = resolveContentTitle(content, frontmatterTitle) + if (resolvedTitle) { + return { + title: resolvedTitle.title, + hasH1: resolvedTitle.source === 'h1', + } + } + + return { + title: filenameStemToTitle(filename), + hasH1: false, + } } diff --git a/tests/fixtures/test-vault/quarter/spring-2026.md b/tests/fixtures/test-vault/quarter/spring-2026.md new file mode 100644 index 00000000..f82f9216 --- /dev/null +++ b/tests/fixtures/test-vault/quarter/spring-2026.md @@ -0,0 +1,10 @@ +--- +title: Spring 2026 +Is A: Quarter +Status: Active +--- + +## Goals + +- [[Alpha Project]] +- [[Note B]] diff --git a/tests/smoke/h1-title-decoupled.spec.ts b/tests/smoke/h1-title-decoupled.spec.ts index f5745d73..92c8d89b 100644 --- a/tests/smoke/h1-title-decoupled.spec.ts +++ b/tests/smoke/h1-title-decoupled.spec.ts @@ -69,12 +69,17 @@ test('creating an untitled draft hides the legacy title section in the editor', await expect(page.locator('.title-section[data-title-ui-visible]')).toHaveCount(0) }) -test('@smoke older notes with an H1 do not render the legacy title section', async ({ page }) => { +test('@smoke older notes with a document title do not render the legacy title section', async ({ page }) => { await openNote(page, 'Alpha Project') await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) await expect(page.getByTestId('title-field-input')).toHaveCount(0) await expect(page.locator('.title-section[data-title-ui-visible]')).toHaveCount(0) + + await openNote(page, 'Spring 2026') + await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) + await expect(page.getByTestId('title-field-input')).toHaveCount(0) + await expect(page.locator('.title-section[data-title-ui-visible]')).toHaveCount(0) }) test('@smoke edited H1 titles drive note list, search, and wikilink autocomplete', async ({ page }) => {