feat: standardize canonical wikilink targets
This commit is contained in:
117
src/utils/rawEditorUtils.test.ts
Normal file
117
src/utils/rawEditorUtils.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user