feat: standardize canonical wikilink targets

This commit is contained in:
lucaronin
2026-04-10 19:57:21 +02:00
parent a44dd41f95
commit 5ebffc447b
19 changed files with 792 additions and 348 deletions

View File

@@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest'
import type { VaultEntry } from '../types'
import { buildRawEditorBaseItems, detectYamlError, extractWikilinkQuery, getRawEditorDropdownPosition, replaceActiveWikilinkQuery } from './rawEditorUtils'
describe('extractWikilinkQuery', () => {
it('returns null when no [[ trigger', () => {
expect(extractWikilinkQuery('hello world', 5)).toBeNull()
})
it('returns empty string immediately after [[', () => {
const text = 'see [['
expect(extractWikilinkQuery(text, text.length)).toBe('')
})
it('returns query after [[', () => {
const text = 'see [[Proj'
expect(extractWikilinkQuery(text, text.length)).toBe('Proj')
})
it('returns null when ]] closes the link', () => {
const text = '[[Proj]]'
expect(extractWikilinkQuery(text, text.length)).toBeNull()
})
it('returns null when newline is in query', () => {
const text = '[[Proj\ncontinued'
expect(extractWikilinkQuery(text, text.length)).toBeNull()
})
it('handles cursor before end of text', () => {
const text = '[[Proj after'
expect(extractWikilinkQuery(text, 6)).toBe('Proj')
})
})
describe('replaceActiveWikilinkQuery', () => {
it('replaces the active wikilink query with the canonical target', () => {
expect(replaceActiveWikilinkQuery('See [[Proj', 10, 'projects/alpha')).toEqual({
text: 'See [[projects/alpha]]',
cursor: 22,
})
})
it('preserves text after the cursor', () => {
expect(replaceActiveWikilinkQuery('See [[Proj today', 10, 'projects/alpha')).toEqual({
text: 'See [[projects/alpha]] today',
cursor: 22,
})
})
it('returns null when no active wikilink trigger exists', () => {
expect(replaceActiveWikilinkQuery('See Proj', 8, 'projects/alpha')).toBeNull()
})
})
describe('detectYamlError', () => {
it('returns null for content without frontmatter', () => {
expect(detectYamlError('# Title\n\nSome content.')).toBeNull()
})
it('returns null for valid frontmatter', () => {
expect(detectYamlError('---\ntitle: My Note\n---\n\n# Title')).toBeNull()
})
it('returns error for unclosed frontmatter', () => {
const error = detectYamlError('---\ntitle: My Note\n\n# Title')
expect(error).toContain('Unclosed frontmatter')
})
it('returns error for tab indentation in frontmatter', () => {
const error = detectYamlError('---\n\ttitle: My Note\n---\n')
expect(error).toContain('tab indentation')
})
it('returns null for content not starting with ---', () => {
expect(detectYamlError('Not frontmatter')).toBeNull()
})
})
describe('buildRawEditorBaseItems', () => {
it('includes filename aliases and deduplicates entries by path', () => {
expect(buildRawEditorBaseItems([
{
title: 'Project Alpha',
aliases: ['Alpha'],
filename: 'project-alpha.md',
isA: 'Project',
path: 'projects/project-alpha.md',
},
{
title: 'Project Alpha',
aliases: ['Ignored'],
filename: 'project-alpha.md',
isA: 'Project',
path: 'projects/project-alpha.md',
},
] as VaultEntry[])).toEqual([
{
title: 'Project Alpha',
aliases: ['project-alpha', 'Alpha'],
group: 'Project',
entryTitle: 'Project Alpha',
path: 'projects/project-alpha.md',
},
])
})
})
describe('getRawEditorDropdownPosition', () => {
it('renders above the cursor when there is not enough space below', () => {
expect(getRawEditorDropdownPosition(
{ caretTop: 500, caretLeft: 900, selectedIndex: 0, items: [] },
200,
{ innerHeight: 640, innerWidth: 1000 },
)).toEqual({ top: 276, left: 740 })
})
})

View File

@@ -1,3 +1,33 @@
import type { EditorView } from '@codemirror/view'
import { attachClickHandlers, enrichSuggestionItems } from './suggestionEnrichment'
import { deduplicateByPath, preFilterWikilinks } from './wikilinkSuggestions'
import type { VaultEntry } from '../types'
import type { WikilinkSuggestionItem } from '../components/WikilinkSuggestionMenu'
export interface RawEditorBaseItem {
title: string
aliases: string[]
group: string
entryTitle: string
path: string
}
export interface RawEditorAutocompleteState {
caretTop: number
caretLeft: number
selectedIndex: number
items: WikilinkSuggestionItem[]
}
interface RawEditorAutocompleteParams {
view: EditorView
baseItems: RawEditorBaseItem[]
query: string
typeEntryMap: Record<string, VaultEntry>
onInsertTarget: (target: string) => void
vaultPath: string
}
/** Extract the wikilink query that the user is currently typing after [[ */
export function extractWikilinkQuery(text: string, cursor: number): string | null {
const before = text.slice(0, cursor)
@@ -9,6 +39,75 @@ export function extractWikilinkQuery(text: string, cursor: number): string | nul
return afterTrigger
}
export function replaceActiveWikilinkQuery(
text: string,
cursor: number,
target: string,
): { text: string; cursor: number } | null {
const before = text.slice(0, cursor)
const triggerIdx = before.lastIndexOf('[[')
if (triggerIdx === -1) return null
const after = text.slice(cursor)
return {
text: `${text.slice(0, triggerIdx)}[[${target}]]${after}`,
cursor: triggerIdx + target.length + 4,
}
}
export function buildRawEditorBaseItems(entries: VaultEntry[]): RawEditorBaseItem[] {
return deduplicateByPath(entries.map(entry => ({
title: entry.title,
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
group: entry.isA || 'Note',
entryTitle: entry.title,
path: entry.path,
})))
}
export function getRawEditorCursorCoords(view: EditorView): { top: number; left: number } | null {
const pos = view.state.selection.main.head
const coords = view.coordsAtPos(pos)
if (!coords) return null
return { top: coords.bottom, left: coords.left }
}
export function buildRawEditorAutocompleteState({
view,
baseItems,
query,
typeEntryMap,
onInsertTarget,
vaultPath,
}: RawEditorAutocompleteParams): RawEditorAutocompleteState | null {
const coords = getRawEditorCursorCoords(view)
if (!coords) return null
const candidates = preFilterWikilinks(baseItems, query)
const withHandlers = attachClickHandlers(candidates, onInsertTarget, vaultPath)
const items = enrichSuggestionItems(withHandlers, query, typeEntryMap)
return {
caretTop: coords.top,
caretLeft: coords.left,
selectedIndex: 0,
items,
}
}
export function getRawEditorDropdownPosition(
autocomplete: RawEditorAutocompleteState | null,
maxHeight: number,
viewport: { innerHeight: number; innerWidth: number },
): { top: number; left: number } {
if (!autocomplete) return { top: 0, left: 0 }
const dropdownBelow = autocomplete.caretTop + 20 + maxHeight <= viewport.innerHeight
return {
top: dropdownBelow ? autocomplete.caretTop + 4 : autocomplete.caretTop - maxHeight - 24,
left: Math.min(autocomplete.caretLeft, viewport.innerWidth - 260),
}
}
/** Basic YAML frontmatter structural checks. */
export function detectYamlError(content: string): string | null {
if (!content.startsWith('---')) return null

View File

@@ -32,9 +32,9 @@ describe('attachClickHandlers', () => {
expect(result).toHaveLength(2)
result[0].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('a|Note A')
expect(insertWikilink).toHaveBeenCalledWith('a')
result[1].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('b|Note B')
expect(insertWikilink).toHaveBeenCalledWith('b')
})
it('preserves all original properties', () => {
@@ -55,13 +55,13 @@ describe('attachClickHandlers', () => {
const result = attachClickHandlers(candidates, insertWikilink, vaultPath)
result[0].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('docs/adr/0001-tauri-stack|ADR 001')
expect(insertWikilink).toHaveBeenCalledWith('docs/adr/0001-tauri-stack')
})
it('omits pipe display when title matches path stem', () => {
it('omits any default alias even when the title differs from the path stem', () => {
const insertWikilink = vi.fn()
const candidates = [
{ title: 'roadmap', aliases: [], group: 'Note', entryTitle: 'roadmap', path: '/vault/roadmap.md' },
{ title: 'Roadmap', aliases: [], group: 'Note', entryTitle: 'Roadmap', path: '/vault/roadmap.md' },
]
const result = attachClickHandlers(candidates, insertWikilink, vaultPath)

View File

@@ -17,17 +17,14 @@ interface BaseSuggestionItem {
path: string
}
/** Build the wikilink target: relative path stem with pipe display for the title.
* e.g. "docs/adr/0001-tauri-stack|Tauri Stack" for subfolders,
* "roadmap|Roadmap" for root files. */
/** Build the canonical wikilink target: vault-relative path stem without a default alias. */
function buildTarget(item: BaseSuggestionItem, vaultPath: string): string {
const stem = relativePathStem(item.path, vaultPath)
return stem === item.entryTitle ? stem : `${stem}|${item.entryTitle}`
return relativePathStem(item.path, vaultPath)
}
/** Add onItemClick to raw suggestion candidates.
* Always inserts the vault-relative path as the wikilink target
* so links are unambiguous and work across subfolders. */
* Always inserts the canonical vault-relative path target so links are
* unambiguous and remain stable across renames. */
export function attachClickHandlers(
candidates: BaseSuggestionItem[],
insertWikilink: (target: string) => void,

View File

@@ -1,6 +1,15 @@
import { describe, it, expect } from 'vitest'
import type { VaultEntry } from '../types'
import { wikilinkTarget, wikilinkDisplay, resolveEntry, relativePathStem } from './wikilink'
import {
wikilinkTarget,
wikilinkDisplay,
resolveEntry,
relativePathStem,
slugifyWikilinkTarget,
canonicalWikilinkTargetForEntry,
canonicalWikilinkTargetForTitle,
formatWikilinkRef,
} from './wikilink'
function makeEntry(overrides: Partial<VaultEntry>): VaultEntry {
return {
@@ -124,3 +133,42 @@ describe('relativePathStem', () => {
expect(relativePathStem('/other/path/note.md', '/Users/luca/Vault')).toBe('note')
})
})
describe('slugifyWikilinkTarget', () => {
it('slugifies a human title to a canonical target', () => {
expect(slugifyWikilinkTarget('Weekly Review')).toBe('weekly-review')
})
it('falls back to untitled when the title has no slug characters', () => {
expect(slugifyWikilinkTarget('+++')).toBe('untitled')
})
})
describe('canonicalWikilinkTargetForEntry', () => {
it('returns a vault-relative path stem', () => {
const entry = makeEntry({ path: '/vault/projects/alpha.md', filename: 'alpha.md', title: 'Alpha' })
expect(canonicalWikilinkTargetForEntry(entry, '/vault')).toBe('projects/alpha')
})
})
describe('canonicalWikilinkTargetForTitle', () => {
const project = makeEntry({ path: '/vault/projects/alpha.md', filename: 'alpha.md', title: 'Alpha Project' })
it('resolves an existing entry to its canonical path target', () => {
expect(canonicalWikilinkTargetForTitle('Alpha Project', [project], '/vault')).toBe('projects/alpha')
})
it('keeps a canonical path input canonical', () => {
expect(canonicalWikilinkTargetForTitle('projects/alpha', [project], '/vault')).toBe('projects/alpha')
})
it('falls back to a slug for a newly created note title', () => {
expect(canonicalWikilinkTargetForTitle('Brand New Note', [], '/vault')).toBe('brand-new-note')
})
})
describe('formatWikilinkRef', () => {
it('wraps a canonical target in wikilink syntax', () => {
expect(formatWikilinkRef('projects/alpha')).toBe('[[projects/alpha]]')
})
})

View File

@@ -27,6 +27,86 @@ export function relativePathStem(absolutePath: string, vaultPath: string): strin
return filename.replace(/\.md$/, '')
}
/** Slugify a human-readable title into the canonical wikilink filename stem. */
export function slugifyWikilinkTarget(title: string): string {
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
return slug || 'untitled'
}
/** Build the canonical wikilink target for a vault entry. */
export function canonicalWikilinkTargetForEntry(entry: VaultEntry, vaultPath: string): string {
return relativePathStem(entry.path, vaultPath)
}
/** Resolve a user-facing title/path input to the canonical wikilink target. */
export function canonicalWikilinkTargetForTitle(
titleOrTarget: string,
entries: VaultEntry[],
vaultPath: string,
): string {
const trimmed = titleOrTarget.trim()
const resolved = resolveEntry(entries, trimmed)
return resolved
? canonicalWikilinkTargetForEntry(resolved, vaultPath)
: trimmed.includes('/')
? trimmed.replace(/^\/+/, '').replace(/\.md$/, '')
: slugifyWikilinkTarget(trimmed)
}
/** Wrap a target in wikilink syntax. */
export function formatWikilinkRef(target: string): string {
return `[[${target}]]`
}
interface ResolutionKey {
exactTarget: string
lastSegment: string
pathSuffix: string | null
humanizedTarget: string | null
}
function buildResolutionKey(rawTarget: string): ResolutionKey {
const exactTarget = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget
const normalizedTarget = exactTarget.toLowerCase()
const lastSegment = exactTarget.includes('/') ? (exactTarget.split('/').pop() ?? exactTarget).toLowerCase() : normalizedTarget
const humanizedTarget = lastSegment.replace(/-/g, ' ')
return {
exactTarget: normalizedTarget,
lastSegment,
pathSuffix: exactTarget.includes('/') ? `/${normalizedTarget}.md` : null,
humanizedTarget: humanizedTarget === normalizedTarget ? null : humanizedTarget,
}
}
function findEntryByPathSuffix(entries: VaultEntry[], pathSuffix: string | null): VaultEntry | undefined {
if (!pathSuffix) return undefined
return entries.find(entry => entry.path.toLowerCase().endsWith(pathSuffix))
}
function findEntryByFilename(entries: VaultEntry[], { exactTarget, lastSegment }: ResolutionKey): VaultEntry | undefined {
return entries.find((entry) => {
const stem = entry.filename.replace(/\.md$/, '').toLowerCase()
return stem === exactTarget || stem === lastSegment
})
}
function findEntryByAlias(entries: VaultEntry[], exactTarget: string): VaultEntry | undefined {
return entries.find(entry => entry.aliases.some(alias => alias.toLowerCase() === exactTarget))
}
function findEntryByTitle(entries: VaultEntry[], exactTarget: string, lastSegment: string): VaultEntry | undefined {
return entries.find((entry) => {
const lowerTitle = entry.title.toLowerCase()
return lowerTitle === exactTarget || lowerTitle === lastSegment
})
}
function findEntryByHumanizedTitle(entries: VaultEntry[], humanizedTarget: string | null): VaultEntry | undefined {
if (!humanizedTarget) return undefined
return entries.find(entry => entry.title.toLowerCase() === humanizedTarget)
}
/**
* Unified wikilink resolution: find the VaultEntry matching a wikilink target.
* Handles pipe syntax, case-insensitive matching.
@@ -38,37 +118,12 @@ export function relativePathStem(absolutePath: string, vaultPath: string): strin
* 5. Humanized title match (kebab-case → words)
*/
export function resolveEntry(entries: VaultEntry[], rawTarget: string): VaultEntry | undefined {
const key = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget
const keyLower = key.toLowerCase()
const lastSegment = key.includes('/') ? (key.split('/').pop() ?? key) : key
const lastSegmentLower = lastSegment.toLowerCase()
const asWords = lastSegmentLower.replace(/-/g, ' ')
const pathSuffix = key.includes('/') ? '/' + key.toLowerCase() + '.md' : null
// Pass 1: path-suffix match (for subfolder targets like "docs/adr/0031-foo")
if (pathSuffix) {
for (const e of entries) {
if (e.path.toLowerCase().endsWith(pathSuffix)) return e
}
}
// Pass 2: filename stem (strongest for flat vault)
for (const e of entries) {
const stem = e.filename.replace(/\.md$/, '').toLowerCase()
if (stem === keyLower || stem === lastSegmentLower) return e
}
// Pass 3: alias
for (const e of entries) {
if (e.aliases.some(a => a.toLowerCase() === keyLower)) return e
}
// Pass 4: exact title
for (const e of entries) {
if (e.title.toLowerCase() === keyLower || e.title.toLowerCase() === lastSegmentLower) return e
}
// Pass 5: humanized title (kebab-case → words)
if (asWords !== keyLower) {
for (const e of entries) {
if (e.title.toLowerCase() === asWords) return e
}
}
return undefined
const resolutionKey = buildResolutionKey(rawTarget)
return (
findEntryByPathSuffix(entries, resolutionKey.pathSuffix)
?? findEntryByFilename(entries, resolutionKey)
?? findEntryByAlias(entries, resolutionKey.exactTarget)
?? findEntryByTitle(entries, resolutionKey.exactTarget, resolutionKey.lastSegment)
?? findEntryByHumanizedTitle(entries, resolutionKey.humanizedTarget)
)
}