fix: normalize windows wikilink targets

This commit is contained in:
lucaronin
2026-05-02 19:01:00 +02:00
parent 7a97f24a0e
commit e23ba0ae27
2 changed files with 58 additions and 14 deletions

View File

@@ -129,6 +129,24 @@ describe('relativePathStem', () => {
expect(relativePathStem('/Users/luca/Vault/docs/adr/0031.md', '/Users/luca/Vault')).toBe('docs/adr/0031')
})
it('normalizes Windows extended-length paths before extracting the vault-relative stem', () => {
expect(
relativePathStem(
'\\\\?\\C:\\Users\\lrfno\\Documents\\tolaria-vault\\application-design-and-build.md',
'C:\\Users\\lrfno\\Documents\\tolaria-vault',
),
).toBe('application-design-and-build')
})
it('keeps nested Windows note paths vault-relative with slash separators', () => {
expect(
relativePathStem(
'C:\\Users\\lrfno\\Documents\\Tolaria Vault\\projects\\application-design-and-build.md',
'c:/users/lrfno/documents/tolaria vault',
),
).toBe('projects/application-design-and-build')
})
it('falls back to filename stem when vault path does not match', () => {
expect(relativePathStem('/other/path/note.md', '/Users/luca/Vault')).toBe('note')
})

View File

@@ -3,15 +3,21 @@
import type { VaultEntry } from '../types'
import { slugifyNoteStem } from './noteSlug'
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: string): string {
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: string): string {
export function wikilinkDisplay(ref: WikilinkReference): string {
const inner = ref.replace(/^\[\[|\]\]$/g, '')
const pipeIdx = inner.indexOf('|')
if (pipeIdx !== -1) return inner.slice(pipeIdx + 1)
@@ -19,29 +25,49 @@ export function wikilinkDisplay(ref: string): string {
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: string, vaultPath: string): string {
const prefix = vaultPath.endsWith('/') ? vaultPath : vaultPath + '/'
if (absolutePath.startsWith(prefix)) return absolutePath.slice(prefix.length).replace(/\.md$/, '')
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 = absolutePath.split('/').pop() ?? absolutePath
return filename.replace(/\.md$/, '')
const filename = normalizedAbsolutePath.split('/').pop() ?? normalizedAbsolutePath
return withoutMarkdownExtension(filename)
}
/** Slugify a human-readable title into the canonical wikilink filename stem. */
export const slugifyWikilinkTarget = slugifyNoteStem
/** Build the canonical wikilink target for a vault entry. */
export function canonicalWikilinkTargetForEntry(entry: VaultEntry, vaultPath: string): string {
export function canonicalWikilinkTargetForEntry(entry: VaultEntry, vaultPath: VaultPath): WikilinkTarget {
return relativePathStem(entry.path, vaultPath)
}
/** Resolve a user-facing title/path input to the canonical wikilink target. */
export function canonicalWikilinkTargetForTitle(
titleOrTarget: string,
titleOrTarget: NoteTitleOrTarget,
entries: VaultEntry[],
vaultPath: string,
): string {
vaultPath: VaultPath,
): WikilinkTarget {
const trimmed = titleOrTarget.trim()
const resolved = resolveEntry(entries, trimmed)
return resolved
@@ -52,7 +78,7 @@ export function canonicalWikilinkTargetForTitle(
}
/** Wrap a target in wikilink syntax. */
export function formatWikilinkRef(target: string): string {
export function formatWikilinkRef(target: WikilinkTarget): WikilinkReference {
return `[[${target}]]`
}
@@ -63,7 +89,7 @@ interface ResolutionKey {
humanizedTarget: string | null
}
function buildResolutionKey(rawTarget: string): ResolutionKey {
function buildResolutionKey(rawTarget: WikilinkTarget): ResolutionKey {
const exactTarget = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget
const normalizedTarget = exactTarget.toLowerCase()
const lastSegment = exactTarget.includes('/') ? (exactTarget.split('/').pop() ?? exactTarget).toLowerCase() : normalizedTarget
@@ -116,7 +142,7 @@ function findEntryByHumanizedTitle(entries: VaultEntry[], resolutionKey: Resolut
* 4. Exact title match
* 5. Humanized title match (kebab-case → words)
*/
export function resolveEntry(entries: VaultEntry[], rawTarget: string): VaultEntry | undefined {
export function resolveEntry(entries: VaultEntry[], rawTarget: WikilinkTarget): VaultEntry | undefined {
const resolutionKey = buildResolutionKey(rawTarget)
return (
findEntryByPathSuffix(entries, resolutionKey)