/** Utility functions for parsing wikilink syntax: [[target|display]] */ import type { VaultEntry } from '../types' import { slugifyNoteStem } from './noteSlug' import { workspaceForEntry, workspacePathForEntry } from './workspaces' export type AbsoluteNotePath = string export type NoteTitleOrTarget = string export type VaultPath = string export type WikilinkReference = string export type WikilinkTarget = string /** Extracts the target path from a wikilink reference (strips [[ ]] and display text). */ export function wikilinkTarget(ref: WikilinkReference): WikilinkTarget { const inner = ref.replace(/^\[\[|\]\]$/g, '') const pipeIdx = inner.indexOf('|') return pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner } /** Extracts the display label from a wikilink reference. Falls back to humanised path stem. */ export function wikilinkDisplay(ref: WikilinkReference): string { const inner = ref.replace(/^\[\[|\]\]$/g, '') const pipeIdx = inner.indexOf('|') if (pipeIdx !== -1) return inner.slice(pipeIdx + 1) const last = inner.split('/').pop() ?? inner return last.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) } function stripWindowsExtendedPathPrefix(path: AbsoluteNotePath | VaultPath): string { return path .replace(/^\\\\\?\\UNC\\/i, '//') .replace(/^\\\\\?\\/, '') } function normalizeFilesystemPath(path: AbsoluteNotePath | VaultPath): string { return stripWindowsExtendedPathPrefix(path) .replace(/\\/g, '/') .replace(/\/+$/g, '') } function withoutMarkdownExtension(pathStem: WikilinkTarget): WikilinkTarget { return pathStem.replace(/\.md$/i, '') } /** Extract the vault-relative path stem (no leading slash, no .md extension). */ export function relativePathStem(absolutePath: AbsoluteNotePath, vaultPath: VaultPath): WikilinkTarget { const normalizedAbsolutePath = normalizeFilesystemPath(absolutePath) const normalizedVaultPath = normalizeFilesystemPath(vaultPath) const prefix = normalizedVaultPath.endsWith('/') ? normalizedVaultPath : normalizedVaultPath + '/' if (normalizedAbsolutePath.toLowerCase().startsWith(prefix.toLowerCase())) { return withoutMarkdownExtension(normalizedAbsolutePath.slice(prefix.length)) } // Fallback: just the filename stem const filename = normalizedAbsolutePath.split('/').pop() ?? normalizedAbsolutePath return withoutMarkdownExtension(filename) } /** Slugify a human-readable title into the canonical wikilink filename stem. */ export const slugifyWikilinkTarget = slugifyNoteStem function shouldPrefixWorkspaceAlias(entryAlias?: string, sourceAlias?: string): boolean { return !!entryAlias && !!sourceAlias && entryAlias !== sourceAlias } /** Build the canonical wikilink target for a vault entry. */ export function canonicalWikilinkTargetForEntry(entry: VaultEntry, vaultPath: VaultPath, sourceEntry?: VaultEntry): WikilinkTarget { const entryWorkspace = workspaceForEntry(entry) const sourceWorkspace = sourceEntry ? workspaceForEntry(sourceEntry) : null const entryVaultPath = workspacePathForEntry(entry) ?? vaultPath const localTarget = relativePathStem(entry.path, entryVaultPath) const entryAlias = entryWorkspace?.alias if (shouldPrefixWorkspaceAlias(entryAlias, sourceWorkspace?.alias)) { return `${entryAlias}/${localTarget}` } return localTarget } /** Resolve a user-facing title/path input to the canonical wikilink target. */ export function canonicalWikilinkTargetForTitle( titleOrTarget: NoteTitleOrTarget, entries: VaultEntry[], vaultPath: VaultPath, sourceEntry?: VaultEntry, ): WikilinkTarget { const trimmed = titleOrTarget.trim() const resolved = resolveEntry(entries, trimmed, sourceEntry) return resolved ? canonicalWikilinkTargetForEntry(resolved, vaultPath, sourceEntry) : trimmed.includes('/') ? trimmed.replace(/^\/+/, '').replace(/\.md$/, '') : slugifyWikilinkTarget(trimmed) } /** Wrap a target in wikilink syntax. */ export function formatWikilinkRef(target: WikilinkTarget): WikilinkReference { return `[[${target}]]` } interface ResolutionKey { exactTarget: string workspaceAlias: string | null targetWithoutWorkspace: string lastSegment: string pathSuffixes: string[] humanizedTarget: string | null } interface IndexedResolutionEntry { aliases: string[] entry: VaultEntry filenameStem: string normalizedPath: string title: string workspaceAlias: string | null } interface ResolutionIndex { entries: IndexedResolutionEntry[] entriesByWorkspaceAlias: Map resolutionCache: Map workspaceAliases: Set } type EntryMatcher = (entry: IndexedResolutionEntry, resolutionKey: ResolutionKey) => boolean const resolutionIndexesByEntries = new WeakMap() function buildResolutionKey(rawTarget: WikilinkTarget, knownWorkspaceAliases: Set = new Set()): ResolutionKey { const exactTarget = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget const normalizedTarget = exactTarget.toLowerCase() const segments = exactTarget.split('/').filter(Boolean) const candidateWorkspaceAlias = segments.length > 1 ? segments[0].toLowerCase() : null const workspaceAlias = candidateWorkspaceAlias && knownWorkspaceAliases.has(candidateWorkspaceAlias) ? candidateWorkspaceAlias : null const targetWithoutWorkspace = workspaceAlias ? segments.slice(1).join('/') : exactTarget const normalizedLocalTarget = targetWithoutWorkspace.toLowerCase() const normalizedPathTarget = normalizedLocalTarget.replace(/^\/+/, '') const pathSuffixes = normalizedPathTarget.includes('/') ? [`/${normalizedPathTarget}`, ...normalizedPathTarget.endsWith('.md') ? [] : [`/${normalizedPathTarget}.md`]] : [] const lastSegment = targetWithoutWorkspace.includes('/') ? (targetWithoutWorkspace.split('/').pop() ?? targetWithoutWorkspace).toLowerCase() : normalizedLocalTarget const humanizedTarget = lastSegment.replace(/-/g, ' ') return { exactTarget: normalizedTarget, workspaceAlias, targetWithoutWorkspace: normalizedLocalTarget, lastSegment, pathSuffixes, humanizedTarget: humanizedTarget === normalizedLocalTarget ? null : humanizedTarget, } } function workspaceAliasForEntry(entry: VaultEntry | undefined): string | null { return entry ? (workspaceForEntry(entry)?.alias.toLowerCase() ?? null) : null } function buildIndexedResolutionEntry(entry: VaultEntry): IndexedResolutionEntry { return { aliases: entry.aliases.map((alias) => alias.toLowerCase()), entry, filenameStem: entry.filename.replace(/\.md$/, '').toLowerCase(), normalizedPath: normalizeFilesystemPath(entry.path).toLowerCase(), title: entry.title.toLowerCase(), workspaceAlias: workspaceAliasForEntry(entry), } } function buildResolutionIndex(entries: VaultEntry[]): ResolutionIndex { const indexedEntries = entries.map(buildIndexedResolutionEntry) const entriesByWorkspaceAlias = new Map() const workspaceAliases = new Set() for (const entry of indexedEntries) { if (!entry.workspaceAlias) continue workspaceAliases.add(entry.workspaceAlias) const workspaceEntries = entriesByWorkspaceAlias.get(entry.workspaceAlias) ?? [] workspaceEntries.push(entry) entriesByWorkspaceAlias.set(entry.workspaceAlias, workspaceEntries) } return { entries: indexedEntries, entriesByWorkspaceAlias, resolutionCache: new Map(), workspaceAliases, } } function resolutionIndexForEntries(entries: VaultEntry[]): ResolutionIndex { const cached = resolutionIndexesByEntries.get(entries) if (cached) return cached const index = buildResolutionIndex(entries) resolutionIndexesByEntries.set(entries, index) return index } function resolutionCacheKey(resolutionKey: ResolutionKey, sourceWorkspaceAlias: string | null): string { return `${sourceWorkspaceAlias ?? ''}\n${resolutionKey.exactTarget}` } function findIndexedEntry(entries: IndexedResolutionEntry[], resolutionKey: ResolutionKey, matcher: EntryMatcher): VaultEntry | undefined { for (const entry of entries) { if (matcher(entry, resolutionKey)) return entry.entry } return undefined } function findPrioritizedEntry( index: ResolutionIndex, resolutionKey: ResolutionKey, sourceWorkspaceAlias: string | null, matcher: EntryMatcher, ): VaultEntry | undefined { if (resolutionKey.workspaceAlias) { return findIndexedEntry(index.entriesByWorkspaceAlias.get(resolutionKey.workspaceAlias) ?? [], resolutionKey, matcher) } if (!sourceWorkspaceAlias) return findIndexedEntry(index.entries, resolutionKey, matcher) const workspaceMatch = findIndexedEntry(index.entriesByWorkspaceAlias.get(sourceWorkspaceAlias) ?? [], resolutionKey, matcher) if (workspaceMatch) return workspaceMatch for (const entry of index.entries) { if (entry.workspaceAlias === sourceWorkspaceAlias) continue if (matcher(entry, resolutionKey)) return entry.entry } return undefined } function matchesPathSuffix(entry: IndexedResolutionEntry, resolutionKey: ResolutionKey): boolean { return resolutionKey.pathSuffixes.some((pathSuffix) => entry.normalizedPath.endsWith(pathSuffix)) } function matchesFilename(entry: IndexedResolutionEntry, resolutionKey: ResolutionKey): boolean { return entry.filenameStem === resolutionKey.exactTarget || entry.filenameStem === resolutionKey.targetWithoutWorkspace || entry.filenameStem === resolutionKey.lastSegment } function matchesAlias(entry: IndexedResolutionEntry, resolutionKey: ResolutionKey): boolean { return entry.aliases.some((alias) => ( alias === resolutionKey.exactTarget || alias === resolutionKey.targetWithoutWorkspace )) } function matchesTitle(entry: IndexedResolutionEntry, resolutionKey: ResolutionKey): boolean { return entry.title === resolutionKey.exactTarget || entry.title === resolutionKey.targetWithoutWorkspace || entry.title === resolutionKey.lastSegment } function matchesHumanizedTitle(entry: IndexedResolutionEntry, resolutionKey: ResolutionKey): boolean { return !!resolutionKey.humanizedTarget && entry.title === resolutionKey.humanizedTarget } function resolveEntryFromIndex( index: ResolutionIndex, resolutionKey: ResolutionKey, sourceWorkspaceAlias: string | null, ): VaultEntry | undefined { return ( (resolutionKey.pathSuffixes.length > 0 ? findPrioritizedEntry(index, resolutionKey, sourceWorkspaceAlias, matchesPathSuffix) : undefined) ?? findPrioritizedEntry(index, resolutionKey, sourceWorkspaceAlias, matchesFilename) ?? findPrioritizedEntry(index, resolutionKey, sourceWorkspaceAlias, matchesAlias) ?? findPrioritizedEntry(index, resolutionKey, sourceWorkspaceAlias, matchesTitle) ?? findPrioritizedEntry(index, resolutionKey, sourceWorkspaceAlias, matchesHumanizedTitle) ) } /** * Unified wikilink resolution: find the VaultEntry matching a wikilink target. * Handles pipe syntax, case-insensitive matching. * Resolution order (multi-pass, global priority): * 1. Path-suffix match (for path-style targets like "docs/adr/0031-foo") * 2. Filename stem match (strongest for flat vaults) * 3. Alias match * 4. Exact title match * 5. Humanized title match (kebab-case → words) */ export function resolveEntry(entries: VaultEntry[], rawTarget: WikilinkTarget, sourceEntry?: VaultEntry): VaultEntry | undefined { const index = resolutionIndexForEntries(entries) const resolutionKey = buildResolutionKey(rawTarget, index.workspaceAliases) const sourceWorkspaceAlias = workspaceAliasForEntry(sourceEntry) const cacheKey = resolutionCacheKey(resolutionKey, sourceWorkspaceAlias) if (index.resolutionCache.has(cacheKey)) return index.resolutionCache.get(cacheKey) ?? undefined const resolved = resolveEntryFromIndex(index, resolutionKey, sourceWorkspaceAlias) index.resolutionCache.set(cacheKey, resolved ?? null) return resolved }