hololake-system-architecture/product-source/hololake-platform/src/utils/wikilink.ts

294 lines
12 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
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<string, IndexedResolutionEntry[]>
resolutionCache: Map<string, VaultEntry | null>
workspaceAliases: Set<string>
}
type EntryMatcher = (entry: IndexedResolutionEntry, resolutionKey: ResolutionKey) => boolean
const resolutionIndexesByEntries = new WeakMap<VaultEntry[], ResolutionIndex>()
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 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<string, IndexedResolutionEntry[]>()
const workspaceAliases = new Set<string>()
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
}