Lands Gosalyn's round 2 candidate,f37a65857on gosalyn/c2-01b-r2 (base fefd54e13; commits3647a5a93,88daaac50,f37a65857), approved by Webby at merge scope (webby-0072, REVIEW-r2efd6c1f02on webby/reviews, #1733 comment 30005, rev 2787). Nine paths, manifest 312c16fc4f65. No 231 path changed betweenfefd54e13and this commit's parent. Patch-id 59b98b1b3e00, the same as fefd54e13..f37a65857. Evidence: Gosalyn's singles on my slot, 61/0 on r2 (gosalyn-0099; run 1 60/1 was a test order defect). Gate on my slot, 10:05:31-10:07:52Z on62ddc96ccplus the nine paths: library 45/0, and every scripts/test-*.sh green (auth 15, conductor 17, config 31, discord 78, extension-package 18, foundation 44, land-work 86, queue 156 and 28, release 14, task 98, all 0 failed). Webby's N4 (README:88 "A link to ... an attachment is R4" next to the embed paragraph) is doc-only and stays as restored (sage-1917).
90 lines
4.7 KiB
JavaScript
90 lines
4.7 KiB
JavaScript
// 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 = /(?<!!)\[[^\[\]\n]*\]\(([^()\s]+)\)/g;
|
|
const FENCE = /^\s{0,3}(`{3,}|~{3,})/;
|
|
|
|
// One wikilink's parts, or null for text that isn't one.
|
|
export function parseWikilink(text) {
|
|
const match = /^(!?)\[\[([^\[\]\n]+?)\]\]$/.exec(text);
|
|
return match ? parts(match[2], match[1] === '!') : null;
|
|
}
|
|
function parts(inner, embed) {
|
|
const bar = inner.indexOf('|');
|
|
const target = bar < 0 ? inner : inner.slice(0, bar);
|
|
const hash = target.indexOf('#');
|
|
return {
|
|
file: (hash < 0 ? target : target.slice(0, hash)).trim(),
|
|
heading: hash < 0 ? null : target.slice(hash + 1).trim(),
|
|
label: bar < 0 ? null : inner.slice(bar + 1),
|
|
embed,
|
|
};
|
|
}
|
|
// Whether a body embed's target ends in an extension other than `.md`. An extension is a dot and up
|
|
// to 8 letters and digits, at least one a letter, at the end of the target's last segment, so
|
|
// `Chapter 1.5` and `Mr. Smith` have none and `cover.png` has one. `Dr.Quinn` has one too, so the
|
|
// reader asks this only of an embed that resolves to nothing. A plain link or a front matter value is
|
|
// never an attachment (sage-1900).
|
|
export const isAttachment = 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' };
|
|
};
|
|
}
|