@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.
Files changed (49) hide show
  1. package/README.md +60 -8
  2. package/dist/cli.js +52 -3
  3. package/dist/core/acl.d.ts +9 -0
  4. package/dist/core/acl.js +40 -6
  5. package/dist/core/adopt.d.ts +68 -0
  6. package/dist/core/adopt.js +665 -0
  7. package/dist/core/config.d.ts +6 -1
  8. package/dist/core/config.js +46 -2
  9. package/dist/core/edits.d.ts +13 -0
  10. package/dist/core/edits.js +20 -0
  11. package/dist/core/frontmatter.d.ts +12 -0
  12. package/dist/core/frontmatter.js +16 -7
  13. package/dist/core/gfm.d.ts +13 -0
  14. package/dist/core/gfm.js +244 -0
  15. package/dist/core/links.d.ts +82 -0
  16. package/dist/core/links.js +345 -0
  17. package/dist/core/lint.d.ts +1 -2
  18. package/dist/core/lint.js +40 -6
  19. package/dist/core/mcp/server.js +11 -2
  20. package/dist/core/printable.d.ts +4 -0
  21. package/dist/core/printable.js +16 -0
  22. package/dist/core/references.d.ts +38 -3
  23. package/dist/core/references.js +543 -41
  24. package/dist/core/repo.d.ts +4 -0
  25. package/dist/core/repo.js +3 -1
  26. package/dist/core/schema.d.ts +4 -0
  27. package/dist/core/schema.js +8 -6
  28. package/dist/core/search.d.ts +8 -0
  29. package/dist/core/search.js +34 -0
  30. package/dist/core/serve/server.d.ts +1 -2
  31. package/dist/core/serve/sessions.d.ts +5 -4
  32. package/dist/core/serve/sessions.js +102 -29
  33. package/dist/core/service.d.ts +118 -20
  34. package/dist/core/service.js +249 -91
  35. package/dist/core/stale.js +2 -1
  36. package/dist/core/tool-registry.js +6 -3
  37. package/dist/core/verify-pages.d.ts +19 -0
  38. package/dist/core/verify-pages.js +110 -0
  39. package/dist/core/version.d.ts +2 -2
  40. package/dist/core/version.js +2 -2
  41. package/dist/web/assets/index-8J-FUbb0.js +29 -0
  42. package/dist/web/assets/index-BGpAZzeM.css +1 -0
  43. package/dist/web/index.html +2 -2
  44. package/docs/format-spec.md +40 -2
  45. package/package.json +2 -2
  46. package/plugin/.claude-plugin/plugin.json +1 -1
  47. package/plugin/bin/docsworth-mcp.mjs +1 -1
  48. package/dist/web/assets/index-BglLMIyN.css +0 -1
  49. package/dist/web/assets/index-DugFPoRu.js +0 -25
@@ -1,4 +1,8 @@
1
- import { posix } from "node:path";
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
- return { ...buildNames(repo, hidden, fold), rendered: buildNames(repo, hidden, foldRendered) };
256
+ const exists = existsIn(repo.root);
257
+ return { ...buildNames(repo, hidden, fold, exists), rendered: buildNames(repo, hidden, foldRendered, exists) };
253
258
  }
254
- function buildNames(repo, hidden, fold) {
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 = [...repo.pages.map((p) => [p.path, hidden(p)]), ...repo.invalid.map((p) => [p.path, true])];
264
- for (const [path, isHidden] of all) {
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
- if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(path))
514
- return namesHidden([path], fromKey, names) || namesNoPage(decodeText(d, !html), names, !html);
515
- const resolved = path.startsWith("/") ? posix.normalize(path.slice(1)) : posix.normalize(posix.join(posix.dirname(fromKey), path));
627
+ // Where a browser goes, too: in markdown, CommonMark's reading (`x&#46md` 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
- return (resolved.startsWith(`${names.root}/`) && resolved.endsWith(".md")) || namesHidden([path], fromKey, names);
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
- if (at.protocol !== "https:" && at.protocol !== "http:")
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
- /** `s` with character references, and with `escapes` CommonMark's backslash escapes, decoded; non-ASCII kept, for a URL parser to read. */
904
- function decodeText(s, escapes) {
905
- return s.replace(/\\([!-/:-@[-`{-~])|&(?:#(\d+);?|#[xX]([0-9a-fA-F]+);?|([A-Za-z][A-Za-z0-9]{0,31});)/g, (m, esc, dec, hex, name) => {
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 `&#46` 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
- for (const token of read(l, i).split(/[\s"'<>`|[\]()]+/)) {
941
- if (!urlish(token))
942
- continue;
943
- if (namesNoPage(token.replace(/[).,;:!*_~]+$/, ""), folded)) {
944
- marked.add(i);
945
- break;
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
- const marked = destinationLines(text, from, names, bases);
1171
- for (const l of repoUrlLines(text, names))
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