@docsworth/cli 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -8
- package/dist/cli.js +52 -3
- package/dist/core/acl.d.ts +9 -0
- package/dist/core/acl.js +40 -6
- package/dist/core/adopt.d.ts +68 -0
- package/dist/core/adopt.js +665 -0
- package/dist/core/config.d.ts +6 -1
- package/dist/core/config.js +46 -2
- package/dist/core/edits.d.ts +13 -0
- package/dist/core/edits.js +20 -0
- package/dist/core/frontmatter.d.ts +12 -0
- package/dist/core/frontmatter.js +16 -7
- package/dist/core/gfm.d.ts +13 -0
- package/dist/core/gfm.js +244 -0
- package/dist/core/links.d.ts +82 -0
- package/dist/core/links.js +345 -0
- package/dist/core/lint.d.ts +1 -2
- package/dist/core/lint.js +40 -6
- package/dist/core/mcp/server.js +11 -2
- package/dist/core/printable.d.ts +4 -0
- package/dist/core/printable.js +16 -0
- package/dist/core/references.d.ts +38 -3
- package/dist/core/references.js +543 -41
- package/dist/core/repo.d.ts +4 -0
- package/dist/core/repo.js +3 -1
- package/dist/core/schema.d.ts +4 -0
- package/dist/core/schema.js +8 -6
- package/dist/core/search.d.ts +8 -0
- package/dist/core/search.js +34 -0
- package/dist/core/serve/server.d.ts +1 -2
- package/dist/core/serve/sessions.d.ts +5 -4
- package/dist/core/serve/sessions.js +102 -29
- package/dist/core/service.d.ts +118 -20
- package/dist/core/service.js +249 -91
- package/dist/core/stale.js +2 -1
- package/dist/core/tool-registry.js +6 -3
- package/dist/core/verify-pages.d.ts +19 -0
- package/dist/core/verify-pages.js +110 -0
- package/dist/core/version.d.ts +2 -2
- package/dist/core/version.js +2 -2
- package/dist/web/assets/index-8J-FUbb0.js +29 -0
- package/dist/web/assets/index-BGpAZzeM.css +1 -0
- package/dist/web/index.html +2 -2
- package/docs/format-spec.md +40 -2
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/bin/docsworth-mcp.mjs +1 -1
- package/dist/web/assets/index-BglLMIyN.css +0 -1
- package/dist/web/assets/index-DugFPoRu.js +0 -25
package/dist/core/references.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join, posix } from "node:path";
|
|
4
|
+
import { Parser } from "commonmark";
|
|
5
|
+
import { pageLinks, pageRoute } from "./links.js";
|
|
2
6
|
/**
|
|
3
7
|
* Where a text still names a page, read the way its readers read it. Link redaction
|
|
4
8
|
* (`redactLinks` in acl.ts) runs a precise pass first; this is the sweep after it, and it
|
|
@@ -249,20 +253,49 @@ function cancelsJunk(token) {
|
|
|
249
253
|
const REACH = 512;
|
|
250
254
|
const WORDS_BACK = 2;
|
|
251
255
|
export function pageNames(repo, hidden) {
|
|
252
|
-
|
|
256
|
+
const exists = existsIn(repo.root);
|
|
257
|
+
return { ...buildNames(repo, hidden, fold, exists), rendered: buildNames(repo, hidden, foldRendered, exists) };
|
|
253
258
|
}
|
|
254
|
-
|
|
259
|
+
/** `exists` for a repo at `root`, memoized: false for a path that leaves it. */
|
|
260
|
+
function existsIn(root) {
|
|
261
|
+
const seen = new Map();
|
|
262
|
+
return (repoPath) => {
|
|
263
|
+
const p = posix.normalize(repoPath);
|
|
264
|
+
if (p === ".." || p.startsWith("../") || p.startsWith("/"))
|
|
265
|
+
return false;
|
|
266
|
+
return memoized(seen, p, () => existsSync(join(root, p)));
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/** The route a page's frontmatter gives it, from its path under the root. */
|
|
270
|
+
const routeOf = (under, page) => pageRoute(under, page.slug, page.id);
|
|
271
|
+
function buildNames(repo, hidden, fold, exists) {
|
|
255
272
|
const root = fold(repo.config.knowledge_root.replace(/\/+$/, ""));
|
|
256
273
|
const paths = new Map();
|
|
257
274
|
const under = new Map();
|
|
258
275
|
const names = new Map();
|
|
259
276
|
const spelled = new Map();
|
|
260
277
|
const spelledUnder = new Map();
|
|
278
|
+
const routes = new Map();
|
|
279
|
+
const stems = new Set();
|
|
280
|
+
const urlDirs = new Map();
|
|
261
281
|
let depth = 0;
|
|
262
282
|
// Pages that didn't load count as hidden: their ACL can't be read.
|
|
263
|
-
const all = [
|
|
264
|
-
|
|
283
|
+
const all = [
|
|
284
|
+
...repo.pages.map((p) => [p.path, hidden(p), p]),
|
|
285
|
+
...repo.invalid.map((p) => [p.path, true, p]),
|
|
286
|
+
];
|
|
287
|
+
const rawRoot = repo.config.knowledge_root.replace(/\/+$/, "");
|
|
288
|
+
for (const [path, isHidden, page] of all) {
|
|
265
289
|
const key = fold(path);
|
|
290
|
+
if (path.startsWith(`${rawRoot}/`)) {
|
|
291
|
+
// As a docs site serves it: its one route, and a relative link read from its URL,
|
|
292
|
+
// with and without a trailing slash (a site may serve either).
|
|
293
|
+
const route = fold(routeOf(path.slice(rawRoot.length + 1), page));
|
|
294
|
+
routes.set(route, (routes.get(route) ?? false) || isHidden);
|
|
295
|
+
if (isHidden)
|
|
296
|
+
stems.add(fold(path.slice(rawRoot.length + 1).replace(/\.md$/, "")));
|
|
297
|
+
urlDirs.set(key, [posix.dirname(route).replace(/^\.$/, ""), route]);
|
|
298
|
+
}
|
|
266
299
|
paths.set(key, (paths.get(key) ?? false) || isHidden);
|
|
267
300
|
const rel = key.startsWith(`${root}/`) ? key.slice(root.length + 1) : key;
|
|
268
301
|
if (rel.includes("/"))
|
|
@@ -282,8 +315,86 @@ function buildNames(repo, hidden, fold) {
|
|
|
282
315
|
if (rel !== key && rel.includes("/"))
|
|
283
316
|
spelledUnder.get(name).push(rel);
|
|
284
317
|
}
|
|
285
|
-
return { root, paths, under, names, spelled, spelledUnder, depth, repoUrls: repoUrls(repo) };
|
|
318
|
+
return { root, paths, under, names, spelled, spelledUnder, depth, repoUrls: repoUrls(repo), routes, stems, urlDirs, site: siteOf(repo), exists };
|
|
319
|
+
}
|
|
320
|
+
/** `site_url`'s host (with any port) and its path prefix without a trailing slash. */
|
|
321
|
+
function siteOf(repo) {
|
|
322
|
+
const at = repo.config.site_url ? webParse(repo.config.site_url) : undefined;
|
|
323
|
+
// Where a host can start: not inside another (`www.docs.example.com`).
|
|
324
|
+
// Every regex metacharacter escaped (code review, round 7: an IPv6 host's brackets).
|
|
325
|
+
const name = at?.hostname.replace(/\.$/, "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
326
|
+
return at && { host: hostOf(at), prefix: fold(decodedPath(at)).replace(/\/+$/, ""), name: at.hostname.replace(/\.$/, ""), at: new RegExp(`(?<![a-z0-9.-])${name}`, "gi") };
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* The host a URL names, with its port unless it's the scheme's default (the URL parser drops
|
|
330
|
+
* that), a trailing dot cut. Not the scheme: `http://` to the site's host shows the same
|
|
331
|
+
* route, so it's the site too. But `www.` and another port are other services (review,
|
|
332
|
+
* round 4), so they stay.
|
|
333
|
+
*/
|
|
334
|
+
const hostOf = (at) => `${at.hostname.replace(/\.$/, "")}${at.port ? `:${at.port}` : ""}`;
|
|
335
|
+
/**
|
|
336
|
+
* The route a URL names on the docs site (`site_url`), as `/route` (folded); undefined for a
|
|
337
|
+
* URL elsewhere. The prefix matches at a segment boundary: `/docs` isn't `/docs-archive`.
|
|
338
|
+
*/
|
|
339
|
+
function siteRouteOf(url, names) {
|
|
340
|
+
if (!names.site)
|
|
341
|
+
return undefined;
|
|
342
|
+
const at = webParse(url);
|
|
343
|
+
if (!at || hostOf(at) !== names.site.host)
|
|
344
|
+
return undefined;
|
|
345
|
+
const path = fold(decodedPath(at));
|
|
346
|
+
const p = names.site.prefix;
|
|
347
|
+
if (path !== p && !path.startsWith(`${p}/`))
|
|
348
|
+
return undefined;
|
|
349
|
+
return path.slice(p.length) || "/";
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Whether URLs that may be one link (a URL read several ways: whole, its trailing
|
|
353
|
+
* punctuation cut, cut at a parenthesis) name a hidden page on the docs site, or a route
|
|
354
|
+
* there that names nothing: readings combine as `routeVerdict`'s do.
|
|
355
|
+
*/
|
|
356
|
+
function siteUrlHides(urls, names, failClosed = true) {
|
|
357
|
+
const routes = urls.map((u) => siteRouteOf(u, names)).filter((r) => r !== undefined);
|
|
358
|
+
if (!routes.length)
|
|
359
|
+
return false;
|
|
360
|
+
// The prefix says where the site is, so no `docs/` reading: a route as it stands.
|
|
361
|
+
const verdict = routeVerdict(routes.map(clean), names);
|
|
362
|
+
return verdict ?? (failClosed && routes.some(routeShaped));
|
|
363
|
+
}
|
|
364
|
+
/** A path clamped at the root (`..` past it stays there, as a browser reads it), without its outer slashes. */
|
|
365
|
+
const clean = (p) => posix.normalize(`/${p}`).replace(/^\/+|\/+$/g, "");
|
|
366
|
+
/**
|
|
367
|
+
* Where a docs site serves a link written on page `from` (folded): a root-relative one as its
|
|
368
|
+
* route, with and without a leading `docs/`; a relative one from the page's URL (`urlDirs`).
|
|
369
|
+
*/
|
|
370
|
+
function routeReadings(path, from, names) {
|
|
371
|
+
// Each resolved, then any `%2F` left decoded (`slashes`), then read.
|
|
372
|
+
if (path.startsWith("/")) {
|
|
373
|
+
const r = clean(slashes(clean(path)));
|
|
374
|
+
return r === "docs" || r.startsWith("docs/") ? [r, clean(r.slice("docs".length))] : [r];
|
|
375
|
+
}
|
|
376
|
+
return (names.urlDirs.get(from) ?? []).map((d) => clean(slashes(clean(posix.join(d, path)))));
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* What route readings name. A hidden page's route by any reading: true. Else a visible
|
|
380
|
+
* page's by any: false, kept. Else a hidden page's file stem, by those readings or `more`
|
|
381
|
+
* (the path in the repo, under the root): true. Else undefined: nothing.
|
|
382
|
+
*/
|
|
383
|
+
function routeVerdict(readings, names, more = []) {
|
|
384
|
+
let visible = false;
|
|
385
|
+
for (const r of readings) {
|
|
386
|
+
const hidden = names.routes.get(r);
|
|
387
|
+
if (hidden)
|
|
388
|
+
return true;
|
|
389
|
+
if (hidden === false)
|
|
390
|
+
visible = true;
|
|
391
|
+
}
|
|
392
|
+
if (visible)
|
|
393
|
+
return false;
|
|
394
|
+
return readings.some((r) => names.stems.has(r)) || more.some((r) => names.stems.has(r)) ? true : undefined;
|
|
286
395
|
}
|
|
396
|
+
/** A link a site serves as a page, not a file: no extension (a lone trailing `.` is none), or a trailing `/`. */
|
|
397
|
+
const routeShaped = (path) => path.endsWith("/") || posix.extname(path).length <= 1;
|
|
287
398
|
/** The knowledge repo's web and raw URL prefixes, when `github.repo` says where it is. */
|
|
288
399
|
function repoUrls(repo) {
|
|
289
400
|
const name = repo.config.github.repo?.toLowerCase();
|
|
@@ -500,9 +611,12 @@ function readDestination(dest, html) {
|
|
|
500
611
|
* Whether a link destination, as written on page `from`, leads to a hidden page: one
|
|
501
612
|
* that resolves to it, or a URL that ends with its path. A path under the knowledge root
|
|
502
613
|
* that is no loaded page fails closed: a page that didn't load has no trustworthy ACL.
|
|
503
|
-
* `html` for an attribute in raw HTML, where backslash escapes don't apply.
|
|
614
|
+
* `html` for an attribute in raw HTML, where backslash escapes don't apply. `links`, the
|
|
615
|
+
* text's CommonMark links (`linkPaths`): a route naming nothing fails closed only where it's
|
|
616
|
+
* one of them (or an attribute's), not where only the liberal reading finds it (`[^1]: The
|
|
617
|
+
* note`, `[term]: word`); a hidden page's route hides it wherever it stands.
|
|
504
618
|
*/
|
|
505
|
-
export function destinationHides(dest, from, names, html = false) {
|
|
619
|
+
export function destinationHides(dest, from, names, html = false, links) {
|
|
506
620
|
let d = dest.trim();
|
|
507
621
|
if (!html && d.startsWith("<") && d.endsWith(">"))
|
|
508
622
|
d = d.slice(1, -1);
|
|
@@ -510,14 +624,107 @@ export function destinationHides(dest, from, names, html = false) {
|
|
|
510
624
|
if (!path)
|
|
511
625
|
return false;
|
|
512
626
|
const fromKey = fold(from);
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
627
|
+
// Where a browser goes, too: in markdown, CommonMark's reading (`x.md` is no reference
|
|
628
|
+
// without its `;`, so its `#` starts a fragment and the link is to `x&`); in an attribute,
|
|
629
|
+
// HTML's (`&#` then a line break is no reference either). The liberal reading (`x.md`)
|
|
630
|
+
// still hides wherever it names a hidden page; a route naming nothing fails closed only by
|
|
631
|
+
// the browser's reading of a link CommonMark parses (`links`), or of an attribute. And
|
|
632
|
+
// only for routes: a `.md` the browser's reading finds no page (`..\x.md`, whose `\` a
|
|
633
|
+
// renderer writes as `%5C`) is the liberal reading's to judge, as before.
|
|
634
|
+
// (A markdown destination's `\` reaches the href as `%5C`, which a browser keeps.)
|
|
635
|
+
const cm = commonmarkPath(html ? decodeText(d, false).replace(/\\/g, "/") : decodeText(d, true, true).replace(/\\/g, "%5C"));
|
|
636
|
+
// Another reading where it differs (`/%2Fgithub.com/x` is a path, kept so: the liberal
|
|
637
|
+
// reading's `//github.com/x` is a URL).
|
|
638
|
+
const other = cm.folded && cm.folded !== path ? cm : undefined;
|
|
639
|
+
const confirmed = (p) => html || !links || links.has(p);
|
|
640
|
+
const byCommonMark = () => !!other && (other.url ? urlHides(other.cased, fromKey, names, false, confirmed(other.folded)) : readingHides(other.folded, fromKey, names, confirmed(other.folded) ? repoPathOf(other.cased, from) : undefined, false));
|
|
641
|
+
// `%2F` as written: a hidden page that way hides it; naming nothing doesn't.
|
|
642
|
+
if (isUrlPath(path))
|
|
643
|
+
return urlHides(decodeText(d, !html), fromKey, names, html, confirmed(cm.folded), path) || byCommonMark();
|
|
644
|
+
return readingHides(path, fromKey, names, !other && confirmed(path) ? writtenPath(d, html, from) : undefined) || byCommonMark();
|
|
645
|
+
}
|
|
646
|
+
const isUrlPath = (p) => /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(p);
|
|
647
|
+
/**
|
|
648
|
+
* Whether a URL (decoded) names a hidden page: on the docs site, by its path's tail, or one
|
|
649
|
+
* into the repo naming no page. `link` if it's a link (`destinationHides`'s `links`): then a
|
|
650
|
+
* route on the docs site naming nothing fails closed too.
|
|
651
|
+
*/
|
|
652
|
+
function urlHides(url, fromKey, names, html, link, path = readDestination(url, true)) {
|
|
653
|
+
return siteUrlHides([url], names, link) || namesHidden([path], fromKey, names) || namesNoPage(url, names, !html);
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Whether one reading of a destination (folded, not a URL) hides a page. `failClosed`, the
|
|
657
|
+
* reading as written in the repo when it may fail closed: then a route naming nothing does,
|
|
658
|
+
* unless it's a file or directory in the repo outside the knowledge root (`/src`), a link
|
|
659
|
+
* to that, which hides no page. `liberal` for the liberal reading, which alone reads a `.md`
|
|
660
|
+
* under the root that is no page, and a hidden page's path at the end of another, as hiding.
|
|
661
|
+
*/
|
|
662
|
+
function readingHides(path, fromKey, names, failClosed, liberal = true) {
|
|
663
|
+
// Resolved first, then any `%2F` left decoded (`commonmarkPath`).
|
|
664
|
+
const resolved = slashes(path.startsWith("/") ? posix.normalize(path.slice(1)) : posix.normalize(posix.join(posix.dirname(fromKey), path)));
|
|
516
665
|
const known = names.paths.get(resolved);
|
|
517
666
|
if (known !== undefined)
|
|
518
667
|
return known;
|
|
519
|
-
|
|
668
|
+
const underRoot = resolved === names.root || resolved.startsWith(`${names.root}/`);
|
|
669
|
+
if (liberal && underRoot && resolved.endsWith(".md"))
|
|
670
|
+
return true;
|
|
671
|
+
// As a docs site serves it (the owner's decision, 2026-11-13): a hidden page's route, or
|
|
672
|
+
// its file's stem, hides it; a visible page's route keeps it.
|
|
673
|
+
const route = routeVerdict(routeReadings(path, fromKey, names), names, underRoot ? [resolved.slice(names.root.length + 1)] : []);
|
|
674
|
+
if (route || (liberal && namesHidden([path], fromKey, names)))
|
|
675
|
+
return true;
|
|
676
|
+
if (route === false || failClosed === undefined)
|
|
677
|
+
return false;
|
|
678
|
+
return routeShaped(slashes(path)) && (underRoot || !names.exists(slashes(failClosed)));
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* A destination CommonMark has decoded (its references and escapes) as a path: cut at its
|
|
682
|
+
* query or fragment, then percent-decoded. `folded` as the sweep compares paths, `cased` as
|
|
683
|
+
* written in the repo. `url` if it's a URL, told before decoding, as a browser tells it:
|
|
684
|
+
* `/%2Fgithub.com/x` is a path, not `//github.com/x`.
|
|
685
|
+
*/
|
|
686
|
+
function commonmarkPath(dest) {
|
|
687
|
+
const raw = dest.replace(/[\t\n\r]/g, "").trim();
|
|
688
|
+
const url = isUrlPath(raw);
|
|
689
|
+
// A browser resolves `..` before it decodes: `%2F` is no separator then (`zz%2F..` is one
|
|
690
|
+
// segment, and `..` climbs out of it only), so it stays as written until the path is
|
|
691
|
+
// resolved (`readingHides`); `%2e` is a dot to WHATWG URL, so it's decoded.
|
|
692
|
+
const cased = (url ? raw : raw.split(/[?#]/)[0].split(/%2f/i).map(percentDecoded).join("%2F")).normalize("NFC").trim();
|
|
693
|
+
return { folded: fold(cased).trim(), cased, url };
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* A path a browser resolved with `%2F` kept, as a server reads it: decoded, then its dot
|
|
697
|
+
* segments resolved again (`a/..%2Fb` is `b`). Unchanged where it holds no `%2F`.
|
|
698
|
+
*/
|
|
699
|
+
const slashes = (p) => (/%2f/i.test(p) ? posix.normalize(p.replace(/%2f/gi, "/")) : p);
|
|
700
|
+
const percentDecoded = (s) => s.replace(/(?:%[0-9a-f]{2})+/gi, (seq) => {
|
|
701
|
+
try {
|
|
702
|
+
return decodeURIComponent(seq);
|
|
703
|
+
}
|
|
704
|
+
catch {
|
|
705
|
+
return seq;
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
/** The paths of `text`'s links and images, as commonmark.js parses them (`commonmarkPath`). */
|
|
709
|
+
export function linkPaths(text) {
|
|
710
|
+
const out = new Set();
|
|
711
|
+
if (!text.includes("]") && !text.includes("<"))
|
|
712
|
+
return out; // a link has a `]`, an autolink a `<`
|
|
713
|
+
for (const dest of pageLinks(text)) {
|
|
714
|
+
const { folded } = commonmarkPath(dest);
|
|
715
|
+
if (folded)
|
|
716
|
+
out.add(folded);
|
|
717
|
+
}
|
|
718
|
+
return out;
|
|
719
|
+
}
|
|
720
|
+
/** A destination as a repo path, with its case and characters as written (for `exists`). */
|
|
721
|
+
function writtenPath(d, html, from) {
|
|
722
|
+
let t = percentDecoded(decodeText(d, !html).replace(/[\t\n\r]/g, "").split(/[?#]/)[0]);
|
|
723
|
+
if (html)
|
|
724
|
+
t = t.replace(/\\/g, "/"); // a browser reads `\` as `/` in raw HTML
|
|
725
|
+
return repoPathOf(t.normalize("NFC").trim(), from);
|
|
520
726
|
}
|
|
727
|
+
const repoPathOf = (t, from) => (t.startsWith("/") ? posix.normalize(t.slice(1)) : posix.normalize(posix.join(posix.dirname(from), t)));
|
|
521
728
|
/**
|
|
522
729
|
* The lines of `text` holding a link destination that `destinationHides` refuses, read
|
|
523
730
|
* wherever a destination can stand, whatever surrounds it: after `](` (an inline link or
|
|
@@ -527,12 +734,12 @@ export function destinationHides(dest, from, names, html = false) {
|
|
|
527
734
|
* page fails closed in every form, as it does in the common ones (merge gate, PR 13:
|
|
528
735
|
* `[a [b] c](../security/not-loaded.md)` was kept).
|
|
529
736
|
*/
|
|
530
|
-
function destinationLines(text, from, names, bases = []) {
|
|
737
|
+
function destinationLines(text, from, names, bases = [], links) {
|
|
531
738
|
const marked = new Set();
|
|
532
739
|
for (const d of destinations(text)) {
|
|
533
740
|
// As written, and as a browser resolves it against each `<base href>` (merge gate, PR
|
|
534
741
|
// 13: `<base href="../security/">` then `<a href="x.md">` is the hidden page's link).
|
|
535
|
-
const hides = destinationHides(d.dest, from, names, d.html) || bases.some((b) => {
|
|
742
|
+
const hides = destinationHides(d.dest, from, names, d.html, links) || bases.some((b) => {
|
|
536
743
|
const r = againstBase(b, d.dest, d.html, from);
|
|
537
744
|
return r !== undefined && destinationHides(r, from, names, true);
|
|
538
745
|
});
|
|
@@ -542,6 +749,168 @@ function destinationLines(text, from, names, bases = []) {
|
|
|
542
749
|
}
|
|
543
750
|
return marked;
|
|
544
751
|
}
|
|
752
|
+
/** `marked`, and the lines holding a reference to a definition on a marked line: whatever took its line, it goes. */
|
|
753
|
+
function withLabels(text, marked, gone) {
|
|
754
|
+
if ((!marked.size || !text.includes("]:")) && !gone?.size)
|
|
755
|
+
return marked;
|
|
756
|
+
// Any line it spans: its destination may stand on the line after its label.
|
|
757
|
+
const spans = (d) => Array.from({ length: d.lines[1] - d.lines[0] + 1 }, (_, k) => d.lines[0] + k).some((l) => marked.has(l));
|
|
758
|
+
const going = destinations(text).filter((d) => d.label !== undefined && spans(d));
|
|
759
|
+
// A label goes when the definition CommonMark puts in effect for it goes (code review,
|
|
760
|
+
// round 9): with the going lines replaced, as they will be, its definition changes or is
|
|
761
|
+
// gone. Not for a `]:` inside a paragraph, which defines nothing, nor for a later
|
|
762
|
+
// definition of a label in effect elsewhere. Where commonmark.js doesn't say, every one.
|
|
763
|
+
let labels = going.map((d) => d.label);
|
|
764
|
+
if (labels.length) {
|
|
765
|
+
const before = definitionsIn(text);
|
|
766
|
+
const lines = new Set();
|
|
767
|
+
for (const d of going)
|
|
768
|
+
for (let l = d.lines[0]; l <= d.lines[1]; l++)
|
|
769
|
+
lines.add(l);
|
|
770
|
+
const after = before && definitionsIn(replaceLines(text, lines, "[restricted page]"));
|
|
771
|
+
if (before && after) {
|
|
772
|
+
labels = labels.filter((label) => {
|
|
773
|
+
const was = before.get(normalLabel(label));
|
|
774
|
+
return was !== undefined && after.get(normalLabel(label)) !== was;
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
// Its references go, and those of the labels the first pass took the definitions of
|
|
779
|
+
// (`gone`). Every other definition of the label goes with them (`labelLines`), so none
|
|
780
|
+
// takes effect in a later pass.
|
|
781
|
+
for (const l of labelLines(text, [...labels, ...(gone ?? [])]))
|
|
782
|
+
marked.add(l);
|
|
783
|
+
return marked;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* CommonMark's link definitions in `text`, the first for each label (keyed as `normalLabel`
|
|
787
|
+
* keys a label) → its destination; undefined where commonmark.js doesn't say.
|
|
788
|
+
*/
|
|
789
|
+
export function definitionsIn(text) {
|
|
790
|
+
if (!text.includes("]:"))
|
|
791
|
+
return new Map();
|
|
792
|
+
const parser = new Parser();
|
|
793
|
+
parser.parse(text);
|
|
794
|
+
const refmap = parser.refmap;
|
|
795
|
+
if (!refmap || typeof refmap !== "object")
|
|
796
|
+
return undefined;
|
|
797
|
+
return new Map(Object.entries(refmap).map(([label, d]) => [label, d.destination ?? ""]));
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* The lines of `text` holding a reference to one of `labels` (definitions that go): left, a
|
|
801
|
+
* reference shows its label as text, in whatever container (code review, round 1: `> [HSM]`
|
|
802
|
+
* over `> [HSM]: …`), or before a `:` (`[ref]:` that can't start a definition is one).
|
|
803
|
+
* Matched as CommonMark matches labels (case, runs of space, and space inside the brackets
|
|
804
|
+
* aside), anywhere on a line.
|
|
805
|
+
*/
|
|
806
|
+
export function labelLines(text, labels) {
|
|
807
|
+
const marked = new Set();
|
|
808
|
+
const wanted = [...new Set([...labels].map(normalLabel).filter(Boolean))];
|
|
809
|
+
if (!wanted.length || !text.includes("["))
|
|
810
|
+
return marked;
|
|
811
|
+
const isWanted = new Set(wanted);
|
|
812
|
+
// The candidates: each bracketed span holding a label (a label holds no unescaped bracket,
|
|
813
|
+
// but may hold an escaped one: `[a\\]b]`), found on the text joined as a paragraph reads
|
|
814
|
+
// it, so a label wrapped across lines is one, and placed back in the text as written.
|
|
815
|
+
// Not one an odd run of backslashes escapes: it opens nothing (`\\[`, not `\\\\[`).
|
|
816
|
+
const lines = text.split("\n");
|
|
817
|
+
const lineStart = [];
|
|
818
|
+
for (let o = 0, i = 0; i < lines.length; o += lines[i].length + 1, i++)
|
|
819
|
+
lineStart.push(o);
|
|
820
|
+
const { t, starts } = joins(lines)[0];
|
|
821
|
+
const cut = (i) => lines[i].length - ((i + 1 < starts.length ? starts[i + 1] : t.length) - starts[i] - 1);
|
|
822
|
+
// A full reference (`[text][label]`) is one reference from its text's `[`: its token goes
|
|
823
|
+
// there, so the reference stays whole, and its lines run from its text to its label
|
|
824
|
+
// (merge gate, PR 53, round 2: the text wrapped to the line before kept showing). Its
|
|
825
|
+
// text may hold brackets of its own, balanced (round 3: `[HSM rotation [draft] details]`),
|
|
826
|
+
// so each `]` is matched to its `[` as they nest, escapes aside: one pass. A label after a
|
|
827
|
+
// `]` with no `[` to match (one a code span holds, say) gets no token, and its reference
|
|
828
|
+
// takes its whole block.
|
|
829
|
+
const opener = new Map();
|
|
830
|
+
const open = [];
|
|
831
|
+
for (let i = 0; i < t.length; i++) {
|
|
832
|
+
if (t[i] === "\\")
|
|
833
|
+
i++;
|
|
834
|
+
else if (t[i] === "[")
|
|
835
|
+
open.push(i);
|
|
836
|
+
else if (t[i] === "]" && open.length)
|
|
837
|
+
opener.set(i, open.pop());
|
|
838
|
+
}
|
|
839
|
+
const closes = (i) => {
|
|
840
|
+
let slashes = 0;
|
|
841
|
+
while (t[i - slashes - 1] === "\\")
|
|
842
|
+
slashes++;
|
|
843
|
+
return t[i] === "]" && slashes % 2 === 0;
|
|
844
|
+
};
|
|
845
|
+
const candidates = new Map();
|
|
846
|
+
for (const m of t.matchAll(/\[((?:[^[\]\\]|\\[^]){0,999})\]/g)) {
|
|
847
|
+
if (!isWanted.has(normalLabel(m[1])))
|
|
848
|
+
continue;
|
|
849
|
+
const from = closes(m.index - 1) ? opener.get(m.index - 1) : m.index;
|
|
850
|
+
if (from === undefined)
|
|
851
|
+
continue;
|
|
852
|
+
const a = lineAt(starts, from);
|
|
853
|
+
const at = lineStart[a] + cut(a) + (from - starts[a]);
|
|
854
|
+
let slashes = 0;
|
|
855
|
+
while (text[at - slashes - 1] === "\\")
|
|
856
|
+
slashes++;
|
|
857
|
+
if (text[at] !== "[" || slashes % 2 !== 0)
|
|
858
|
+
continue;
|
|
859
|
+
// A later label at the same `[` (`[ref][ref]`) ends later: its lines run further.
|
|
860
|
+
candidates.set(at, [a, lineAt(starts, m.index + m[0].length - 1)]);
|
|
861
|
+
}
|
|
862
|
+
// Read once, whole, as CommonMark reads it (merge gate, PR 53: on its own a line loses a
|
|
863
|
+
// code span opened on the line before): probe definitions for the labels, put first so
|
|
864
|
+
// they win over any left in the text, and a token before each candidate, which changes
|
|
865
|
+
// nothing CommonMark reads of a reference (letters and digits, before a `[`). Each link to
|
|
866
|
+
// a probe (round 5: not an inline link whose text is the label) is the candidate whose
|
|
867
|
+
// token stands right before it: its lines go. One with none (written some other way)
|
|
868
|
+
// takes its whole block. A reference image is one (merge gate, PR 53): with a token after
|
|
869
|
+
// its `!`, it reads as a link. So does a definition of the label, whose line no longer
|
|
870
|
+
// starts with `[`: it goes too, so no other definition of the label takes effect later.
|
|
871
|
+
// Absent from the text, short, and not one a page can be written to hold: a random 64-bit
|
|
872
|
+
// nonce, as `linkSpans` takes (merge gate, PR 53, round 2: growing one a `q` at a time
|
|
873
|
+
// until absent was quadratic). One scan, again only on a collision.
|
|
874
|
+
let nonce;
|
|
875
|
+
do
|
|
876
|
+
nonce = `z${randomBytes(8).toString("hex")}q`;
|
|
877
|
+
while (text.includes(nonce));
|
|
878
|
+
const order = [...candidates.keys()].sort((x, y) => x - y);
|
|
879
|
+
let tokened = "";
|
|
880
|
+
let from = 0;
|
|
881
|
+
order.forEach((at, k) => {
|
|
882
|
+
tokened += `${text.slice(from, at)}${nonce}${k}x`;
|
|
883
|
+
from = at;
|
|
884
|
+
});
|
|
885
|
+
tokened += text.slice(from);
|
|
886
|
+
const head = wanted.length + 1; // the probes' lines and a blank one
|
|
887
|
+
const token = new RegExp(`${nonce}(\\d+)x$`);
|
|
888
|
+
const walker = new Parser().parse(`${wanted.map((label, i) => `[${label}]: ${PROBE}${i}`).join("\n")}\n\n${tokened}`).walker();
|
|
889
|
+
for (let e = walker.next(); e; e = walker.next()) {
|
|
890
|
+
const n = e.node;
|
|
891
|
+
// An image too: one that got no token stays one (Codex, PR 54: `![HSM \`]\` …][ref]`),
|
|
892
|
+
// and with no token before it, it takes its block.
|
|
893
|
+
if (!e.entering || (n.type !== "link" && n.type !== "image") || !n.destination?.startsWith(PROBE))
|
|
894
|
+
continue;
|
|
895
|
+
const k = n.prev?.type === "text" ? token.exec(n.prev.literal ?? "")?.[1] : undefined;
|
|
896
|
+
const span = k === undefined ? undefined : candidates.get(order[Number(k)]);
|
|
897
|
+
if (span) {
|
|
898
|
+
for (let l = span[0]; l <= span[1]; l++)
|
|
899
|
+
marked.add(l);
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
let b = n;
|
|
903
|
+
while (b && !b.sourcepos)
|
|
904
|
+
b = b.parent;
|
|
905
|
+
if (b?.sourcepos)
|
|
906
|
+
for (let l = Math.max(b.sourcepos[0][0] - 1 - head, 0); l <= b.sourcepos[1][0] - 1 - head; l++)
|
|
907
|
+
marked.add(l);
|
|
908
|
+
}
|
|
909
|
+
return marked;
|
|
910
|
+
}
|
|
911
|
+
const PROBE = "https://label.invalid/";
|
|
912
|
+
/** A label as CommonMark compares labels: runs of space one space, case folded as Unicode folds it (`ß` is `SS`: code review, round 2). */
|
|
913
|
+
export const normalLabel = (s) => s.replace(/[ \t\r\n]+/g, " ").trim().toLowerCase().toUpperCase();
|
|
545
914
|
/** Every destination in `text`, wherever one can stand (see `destinationLines`). */
|
|
546
915
|
function destinations(text) {
|
|
547
916
|
const out = [];
|
|
@@ -550,12 +919,25 @@ function destinations(text) {
|
|
|
550
919
|
for (const { t, starts, raw } of joins(text.split("\n"))) {
|
|
551
920
|
const span = (i, j) => [lineAt(starts, i), lineAt(starts, Math.max(i, j - 1))];
|
|
552
921
|
for (const opener of raw ? [] : ["](", "]:"]) {
|
|
922
|
+
// The last `[` before a definition's `]:`, found going forward (linear): its label.
|
|
923
|
+
let open = -1;
|
|
924
|
+
let scanned = 0;
|
|
553
925
|
for (let i = t.indexOf(opener); i >= 0;) {
|
|
554
926
|
// Read no further than the next opener, so no stretch is read twice.
|
|
555
927
|
const next = t.indexOf(opener, i + 2);
|
|
556
928
|
const [s, e] = destinationAt(t, i + 2, next < 0 ? t.length : next, opener === "](");
|
|
929
|
+
if (opener === "]:") {
|
|
930
|
+
// Not an escaped one: `[a\\[b]: …` is the label `a\\[b`. (CommonMark then says
|
|
931
|
+
// whether it's a definition at all: `withLabels`.)
|
|
932
|
+
for (; scanned < i; scanned++)
|
|
933
|
+
if (t[scanned] === "[" && t[scanned - 1] !== "\\")
|
|
934
|
+
open = scanned;
|
|
935
|
+
}
|
|
936
|
+
// A definition's label (CommonMark's at most 999 characters): what a reference to it
|
|
937
|
+
// holds, which goes with it.
|
|
938
|
+
const label = opener === "]:" && open >= 0 && i - open <= 1000 ? { label: t.slice(open + 1, i) } : {};
|
|
557
939
|
if (e > s)
|
|
558
|
-
out.push({ dest: t.slice(s, e), html: false, lines: span(i, e) });
|
|
940
|
+
out.push({ dest: t.slice(s, e), html: false, lines: span(i, e), ...label });
|
|
559
941
|
i = next;
|
|
560
942
|
}
|
|
561
943
|
}
|
|
@@ -881,30 +1263,41 @@ const READINGS = 8;
|
|
|
881
1263
|
* Without a scheme it's read as one written in text (`github.com/acme/…`).
|
|
882
1264
|
*/
|
|
883
1265
|
function webUrl(url) {
|
|
1266
|
+
const at = webParse(url);
|
|
1267
|
+
return at && { host: at.hostname.replace(/\.$/, "").replace(/^www\./, ""), path: decodedPath(at).toLowerCase() };
|
|
1268
|
+
}
|
|
1269
|
+
/** A web URL parsed as a browser parses it (`webUrl`), or undefined. */
|
|
1270
|
+
function webParse(url) {
|
|
884
1271
|
// A URL parser drops tabs and line breaks first, wherever they are (`h` + break + `ttps:`).
|
|
885
1272
|
const u = url.replace(/[\t\n\r]/g, "").trim();
|
|
886
1273
|
try {
|
|
887
1274
|
const at = new URL(/^[a-z][a-z0-9+.-]*:/i.test(u) ? u : `https://${u.replace(/^[\\/]*/, "")}`);
|
|
888
|
-
|
|
889
|
-
return undefined;
|
|
890
|
-
let path = at.pathname;
|
|
891
|
-
try {
|
|
892
|
-
path = decodeURIComponent(path);
|
|
893
|
-
}
|
|
894
|
-
catch {
|
|
895
|
-
/* a malformed escape: as written */
|
|
896
|
-
}
|
|
897
|
-
return { host: at.hostname.replace(/\.$/, "").replace(/^www\./, ""), path: path.toLowerCase() };
|
|
1275
|
+
return at.protocol === "https:" || at.protocol === "http:" ? at : undefined;
|
|
898
1276
|
}
|
|
899
1277
|
catch {
|
|
900
1278
|
return undefined;
|
|
901
1279
|
}
|
|
902
1280
|
}
|
|
903
|
-
/**
|
|
904
|
-
function
|
|
905
|
-
|
|
1281
|
+
/** A parsed URL's path, percent-decoded (as written where an escape is malformed). */
|
|
1282
|
+
function decodedPath(at) {
|
|
1283
|
+
try {
|
|
1284
|
+
return decodeURIComponent(at.pathname);
|
|
1285
|
+
}
|
|
1286
|
+
catch {
|
|
1287
|
+
return at.pathname;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* `s` with character references, and with `escapes` CommonMark's backslash escapes, decoded;
|
|
1292
|
+
* non-ASCII kept, for a URL parser to read. With `strict`, as CommonMark decodes them: a
|
|
1293
|
+
* numeric reference needs its `;` (HTML reads `.` in an attribute as `.`).
|
|
1294
|
+
*/
|
|
1295
|
+
function decodeText(s, escapes, strict = false) {
|
|
1296
|
+
return s.replace(/\\([!-/:-@[-`{-~])|&(?:#(\d+)(;?)|#[xX]([0-9a-fA-F]+)(;?)|([A-Za-z][A-Za-z0-9]{0,31});)/g, (m, esc, dec, decEnd, hex, hexEnd, name) => {
|
|
906
1297
|
if (esc !== undefined)
|
|
907
1298
|
return escapes ? esc : m;
|
|
1299
|
+
if (strict && (decEnd === "" || hexEnd === ""))
|
|
1300
|
+
return m;
|
|
908
1301
|
const code = dec !== undefined ? Number(dec) : hex !== undefined ? Number.parseInt(hex, 16) : undefined;
|
|
909
1302
|
if (code !== undefined)
|
|
910
1303
|
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : "\ufffd";
|
|
@@ -917,9 +1310,9 @@ function decodeText(s, escapes) {
|
|
|
917
1310
|
* repo that names no page (`namesNoPage`), however it's written: a bare URL, an autolink,
|
|
918
1311
|
* in code. Destinations are `destinationLines`', but a URL needs no link around it.
|
|
919
1312
|
*/
|
|
920
|
-
function repoUrlLines(text, names) {
|
|
1313
|
+
function repoUrlLines(text, names, links) {
|
|
921
1314
|
const marked = new Set();
|
|
922
|
-
if (!names.repoUrls.length)
|
|
1315
|
+
if (!names.repoUrls.length && !names.site)
|
|
923
1316
|
return marked;
|
|
924
1317
|
const lines = text.split("\n");
|
|
925
1318
|
const block = text.includes("\\") ? htmlBlockPrefixes(lines) : [];
|
|
@@ -928,6 +1321,10 @@ function repoUrlLines(text, names) {
|
|
|
928
1321
|
// on screen), each with its paths' folding. Then every token that may be a URL is read
|
|
929
1322
|
// by the URL parser, which maps a host as a browser does (`github.com`, `github。com`).
|
|
930
1323
|
const readings = [[(l, i) => decodeText(l, block[i] === undefined), names]];
|
|
1324
|
+
// And with CommonMark's escapes on a raw HTML block's lines too, as the name sweep reads
|
|
1325
|
+
// both: a reader sees `broken\-route` there as the route `broken-route` (found by the fuzz).
|
|
1326
|
+
if (block.some((p) => p !== undefined))
|
|
1327
|
+
readings.push([(l) => decodeText(l, true), names]);
|
|
931
1328
|
if (names.rendered)
|
|
932
1329
|
for (const show of renderings(text))
|
|
933
1330
|
readings.push([(l) => decodeText(show(l), true), names.rendered]);
|
|
@@ -937,18 +1334,120 @@ function repoUrlLines(text, names) {
|
|
|
937
1334
|
return;
|
|
938
1335
|
// Not normalized here: the parser maps a host itself, and NFKC would change a path
|
|
939
1336
|
// (`𝔄.md` is `A.md` to it).
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1337
|
+
const line = read(l, i);
|
|
1338
|
+
if (folded.repoUrls.length) {
|
|
1339
|
+
for (const token of line.split(/[\s"'<>`|[\]()]+/)) {
|
|
1340
|
+
if (!urlish(token))
|
|
1341
|
+
continue;
|
|
1342
|
+
if (namesNoPage(token.replace(/[).,;:!*_~]+$/, ""), folded)) {
|
|
1343
|
+
marked.add(i);
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
946
1346
|
}
|
|
947
1347
|
}
|
|
1348
|
+
// And percent-decoded, as a hidden page's path is read in text (`…%2Fdocs/moved%2Fsecret`):
|
|
1349
|
+
// there a hidden page's route hides it, naming nothing doesn't (no link reads it so).
|
|
1350
|
+
if (folded.site && (siteUrlsHide(line, folded, links) || (line.includes("%") && siteUrlsHide(percentDecoded(line), folded, NO_LINKS))))
|
|
1351
|
+
marked.add(i);
|
|
948
1352
|
});
|
|
949
1353
|
}
|
|
950
1354
|
return marked;
|
|
951
1355
|
}
|
|
1356
|
+
/**
|
|
1357
|
+
* Whether a line names a hidden page by a URL on the docs site, however it's written: an
|
|
1358
|
+
* autolink, a bare URL, in code. Each URL is read whole to the next space, quote or `<` (a
|
|
1359
|
+
* slug may hold `(old)` or end in `_`: review, round 4), and cut at a parenthesis, bracket
|
|
1360
|
+
* or `|`; each such reading also as GFM ends a bare URL (a sentence's `.` cut, a `)` only
|
|
1361
|
+
* while unpaired), the two read together. A route naming nothing fails closed only where it's a link CommonMark
|
|
1362
|
+
* reads (`links`, an autolink): a bare URL's or a piece's could be cut short by markup.
|
|
1363
|
+
*/
|
|
1364
|
+
function siteUrlsHide(line, names, links) {
|
|
1365
|
+
// As GFM ends a bare URL: trailing punctuation, and a `)` only while unpaired, so
|
|
1366
|
+
// `…/setup(old))` is `…/setup(old)`.
|
|
1367
|
+
// One pass from the end (code review, round 5: cutting one `)` at a time was quadratic).
|
|
1368
|
+
const paired = (t) => {
|
|
1369
|
+
let closes = 0;
|
|
1370
|
+
for (const c of t)
|
|
1371
|
+
closes += c === ")" ? 1 : c === "(" ? -1 : 0;
|
|
1372
|
+
let end = t.length;
|
|
1373
|
+
for (; end > 0; end--) {
|
|
1374
|
+
const c = t[end - 1];
|
|
1375
|
+
if (c === ")" && closes > 0)
|
|
1376
|
+
closes--;
|
|
1377
|
+
else if (!".,;:!?*_~".includes(c))
|
|
1378
|
+
break;
|
|
1379
|
+
}
|
|
1380
|
+
return t.slice(0, end);
|
|
1381
|
+
};
|
|
1382
|
+
for (const wide of line.split(/[\s"'<>`]+/)) {
|
|
1383
|
+
for (const piece of new Set([wide, ...wide.split(/[|[\]()]+/)])) {
|
|
1384
|
+
// And from every place the site's host starts one: after a scheme a browser can't
|
|
1385
|
+
// parse (`https:%2F/docs.example.com/…`), or inside an archive's URL, even after the
|
|
1386
|
+
// host stood once already (code review, round 4), it's still the site's URL. Past a
|
|
1387
|
+
// few such places it fails closed, as a repo URL's readings do: linear.
|
|
1388
|
+
// The places a host starts: as written, or wherever one can (the token's start, after a
|
|
1389
|
+
// separator a URL holds before a host) and a URL parser reads the site's there, mapped
|
|
1390
|
+
// as it maps one: percent-encoded, fullwidth, IDNA's dots, what IDNA ignores (code
|
|
1391
|
+
// review, rounds 7 and 8: `https://docs.example.com/…`, `web/docs%2eexample.com/…`
|
|
1392
|
+
// inside an archive's URL). Past eight places (round 6: places, not the readings each
|
|
1393
|
+
// gives) it fails closed.
|
|
1394
|
+
const places = new Set([...piece.matchAll(names.site.at)].map((m) => m.index));
|
|
1395
|
+
// Parsed only where the pattern can't see a host: one percent-encoded or not in ASCII
|
|
1396
|
+
// (an ASCII one it finds at the same places, in any case). Where each position's
|
|
1397
|
+
// authority ends, found in one pass from the end: linear.
|
|
1398
|
+
if (/[%\u0080-\uffff]/.test(piece)) {
|
|
1399
|
+
const stop = new Int32Array(piece.length + 1).fill(piece.length);
|
|
1400
|
+
for (let k = piece.length - 1; k >= 0; k--)
|
|
1401
|
+
stop[k] = "/\\?#".includes(piece[k]) ? k : stop[k + 1];
|
|
1402
|
+
for (const m of piece.matchAll(/(?:^|[\/\\=?&:@(])(?=[^\/\\=?&:@(])/g)) {
|
|
1403
|
+
const at = m.index + m[0].length;
|
|
1404
|
+
if (stop[at] - at <= HOST_LENGTH && /[%\u0080-\uffff]/.test(piece.slice(at, stop[at])) && hostAt(piece, at, stop[at]) === names.site.name)
|
|
1405
|
+
places.add(at);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
if (places.size > HOST_READINGS)
|
|
1409
|
+
return true;
|
|
1410
|
+
// From each: the host, and the scheme before its `//`, which says what port is its
|
|
1411
|
+
// default (`HTTP://…:80`). Past any userinfo to that `//` (merge gate, PR 53, round 3:
|
|
1412
|
+
// `http://user@docs.example.com:80/…`): a host after an `@` starts at its authority's
|
|
1413
|
+
// start, where the last `/`, `\`, `?` or `#` before it ends. At most eight places: linear.
|
|
1414
|
+
const starts = [];
|
|
1415
|
+
for (const at of places) {
|
|
1416
|
+
starts.push(at);
|
|
1417
|
+
let from = at;
|
|
1418
|
+
if (piece[at - 1] === "@")
|
|
1419
|
+
while (from > 0 && !"/\\?#".includes(piece[from - 1]))
|
|
1420
|
+
from--;
|
|
1421
|
+
if (from < 2 || !/[\\/]{2}/.test(piece.slice(from - 2, from)))
|
|
1422
|
+
continue;
|
|
1423
|
+
const scheme = /[a-z][a-z0-9+.-]*:$/i.exec(piece.slice(Math.max(0, from - 34), from - 2));
|
|
1424
|
+
if (scheme && from - 2 > scheme[0].length)
|
|
1425
|
+
starts.push(from - 2 - scheme[0].length);
|
|
1426
|
+
}
|
|
1427
|
+
for (const u of [piece, ...starts.map((at) => piece.slice(at))]) {
|
|
1428
|
+
if (urlish(u) && siteUrlHides([u, paired(u)], names, !links || links.has(commonmarkPath(u).folded)))
|
|
1429
|
+
return true;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
return false;
|
|
1434
|
+
}
|
|
1435
|
+
const HOST_READINGS = 8;
|
|
1436
|
+
const HOST_LENGTH = 320;
|
|
1437
|
+
const NO_LINKS = new Set();
|
|
1438
|
+
/** The host name starting at `at` in `s`, as a URL parser decodes it (to the next `/`, `\`, `?` or `#`; userinfo and port cut). Linear: it stops at the next separator. */
|
|
1439
|
+
function hostAt(s, at, stopAt) {
|
|
1440
|
+
let end = stopAt ?? at;
|
|
1441
|
+
// No host is longer than DNS allows (253, a port and userinfo aside): linear, however
|
|
1442
|
+
// many places are tried.
|
|
1443
|
+
if (stopAt === undefined)
|
|
1444
|
+
while (end < s.length && !"/\\?#".includes(s[end]))
|
|
1445
|
+
if (++end - at > HOST_LENGTH)
|
|
1446
|
+
return "";
|
|
1447
|
+
const authority = s.slice(at, end);
|
|
1448
|
+
const at2 = webParse(`https://${authority.slice(authority.lastIndexOf("@") + 1)}/`);
|
|
1449
|
+
return at2 ? at2.hostname.replace(/\.$/, "") : "";
|
|
1450
|
+
}
|
|
952
1451
|
/**
|
|
953
1452
|
* Whether a token may be a URL with a path, for the URL parser to read: it has a scheme,
|
|
954
1453
|
* or its first part (past any slashes, as in `//github.com/…`) looks like a host and a
|
|
@@ -1163,17 +1662,20 @@ function scan(v, from, names, marked) {
|
|
|
1163
1662
|
}
|
|
1164
1663
|
const renderings = (text) => (SHORTER.test(text) ? [unshown, (l) => unshown(l, true)] : [unshown]);
|
|
1165
1664
|
/** The lines of `text`, on page `from`, that name a hidden page: see the module comment. */
|
|
1166
|
-
export function namingLines(text, from, names, depth = 0, inherited = []) {
|
|
1665
|
+
export function namingLines(text, from, names, depth = 0, inherited = [], liberal = false, gone) {
|
|
1167
1666
|
// Destinations and URLs into the repo first, even with nothing hidden: one that names
|
|
1168
1667
|
// no page fails closed. A srcdoc is a document of its own, with its parent's bases.
|
|
1169
1668
|
const bases = [...inherited, ...basesIn(text)];
|
|
1170
|
-
|
|
1171
|
-
|
|
1669
|
+
// A srcdoc is HTML: there only an attribute is a link.
|
|
1670
|
+
// `liberal`: every route naming nothing fails closed, as if each were a link.
|
|
1671
|
+
const links = depth === 0 ? linkPaths(text) : new Set();
|
|
1672
|
+
const marked = destinationLines(text, from, names, bases, liberal ? undefined : links);
|
|
1673
|
+
for (const l of repoUrlLines(text, names, liberal ? undefined : depth === 0 ? links : new Set()))
|
|
1172
1674
|
marked.add(l);
|
|
1173
1675
|
for (const l of srcdocLines(text, depth, (doc) => namingLines(doc, from, names, depth + 1, bases).size > 0))
|
|
1174
1676
|
marked.add(l);
|
|
1175
1677
|
if (!names.names.size)
|
|
1176
|
-
return marked;
|
|
1678
|
+
return withLabels(text, marked, gone);
|
|
1177
1679
|
const lines = text.split("\n");
|
|
1178
1680
|
const fromKey = fold(from);
|
|
1179
1681
|
// Read with CommonMark's escapes and without (as raw HTML reads `\`); the same text
|
|
@@ -1191,7 +1693,7 @@ export function namingLines(text, from, names, depth = 0, inherited = []) {
|
|
|
1191
1693
|
for (const show of renderings(text))
|
|
1192
1694
|
scan(encoded ? readerView(lines, false, true, show) : plainView(lines, false, show), foldRendered(from), names.rendered, marked);
|
|
1193
1695
|
}
|
|
1194
|
-
return marked;
|
|
1696
|
+
return withLabels(text, marked, gone);
|
|
1195
1697
|
}
|
|
1196
1698
|
/**
|
|
1197
1699
|
* The lines of `text` where `find`, run over each view the sweep reads (as prose and
|