fix: use h1 titles for displayed note names
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
¬e_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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
49
src/utils/noteTitle.test.ts
Normal file
49
src/utils/noteTitle.test.ts
Normal 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
68
src/utils/noteTitle.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user