fix: use h1 titles for displayed note names

This commit is contained in:
lucaronin
2026-04-08 19:46:16 +02:00
parent ad46eb0295
commit 429aba7ae7
8 changed files with 298 additions and 21 deletions

View File

@@ -13,7 +13,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:regression": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "vitest run --coverage",

View File

@@ -88,11 +88,8 @@ pub fn search_vault(
};
let content_lower = content.to_lowercase();
let title = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let filename = path.file_name().and_then(|value| value.to_str()).unwrap_or("");
let title = crate::vault::derive_markdown_title_from_content(&content, filename);
let title_lower = title.to_lowercase();
if !title_lower.contains(&query_lower) && !content_lower.contains(&query_lower) {
@@ -132,6 +129,8 @@ pub fn search_vault(
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::Builder;
#[test]
fn test_extract_snippet_basic() {
@@ -166,4 +165,23 @@ mod tests {
let snippet = extract_snippet(&content, "keyword");
assert!(snippet.len() <= 203); // 200 + "…" (3 bytes UTF-8)
}
#[test]
fn test_search_vault_uses_h1_for_result_title() {
let dir = Builder::new()
.prefix("search-vault-")
.tempdir_in(std::env::current_dir().unwrap())
.unwrap();
let note_path = dir.path().join("legacy-name.md");
fs::write(
&note_path,
"# Updated Display Title\n\nThe body contains keyword for search.",
)
.unwrap();
let response = search_vault(dir.path().to_str().unwrap(), "keyword", "keyword", 10).unwrap();
assert_eq!(response.results.len(), 1);
assert_eq!(response.results[0].title, "Updated Display Title");
}
}

View File

@@ -40,6 +40,13 @@ use std::fs;
use std::path::Path;
use walkdir::WalkDir;
pub(crate) fn derive_markdown_title_from_content(content: &str, filename: &str) -> String {
let matter = Matter::<YAML>::new();
let parsed = matter.parse(content);
let (frontmatter, _, _) = extract_fm_and_rels(parsed.data, content);
extract_title(frontmatter.title.as_deref(), content, filename)
}
/// Parse a single markdown file into a VaultEntry.
///
/// If `git_dates` is provided, those timestamps override filesystem metadata
@@ -57,7 +64,7 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
let parsed = matter.parse(&content);
let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data, &content);
let title = extract_title(frontmatter.title.as_deref(), &content, &filename);
let title = derive_markdown_title_from_content(&content, &filename);
let has_h1 = parsing::extract_h1_title(&content).is_some();
let snippet = extract_snippet(&content);
let word_count = count_body_words(&content);

View File

@@ -69,14 +69,21 @@ describe('useEditorSaveWithLinks', () => {
act(() => {
result.current.handleContentChange('/note.md', 'text [[Alpha]] more text')
})
expect(updateEntry).toHaveBeenCalledTimes(1)
expect(updateEntry).toHaveBeenCalledTimes(2)
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
outgoingLinks: ['Alpha'],
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
title: 'Note',
hasH1: false,
})
// Same link, different surrounding text
act(() => {
result.current.handleContentChange('/note.md', 'different text [[Alpha]] still')
})
// updateEntry should NOT have been called again — links unchanged
expect(updateEntry).toHaveBeenCalledTimes(1)
expect(updateEntry).toHaveBeenCalledTimes(2)
})
it('handleContentChange calls updateEntry again when links change on subsequent edit', () => {
@@ -85,32 +92,37 @@ describe('useEditorSaveWithLinks', () => {
act(() => {
result.current.handleContentChange('/note.md', 'see [[Alpha]]')
})
expect(updateEntry).toHaveBeenCalledTimes(1)
expect(updateEntry).toHaveBeenCalledTimes(2)
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
outgoingLinks: ['Alpha'],
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
title: 'Note',
hasH1: false,
})
// Now links change
act(() => {
result.current.handleContentChange('/note.md', 'see [[Alpha]] and [[Beta]]')
})
expect(updateEntry).toHaveBeenCalledTimes(2)
expect(updateEntry).toHaveBeenCalledTimes(3)
expect(updateEntry).toHaveBeenLastCalledWith('/note.md', {
outgoingLinks: ['Alpha', 'Beta'],
})
})
it('handleContentChange calls updateEntry with empty links on first call with no links', () => {
it('handleContentChange updates the fallback filename title on first call with no links', () => {
const { result } = renderHookWithLinks()
// First call with no links — prevLinksKeyRef starts as '' and extracted key is also ''
// but since they're equal, updateEntry should NOT be called
act(() => {
result.current.handleContentChange('/note.md', 'plain text no links')
})
// The initial ref is '' and no-links key is also '' — no change
expect(updateEntry).not.toHaveBeenCalled()
expect(updateEntry).toHaveBeenCalledTimes(1)
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
title: 'Note',
hasH1: false,
})
})
it('handles pipe-separated wikilinks (display text syntax)', () => {
@@ -132,7 +144,12 @@ describe('useEditorSaveWithLinks', () => {
result.current.handleContentChange('/note.md', '---\ntype: Project\nstatus: Active\n---\nBody')
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', { isA: 'Project', status: 'Active' })
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
isA: 'Project',
status: 'Active',
title: 'Note',
hasH1: false,
})
})
it('handleContentChange does NOT call updateEntry for frontmatter when unchanged', () => {
@@ -153,12 +170,33 @@ describe('useEditorSaveWithLinks', () => {
act(() => {
result.current.handleContentChange('/note.md', '---\ntype: Essay\n---\nBody')
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', { isA: 'Essay' })
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
isA: 'Essay',
title: 'Note',
hasH1: false,
})
act(() => {
result.current.handleContentChange('/note.md', '---\ntype: Note\n---\nBody')
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', { isA: 'Note' })
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
isA: 'Note',
title: 'Note',
hasH1: false,
})
})
it.each([
['/old-title.md', '# Renamed Note\n\nBody', { title: 'Renamed Note', hasH1: true }],
['/renamed-note.md', 'Body without a heading', { title: 'Renamed Note', hasH1: false }],
])('handleContentChange derives the displayed title for %s', (path, content, expected) => {
const { result } = renderHookWithLinks()
act(() => {
result.current.handleContentChange(path, content)
})
expect(updateEntry).toHaveBeenCalledWith(path, expected)
})
it('spreads all properties from useEditorSave onto the return value', () => {

View File

@@ -2,6 +2,7 @@ import { useCallback, useRef } from 'react'
import { useEditorSave } from './useEditorSave'
import { extractOutgoingLinks, extractSnippet, countWords } from '../utils/wikilinks'
import { contentToEntryPatch } from './frontmatterOps'
import { deriveDisplayTitleState } from '../utils/noteTitle'
import type { VaultEntry } from '../types'
export function useEditorSaveWithLinks(config: {
@@ -31,7 +32,16 @@ export function useEditorSaveWithLinks(config: {
prevLinksKeyRef.current = key
updateEntry(path, { outgoingLinks: links })
}
const fmPatch = contentToEntryPatch(content)
const frontmatterPatch = contentToEntryPatch(content)
const filename = path.split('/').pop() ?? path
const fmPatch = {
...frontmatterPatch,
...deriveDisplayTitleState(
content,
filename,
typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
),
}
const fmKey = JSON.stringify(fmPatch)
if (fmKey !== prevFmKeyRef.current) {
prevFmKeyRef.current = fmKey

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { deriveDisplayTitleState, extractH1TitleFromContent, filenameStemToTitle } from './noteTitle'
describe('filenameStemToTitle', () => {
it('converts kebab-case filenames into title case', () => {
expect(filenameStemToTitle('renamed-note.md')).toBe('Renamed Note')
})
})
describe('extractH1TitleFromContent', () => {
it('extracts the first H1 after frontmatter', () => {
const content = '---\ntitle: Legacy Title\n---\n# Updated Title\n\nBody'
expect(extractH1TitleFromContent(content)).toBe('Updated Title')
})
it('strips markdown formatting from the H1', () => {
const content = '# **Bold** [Link](https://example.com) and `code`'
expect(extractH1TitleFromContent(content)).toBe('Bold Link and code')
})
it('returns null when the first non-empty line is not an H1', () => {
expect(extractH1TitleFromContent('Body first\n# Not the title')).toBeNull()
})
})
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({
title: 'Updated Title',
hasH1: true,
})
})
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({
title: 'Legacy Title',
hasH1: false,
})
})
it('falls back to filename title when there is no H1 or frontmatter title', () => {
expect(deriveDisplayTitleState('Body only', 'renamed-note.md')).toEqual({
title: 'Renamed Note',
hasH1: false,
})
})
})

68
src/utils/noteTitle.ts Normal file
View File

@@ -0,0 +1,68 @@
import { splitFrontmatter } from './wikilinks'
function replaceWikilinkAliases(text: string): string {
return text.replace(/\[\[[^|\]]+\|([^\]]+)\]\]/g, '$1')
}
function replacePlainWikilinks(text: string): string {
return text.replace(/\[\[([^\]]+)\]\]/g, '$1')
}
function replaceMarkdownLinks(text: string): string {
return text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
}
function removeInlineMarkdownMarkers(text: string): string {
return text.replace(/[*_`~]/g, '')
}
function stripMarkdownFormatting(text: string): string {
return removeInlineMarkdownMarkers(
replaceMarkdownLinks(
replacePlainWikilinks(
replaceWikilinkAliases(text),
),
),
)
}
export function filenameStemToTitle(filename: string): string {
const stem = filename.replace(/\.[^.]+$/, '')
return stem
.split('-')
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
export function extractH1TitleFromContent(content: string): string | null {
const [, body] = splitFrontmatter(content)
for (const line of body.split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
if (!trimmed.startsWith('# ')) return null
const title = stripMarkdownFormatting(trimmed.slice(2)).trim()
return title || null
}
return null
}
export function deriveDisplayTitleState(
content: string,
filename: string,
frontmatterTitle?: string | null,
): { title: string, hasH1: boolean } {
const h1Title = extractH1TitleFromContent(content)
if (h1Title) {
return { title: h1Title, hasH1: true }
}
const trimmedFrontmatterTitle = frontmatterTitle?.trim()
if (trimmedFrontmatterTitle) {
return { title: trimmedFrontmatterTitle, hasH1: false }
}
return { title: filenameStemToTitle(filename), hasH1: false }
}

View File

@@ -1,5 +1,6 @@
import { test, expect } from '@playwright/test'
import { test, expect, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { executeCommand, openCommandPalette, sendShortcut } from './helpers'
let tempVaultDir: string
@@ -13,6 +14,53 @@ test.afterEach(async () => {
removeFixtureVaultCopy(tempVaultDir)
})
async function openNote(page: Page, title: string) {
const noteList = page.locator('[data-testid="note-list-container"]')
await noteList.getByText(title, { exact: true }).click()
}
async function openRawMode(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 })
}
async function openBlockNoteMode(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function getRawEditorContent(page: Page): Promise<string> {
return page.evaluate(() => {
const el = document.querySelector('.cm-content')
if (!el) return ''
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const view = (el as any).cmTile?.view
if (view) return view.state.doc.toString() as string
return el.textContent ?? ''
})
}
async function setRawEditorContent(page: Page, content: string) {
await page.evaluate((newContent) => {
const el = document.querySelector('.cm-content')
if (!el) return
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const view = (el as any).cmTile?.view
if (!view) return
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: newContent },
})
}, content)
}
async function openQuickOpen(page: Page) {
await page.locator('body').click()
await sendShortcut(page, 'p', ['Control'])
await expect(page.locator('input[placeholder="Search notes..."]')).toBeVisible({ timeout: 5_000 })
}
test('creating an untitled draft hides the legacy title section in the editor', async ({ page }) => {
await page.locator('button[title="Create new note"]').click()
@@ -20,3 +68,42 @@ test('creating an untitled draft hides the legacy title section in the editor',
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 }) => {
const updatedTitle = 'Updated Display Title'
const noteList = page.locator('[data-testid="note-list-container"]')
await openNote(page, 'Note B')
await openRawMode(page)
const rawContent = await getRawEditorContent(page)
expect(rawContent).toContain('# Note B')
await setRawEditorContent(page, rawContent.replace('# Note B', `# ${updatedTitle}`))
await page.waitForTimeout(700)
await page.keyboard.press('Meta+s')
await openBlockNoteMode(page)
await expect(page.getByRole('heading', { name: updatedTitle, level: 1 })).toBeVisible({ timeout: 5_000 })
await expect(noteList.getByText(updatedTitle, { exact: true })).toBeVisible({ timeout: 5_000 })
await expect(noteList.getByText('Note B', { exact: true })).toHaveCount(0)
await openQuickOpen(page)
const quickOpenInput = page.locator('input[placeholder="Search notes..."]')
await quickOpenInput.fill(updatedTitle)
await expect(page.locator('[class*="bg-accent"] span.truncate').first()).toHaveText(updatedTitle, { timeout: 5_000 })
await page.keyboard.press('Escape')
await openNote(page, 'Alpha Project')
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 5_000 })
await editor.click()
await page.keyboard.press('End')
await page.keyboard.press('Enter')
await page.keyboard.type('[[Up')
const suggestionMenu = page.locator('.wikilink-menu')
await expect(suggestionMenu).toContainText(updatedTitle, { timeout: 5_000 })
await page.keyboard.press('Enter')
await expect(page.locator('.wikilink').last()).toHaveText(updatedTitle, { timeout: 5_000 })
})