// Wikilinks and the link resolver of design 1.3. Links are [[target]], [[target|label]] and // [[target#heading]], with ![[...]] embeds read the same way. Links inside fenced code blocks and // inline code spans are not links, as in Obsidian. A body embed that resolves to no indexed note and whose // target ends in an extension other than `.md`, in any case, embeds an attachment (C2-01b N3): the // reader doesn't index it, and it raises no problem. import { posix } from 'node:path'; const WIKILINK = /(!?)\[\[([^\[\]\n]+?)\]\]/g; const MARKDOWN_LINK = /(? link.embed === true && !link.field && /\.(?=[a-z0-9]*[a-z])[a-z0-9]{1,8}$/i.test(link.file.slice(link.file.lastIndexOf('/') + 1)) && !/\.md$/i.test(link.file); // The form a link is counted under in the survey. export const formOf = link => link.markdown ? 'markdown' : link.embed ? 'embed' : !link.file ? 'self' : link.label !== null ? 'label' : link.heading !== null ? 'heading' : 'plain'; // Links in body lines. `lines` is [[number, text], ...]. Markdown links are returned only with // `markdown: true`, for the survey's counts. The resolver follows wikilinks only. export function extractLinks(lines, { markdown = false } = {}) { const found = []; let fence = null; for (const [line, text] of lines) { const open = FENCE.exec(text); if (fence) { if (open && open[1][0] === fence[0] && open[1].length >= fence.length) fence = null; continue; } if (open) { fence = open[1]; continue; } const plain = text.replace(/(`+)[^`]*?\1/g, match => ' '.repeat(match.length)); for (const m of plain.matchAll(WIKILINK)) found.push({ ...parts(m[2], m[1] === '!'), line }); if (markdown) { for (const m of plain.matchAll(MARKDOWN_LINK)) { if (!/^[a-z][a-z0-9+.-]*:/i.test(m[1]) && !m[1].startsWith('#')) found.push({ file: m[1], heading: null, label: null, embed: false, markdown: true, line }); } } } return found; } // A resolver over the indexed paths. resolve(from, file) tries, in order: a path from the repo // root, the same path with `vaultPrefix/` stripped, a path relative to the linking file, and // Obsidian's shortest-path form, a base name that matches exactly one indexed file. Each step tries // the target as written and then with `.md` added. A target in another of her repos is not in the // index, so it resolves to nothing. export function resolver(paths, { vaultPrefix } = {}) { const known = new Set(paths), byBase = new Map(); for (const path of known) { const base = posix.basename(path); for (const key of new Set([base, base.replace(/\.md$/, '')])) byBase.set(key, [...(byBase.get(key) ?? []), path]); } const hit = candidate => [candidate, `${candidate}.md`].find(c => known.has(c)); return function resolve(from, file) { if (typeof file !== 'string' || !file) return { path: null, step: 'missing' }; let path = hit(file); if (path) return { path, step: 'root' }; if (vaultPrefix && file.startsWith(`${vaultPrefix}/`) && (path = hit(file.slice(vaultPrefix.length + 1)))) return { path, step: 'vault-prefix' }; const relative = posix.normalize(posix.join(posix.dirname(from), file)); if (relative !== '..' && !relative.startsWith('../') && !relative.startsWith('/') && (path = hit(relative))) return { path, step: 'relative' }; if (!file.includes('/')) { const matches = byBase.get(file) ?? []; if (matches.length === 1) return { path: matches[0], step: 'shortest' }; if (matches.length > 1) return { path: null, step: 'ambiguous', count: matches.length }; } return { path: null, step: 'missing' }; }; }