fix: hide legacy title chrome for frontmatter titles
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
10
tests/fixtures/test-vault/quarter/spring-2026.md
vendored
Normal file
10
tests/fixtures/test-vault/quarter/spring-2026.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Spring 2026
|
||||
Is A: Quarter
|
||||
Status: Active
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- [[Alpha Project]]
|
||||
- [[Note B]]
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user