202 lines
8.8 KiB
TypeScript
202 lines
8.8 KiB
TypeScript
/** 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
|
|
pathSuffix: string | null
|
|
humanizedTarget: string | null
|
|
}
|
|
|
|
function buildResolutionKey(rawTarget: WikilinkTarget, knownWorkspaceAliases: Set<string> = 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 lastSegment = targetWithoutWorkspace.includes('/') ? (targetWithoutWorkspace.split('/').pop() ?? targetWithoutWorkspace).toLowerCase() : normalizedLocalTarget
|
|
const humanizedTarget = lastSegment.replace(/-/g, ' ')
|
|
|
|
return {
|
|
exactTarget: normalizedTarget,
|
|
workspaceAlias,
|
|
targetWithoutWorkspace: normalizedLocalTarget,
|
|
lastSegment,
|
|
pathSuffix: targetWithoutWorkspace.includes('/') ? `/${normalizedLocalTarget}.md` : null,
|
|
humanizedTarget: humanizedTarget === normalizedLocalTarget ? null : humanizedTarget,
|
|
}
|
|
}
|
|
|
|
function filterEntriesByWorkspace(entries: VaultEntry[], alias: string | null): VaultEntry[] {
|
|
if (!alias) return entries
|
|
return entries.filter((entry) => workspaceForEntry(entry)?.alias.toLowerCase() === alias)
|
|
}
|
|
|
|
function prioritizeSourceWorkspace(entries: VaultEntry[], sourceEntry?: VaultEntry): VaultEntry[] {
|
|
const sourceWorkspace = sourceEntry ? workspaceForEntry(sourceEntry) : null
|
|
if (!sourceWorkspace) return entries
|
|
return [
|
|
...entries.filter((entry) => workspaceForEntry(entry)?.alias === sourceWorkspace.alias),
|
|
...entries.filter((entry) => workspaceForEntry(entry)?.alias !== sourceWorkspace.alias),
|
|
]
|
|
}
|
|
|
|
function findEntryByPathSuffix(entries: VaultEntry[], resolutionKey: ResolutionKey): VaultEntry | undefined {
|
|
if (!resolutionKey.pathSuffix) return undefined
|
|
const { pathSuffix } = resolutionKey
|
|
return entries.find(entry => entry.path.toLowerCase().endsWith(pathSuffix))
|
|
}
|
|
|
|
function findEntryByFilename(entries: VaultEntry[], { exactTarget, targetWithoutWorkspace, lastSegment }: ResolutionKey): VaultEntry | undefined {
|
|
return entries.find((entry) => {
|
|
const stem = entry.filename.replace(/\.md$/, '').toLowerCase()
|
|
return stem === exactTarget || stem === targetWithoutWorkspace || stem === lastSegment
|
|
})
|
|
}
|
|
|
|
function findEntryByAlias(entries: VaultEntry[], resolutionKey: ResolutionKey): VaultEntry | undefined {
|
|
return entries.find(entry => entry.aliases.some((alias) => {
|
|
const normalizedAlias = alias.toLowerCase()
|
|
return normalizedAlias === resolutionKey.exactTarget || normalizedAlias === resolutionKey.targetWithoutWorkspace
|
|
}))
|
|
}
|
|
|
|
function findEntryByTitle(entries: VaultEntry[], resolutionKey: ResolutionKey): VaultEntry | undefined {
|
|
return entries.find((entry) => {
|
|
const lowerTitle = entry.title.toLowerCase()
|
|
return lowerTitle === resolutionKey.exactTarget || lowerTitle === resolutionKey.targetWithoutWorkspace || lowerTitle === resolutionKey.lastSegment
|
|
})
|
|
}
|
|
|
|
function findEntryByHumanizedTitle(entries: VaultEntry[], resolutionKey: ResolutionKey): VaultEntry | undefined {
|
|
if (!resolutionKey.humanizedTarget) return undefined
|
|
return entries.find(entry => entry.title.toLowerCase() === resolutionKey.humanizedTarget)
|
|
}
|
|
|
|
/**
|
|
* 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 workspaceAliases = new Set(entries.map((entry) => workspaceForEntry(entry)?.alias.toLowerCase()).filter((alias): alias is string => !!alias))
|
|
const resolutionKey = buildResolutionKey(rawTarget, workspaceAliases)
|
|
const workspaceScopedEntries = filterEntriesByWorkspace(entries, resolutionKey.workspaceAlias)
|
|
const candidates = resolutionKey.workspaceAlias
|
|
? workspaceScopedEntries
|
|
: prioritizeSourceWorkspace(entries, sourceEntry)
|
|
return (
|
|
findEntryByPathSuffix(candidates, resolutionKey)
|
|
?? findEntryByFilename(candidates, resolutionKey)
|
|
?? findEntryByAlias(candidates, resolutionKey)
|
|
?? findEntryByTitle(candidates, resolutionKey)
|
|
?? findEntryByHumanizedTitle(candidates, resolutionKey)
|
|
)
|
|
}
|