@docsworth/cli 0.0.0-stage → 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 (103) hide show
  1. package/LICENSE +55 -0
  2. package/README.md +238 -2
  3. package/dist/cli.d.ts +11 -0
  4. package/dist/cli.js +458 -0
  5. package/dist/core/acl.d.ts +37 -0
  6. package/dist/core/acl.js +136 -0
  7. package/dist/core/adopt.d.ts +68 -0
  8. package/dist/core/adopt.js +665 -0
  9. package/dist/core/audit-log.d.ts +133 -0
  10. package/dist/core/audit-log.js +290 -0
  11. package/dist/core/blocks.d.ts +35 -0
  12. package/dist/core/blocks.js +417 -0
  13. package/dist/core/codeowners.d.ts +62 -0
  14. package/dist/core/codeowners.js +133 -0
  15. package/dist/core/config.d.ts +101 -0
  16. package/dist/core/config.js +175 -0
  17. package/dist/core/coverage.d.ts +25 -0
  18. package/dist/core/coverage.js +41 -0
  19. package/dist/core/duration.d.ts +20 -0
  20. package/dist/core/duration.js +60 -0
  21. package/dist/core/edits.d.ts +13 -0
  22. package/dist/core/edits.js +20 -0
  23. package/dist/core/errors.d.ts +11 -0
  24. package/dist/core/errors.js +13 -0
  25. package/dist/core/frontmatter.d.ts +29 -0
  26. package/dist/core/frontmatter.js +49 -0
  27. package/dist/core/gate.d.ts +17 -0
  28. package/dist/core/gate.js +25 -0
  29. package/dist/core/gfm.d.ts +13 -0
  30. package/dist/core/gfm.js +244 -0
  31. package/dist/core/github.d.ts +152 -0
  32. package/dist/core/github.js +234 -0
  33. package/dist/core/host.d.ts +125 -0
  34. package/dist/core/host.js +22 -0
  35. package/dist/core/identity.d.ts +39 -0
  36. package/dist/core/identity.js +127 -0
  37. package/dist/core/init.d.ts +25 -0
  38. package/dist/core/init.js +86 -0
  39. package/dist/core/links.d.ts +82 -0
  40. package/dist/core/links.js +345 -0
  41. package/dist/core/lint.d.ts +16 -0
  42. package/dist/core/lint.js +149 -0
  43. package/dist/core/mcp/audit-transport.d.ts +16 -0
  44. package/dist/core/mcp/audit-transport.js +238 -0
  45. package/dist/core/mcp/server.d.ts +19 -0
  46. package/dist/core/mcp/server.js +216 -0
  47. package/dist/core/mcp/text.d.ts +14 -0
  48. package/dist/core/mcp/text.js +43 -0
  49. package/dist/core/mirror-view.d.ts +68 -0
  50. package/dist/core/mirror-view.js +367 -0
  51. package/dist/core/new-page.d.ts +52 -0
  52. package/dist/core/new-page.js +49 -0
  53. package/dist/core/policy.d.ts +10 -0
  54. package/dist/core/policy.js +30 -0
  55. package/dist/core/polish.d.ts +42 -0
  56. package/dist/core/polish.js +1000 -0
  57. package/dist/core/printable.d.ts +4 -0
  58. package/dist/core/printable.js +16 -0
  59. package/dist/core/references.d.ts +87 -0
  60. package/dist/core/references.js +1744 -0
  61. package/dist/core/repo.d.ts +46 -0
  62. package/dist/core/repo.js +118 -0
  63. package/dist/core/schema.d.ts +50 -0
  64. package/dist/core/schema.js +51 -0
  65. package/dist/core/search.d.ts +32 -0
  66. package/dist/core/search.js +132 -0
  67. package/dist/core/serve/auth.d.ts +23 -0
  68. package/dist/core/serve/auth.js +38 -0
  69. package/dist/core/serve/server.d.ts +21 -0
  70. package/dist/core/serve/server.js +138 -0
  71. package/dist/core/serve/sessions.d.ts +40 -0
  72. package/dist/core/serve/sessions.js +203 -0
  73. package/dist/core/serve/token.d.ts +10 -0
  74. package/dist/core/serve/token.js +96 -0
  75. package/dist/core/service.d.ts +473 -0
  76. package/dist/core/service.js +973 -0
  77. package/dist/core/sla.d.ts +16 -0
  78. package/dist/core/sla.js +20 -0
  79. package/dist/core/stale.d.ts +16 -0
  80. package/dist/core/stale.js +24 -0
  81. package/dist/core/tool-registry.d.ts +31 -0
  82. package/dist/core/tool-registry.js +80 -0
  83. package/dist/core/url.d.ts +4 -0
  84. package/dist/core/url.js +31 -0
  85. package/dist/core/verify-pages.d.ts +19 -0
  86. package/dist/core/verify-pages.js +110 -0
  87. package/dist/core/version.d.ts +22 -0
  88. package/dist/core/version.js +32 -0
  89. package/dist/core/writes.d.ts +58 -0
  90. package/dist/core/writes.js +113 -0
  91. package/dist/web/assets/index-8J-FUbb0.js +29 -0
  92. package/dist/web/assets/index-BGpAZzeM.css +1 -0
  93. package/dist/web/index.html +14 -0
  94. package/docs/format-spec.md +349 -0
  95. package/package.json +75 -4
  96. package/plugin/.claude-plugin/plugin.json +8 -0
  97. package/plugin/.mcp.json +8 -0
  98. package/plugin/bin/docsworth-mcp.mjs +46 -0
  99. package/plugin/skills/draft-runbook/SKILL.md +75 -0
  100. package/plugin/skills/polish-page/SKILL.md +64 -0
  101. package/plugin/skills/update-after-incident/SKILL.md +59 -0
  102. package/plugin/skills/verify-page/SKILL.md +46 -0
  103. package/templates/github/knowledge.yml +51 -0
@@ -0,0 +1,1744 @@
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";
6
+ /**
7
+ * Where a text still names a page, read the way its readers read it. Link redaction
8
+ * (`redactLinks` in acl.ts) runs a precise pass first; this is the sweep after it, and it
9
+ * fails closed: any line that names a hidden page, in any form, is redacted whole.
10
+ *
11
+ * "Names" means a link destination, an HTML attribute, a URL or a path in text that
12
+ * resolves to the page, after the decoding a reader applies:
13
+ *
14
+ * - backslash escapes, and character references (`/`, `/`; numeric ones without
15
+ * their `;` too, as HTML reads them in attributes)
16
+ * - percent-encoding, all of it (`%2F` included)
17
+ * - `\` as `/`, as browsers read a relative URL
18
+ * - a line break or tab inside a value, which URL parsing drops, and the quote and list
19
+ * markers before a continuation line
20
+ *
21
+ * A destination always ends with the page's file name, so the sweep looks only where a
22
+ * hidden page's file name ends. From there it reads every place the reference could
23
+ * start, as CommonMark and HTML allow: after any character that can't be part of a path,
24
+ * back to whitespace (a plain destination holds none), and after the nearest `<`, `"`
25
+ * and `'` before that (angle destinations and quoted attributes may hold spaces). It
26
+ * redacts when any of those names a hidden page, either by resolving from the page as a
27
+ * link would, or, for a URL or a path that resolves nowhere, by ending in a hidden page's
28
+ * repo path, or its path under the knowledge root (two segments or more). The longest
29
+ * such tail decides, so a URL to a visible `team/runbooks/x.md` isn't taken for a hidden
30
+ * `runbooks/x.md`.
31
+ *
32
+ * Paths are folded before they're compared (`fold`): case, Unicode composition and
33
+ * non-ASCII runs. A visible page whose path differs from a hidden one only there is
34
+ * redacted as if hidden.
35
+ *
36
+ * Linear: each file name ending is read back at most REACH characters (past that, the line
37
+ * is redacted without resolving), and what a text repeats is resolved once (`Memo`).
38
+ *
39
+ * A link to a path under the knowledge root that is no page (one that didn't load, or no
40
+ * file at all) has no name to look for, so a second pass reads every link destination,
41
+ * in any form, and refuses it as the precise pass does (`destinationLines`).
42
+ */
43
+ /**
44
+ * Stands for a run of non-ASCII characters, with the ASCII letter or digit a combining
45
+ * mark sits on: one SUB however the run is written (composed or not, an astral character,
46
+ * or a character reference this doesn't decode, which may stand for several). A path runs
47
+ * through it (`café.md`), and one may also start right after it (a curly quote or a
48
+ * no-break space before a path).
49
+ */
50
+ const SUB = "\u001a";
51
+ // A combining mark: part of the character before it (`e` and U+0301 are `é`).
52
+ const MARK = /^\p{M}$/u;
53
+ // What a combining mark joins: an ASCII letter or digit.
54
+ const BASE = /^[A-Za-z0-9]$/;
55
+ /** Appends a non-ASCII character to `out`, as `fold` reads it. */
56
+ function foldWide(out, ch) {
57
+ const last = out.length - 1;
58
+ if (last >= 0 && out[last] === SUB)
59
+ return; // the run goes on
60
+ if (MARK.test(ch) && last >= 0 && BASE.test(out[last]))
61
+ out[last] = SUB;
62
+ else
63
+ out.push(SUB);
64
+ }
65
+ /**
66
+ * A text as the sweep compares it: NFC, ASCII letters in lower case (so a path differing
67
+ * only in case, which a case-insensitive file system opens, matches), and each run of
68
+ * other characters as one SUB. So `café` matches whether it's composed, decomposed, or
69
+ * encoded either way. Page paths and texts are both read through this, and a visible
70
+ * page whose path differs from a hidden one only inside such a run is taken for it.
71
+ */
72
+ // Inline markup a renderer removes, where it pairs as a renderer pairs it: emphasis,
73
+ // strikethrough, code and link text. `knowledge/team/**secret.md**` shows
74
+ // `knowledge/team/secret.md`; a lone `_` (`_secret.md`) shows as written, and so does
75
+ // `_` between letters or digits (`on_call`), which is never emphasis. Not escaped ones.
76
+ // Spans are bounded, so a line of unpaired markers stays linear.
77
+ // A code span may hold spaces at its edges (`` on` call.md` `` shows `on call.md`);
78
+ // CommonMark strips one from each end when both have one.
79
+ // Each marker run is matched whole (no marker before or after it), so a long run isn't
80
+ // retried at every shorter length: a line of 64 KB of backticks took 1.7 s that way.
81
+ const PAIRED = /(?<![\\`])(`+)(?!`)([^`\n]{1,256}?)(?<!`)\1(?!`)|(?<![\\*])(\*+)(?![*\s])([^\n]{0,256}?\S)\3(?!\*)|(?<![\\~])(~+)(?![~\s])([^\n]{0,256}?\S)\5(?!~)|(?<![\\\p{L}\p{N}_])(_+)(?![_\s])([^\n]{0,256}?\S)\7(?![\p{L}\p{N}_])|(?<!\\)\[([^[\]\n]{0,256})\]/gu;
82
+ const codeText = (c) => (/^ [\s\S]* $/.test(c) && /\S/.test(c) ? c.slice(1, -1) : c);
83
+ /** The text with paired markup gone, to a nesting of a few levels (`**_x_**`). */
84
+ function unmark(s) {
85
+ for (let i = 0; i < 4; i++) {
86
+ const next = s.replace(PAIRED, (_m, _a, code, _c, star, _e, tilde, _g, under, link) => code !== undefined ? codeText(code) : (star ?? tilde ?? under ?? link ?? ""));
87
+ if (next === s)
88
+ break;
89
+ s = next;
90
+ }
91
+ return s;
92
+ }
93
+ // Syntax that renders as nothing: inline tags, comments (`team/<!--x-->secret.md` shows
94
+ // `team/secret.md`), processing instructions and declarations, on one line; a link's
95
+ // `](destination "title")` or `][label]`, and an image's `!` (`[team/sec](x)ret.md`).
96
+ // Invisible format characters too: a soft hyphen or a zero-width space inside a path.
97
+ const INVISIBLE = /\p{Cf}/gu;
98
+ // What renders as nothing, read left to right as CommonMark reads it, so the leftmost
99
+ // wins: a `<!--` inside a tag's quoted value is part of the tag, and a `<![CDATA[` inside a
100
+ // comment part of the comment. First the openers of comments, processing instructions,
101
+ // CDATA and declarations (`unshown` finds where each ends); then a tag, whose quoted value
102
+ // may hold `<` or `>` (`<span title="a<b">`), and any other `<…>` that starts like one. Each
103
+ // attribute starts after whitespace with a name character, so the attribute list can't
104
+ // backtrack against the trailing space.
105
+ const UNSHOWN = /<!--|<\?|<!\[CDATA\[|<![A-Za-z]|<[A-Za-z][A-Za-z0-9-]*(?:\s+[A-Za-z_:][A-Za-z0-9_.:-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*\s*\/?>|<\/?[A-Za-z][^<>]*>|\]\((?:<[^<>\n]*>|[^()\s]*(?:\([^()\s]*\)[^()\s]*)*)(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^()\n]*\)))?\s*\)|\]\[[^\]\n]*\]|!(?=\[)/g;
106
+ /**
107
+ * `fold`, as rendered text: paired markup gone first (page paths are read the same way),
108
+ * so a run of non-ASCII characters split by markup is one run.
109
+ */
110
+ const foldRendered = (s) => fold(unmark(s.replace(INVISIBLE, "")));
111
+ /**
112
+ * A line as it shows: what renders as nothing gone (a link's tail leaves its `]`, so the
113
+ * text pairs), then paired markup. A comment, processing instruction, CDATA section or
114
+ * declaration goes however long it is (merge gate, PR 13: one over a bound of 999 was
115
+ * kept, and its spaces cut the path in two), and only where CommonMark reads one: one with
116
+ * no end is text. Where it ends: CommonMark's end, or with `shortest`, a browser's, which
117
+ * can be sooner (a comment at `--!>`, a processing instruction or CDATA, bogus comments in
118
+ * HTML, at its first `>`), so the rest shows. Linear: each end is found by `indexOf`, and
119
+ * reused while the scan hasn't passed it.
120
+ */
121
+ function unshown(line, shortest = false) {
122
+ const l = line.replace(INVISIBLE, "");
123
+ const found = new Map();
124
+ const find = (needle, from) => {
125
+ const seen = found.get(needle);
126
+ if (seen && from >= seen[0] && (seen[1] < 0 || from <= seen[1]))
127
+ return seen[1];
128
+ const at = l.indexOf(needle, from);
129
+ found.set(needle, [from, at]);
130
+ return at;
131
+ };
132
+ let out = "";
133
+ let at = 0;
134
+ UNSHOWN.lastIndex = 0;
135
+ for (let m; (m = UNSHOWN.exec(l));) {
136
+ const i = m.index;
137
+ let end = i + m[0].length;
138
+ if (m[0].startsWith("<!") || m[0] === "<?") {
139
+ end = spanEnd(m[0], i, find, shortest);
140
+ if (end < 0) {
141
+ UNSHOWN.lastIndex = i + 1; // CommonMark reads it as text
142
+ continue;
143
+ }
144
+ UNSHOWN.lastIndex = end;
145
+ }
146
+ out += l.slice(at, i) + (m[0].startsWith("]") ? "]" : "");
147
+ at = end;
148
+ }
149
+ return unmark(out + l.slice(at));
150
+ }
151
+ /** Where the span `open` starts at `i` ends (see `unshown`), or -1 if it has no end. */
152
+ function spanEnd(open, i, find, shortest) {
153
+ // `<!-->` and `<!--->` are whole comments, so `-->` is looked for from the opener's `--`.
154
+ const [close, from] = open === "<!--" ? ["-->", i + 2] : open === "<?" ? ["?>", i + 2] : open === "<![CDATA[" ? ["]]>", i + 9] : [">", i + 2];
155
+ const j = find(close, from);
156
+ if (j < 0 || !shortest || close === ">")
157
+ return j < 0 ? -1 : j + close.length;
158
+ if (open !== "<!--")
159
+ return find(">", i + 2) + 1;
160
+ const k = find("--!>", i + 4);
161
+ return k >= 0 && k < j ? k + 4 : j + 3;
162
+ }
163
+ /** Could a browser end a span sooner than CommonMark does in `text`? Then the sweep reads both. */
164
+ const SHORTER = /<\?|<!\[CDATA\[|--!>/;
165
+ function fold(s, lower = true) {
166
+ if (/^[\x00-\x7f]*$/.test(s))
167
+ return lower ? s.toLowerCase() : s; // most lines: nothing else to do
168
+ const out = [];
169
+ for (const ch of s.normalize("NFC")) {
170
+ if (ch <= "\x7f")
171
+ out.push(lower ? ch.toLowerCase() : ch);
172
+ else
173
+ foldWide(out, ch);
174
+ }
175
+ return out.join("");
176
+ }
177
+ /**
178
+ * HTML's named character references that hold an ASCII character: all 49 of them. Every
179
+ * other name stands for non-ASCII characters only. test/references.test.ts checks this
180
+ * table against the `entities` package.
181
+ */
182
+ export const ASCII_REFERENCES = {
183
+ amp: "&", AMP: "&", apos: "'", ast: "*", midast: "*", bsol: "\\", colon: ":", comma: ",", commat: "@",
184
+ DiacriticalGrave: "`", grave: "`", dollar: "$", equals: "=", excl: "!", fjlig: "fj", gt: ">", GT: ">",
185
+ Hat: "^", lbrace: "{", lcub: "{", lbrack: "[", lsqb: "[", lowbar: "_", UnderBar: "_", lpar: "(",
186
+ lt: "<", LT: "<", NewLine: "\n", num: "#", percnt: "%", period: ".", plus: "+", quest: "?",
187
+ quot: '"', QUOT: '"', rbrace: "}", rcub: "}", rbrack: "]", rsqb: "]", rpar: ")", semi: ";",
188
+ sol: "/", Tab: "\t", verbar: "|", vert: "|", VerticalLine: "|",
189
+ bne: `=${SUB}`, nvgt: `>${SUB}`, nvlt: `<${SUB}`,
190
+ };
191
+ /**
192
+ * HTML's named references to invisible format characters only (soft hyphen, zero-width
193
+ * space and joiners, word joiner, bidi marks): all 17. The rendered view drops them.
194
+ * test/references.test.ts checks this list against the `entities` package.
195
+ */
196
+ export const INVISIBLE_REFERENCES = new Set([
197
+ "af", "ApplyFunction", "ic", "InvisibleComma", "InvisibleTimes", "it", "lrm", "NegativeMediumSpace", "NegativeThickSpace",
198
+ "NegativeThinSpace", "NegativeVeryThinSpace", "NoBreak", "rlm", "shy", "ZeroWidthSpace", "zwj", "zwnj",
199
+ ]);
200
+ /** A reference as HTML reads one in an attribute: numeric ones with or without `;`. */
201
+ const REFERENCE = /&(?:#(\d+);?|#[xX]([0-9a-fA-F]+);?|([A-Za-z][A-Za-z0-9]{0,31});)/y;
202
+ const PUNCT = /[!-/:-@[-`{-~]/;
203
+ const HEX = /^[0-9a-fA-F]$/;
204
+ // Quote and list markers before a line's content.
205
+ const CONTAINER = /^(?:[ \t]*(?:>|[-*+](?=[ \t]|$)|\d{1,9}[.)](?=[ \t]|$)))*[ \t]*/;
206
+ // Characters a path runs through. Anything else may come right before one starts.
207
+ const PATH_CHAR = /[A-Za-z0-9._~%/\-\u001a]/;
208
+ // The same as a table, for the loops: the views hold only ASCII and SUB.
209
+ const PATH_CODE = new Uint8Array(128);
210
+ for (let c = 0; c < 128; c++)
211
+ PATH_CODE[c] = PATH_CHAR.test(String.fromCharCode(c)) ? 1 : 0;
212
+ /** Where a path may start after this character: anything a path can't hold, and SUB. */
213
+ const startsAfter = (D, k) => {
214
+ const c = D.charCodeAt(k);
215
+ return c === 0x1a || !PATH_CODE[c];
216
+ };
217
+ /** Whether `v` holds, as written, a character no URL holds, between `i` and `j`. */
218
+ const writtenDelimiter = (v, i, j) => {
219
+ for (let k = i; k < j; k++)
220
+ if (v.raw(k) && " \t\n\r\f\v\"'<>[]`".includes(v.text[k]))
221
+ return true;
222
+ return false;
223
+ };
224
+ /** Whitespace as written (not decoded from something else): it ends a plain path. */
225
+ const writtenSpace = (v, k) => {
226
+ const c = v.text.charCodeAt(k);
227
+ return (c === 32 || (c >= 9 && c <= 13)) && v.raw(k);
228
+ };
229
+ /**
230
+ * Whether a `..` in a candidate cancels a segment holding a delimiter: whitespace or one
231
+ * of the characters markdown and HTML put before a destination (a quote, `<`, `(`, `]`,
232
+ * `=`, `:`, a backtick...). A start too early leaves such segments in front
233
+ * (`"../../x.md`, or from a quote two lines up, `>see</a>text \`zz/../../x.md`), and
234
+ * cancelling them would resolve the rest too deep. Such a start is read second
235
+ * (`namesHidden`): only when no other start resolves to a page, since a directory may
236
+ * hold a delimiter of its own (`(draft)/../../x.md`, `../scratch folder/../x.md`).
237
+ */
238
+ const DELIMITER = /[\s"'<>()[\]{}=:`*|]/;
239
+ function cancelsJunk(token) {
240
+ const kept = []; // per segment kept so far: does it hold a delimiter?
241
+ for (const seg of token.split("/")) {
242
+ if (seg === "" || seg === ".")
243
+ continue;
244
+ if (seg === "..") {
245
+ if (kept.pop())
246
+ return true;
247
+ continue;
248
+ }
249
+ kept.push(DELIMITER.test(seg));
250
+ }
251
+ return false;
252
+ }
253
+ const REACH = 512;
254
+ const WORDS_BACK = 2;
255
+ export function pageNames(repo, hidden) {
256
+ const exists = existsIn(repo.root);
257
+ return { ...buildNames(repo, hidden, fold, exists), rendered: buildNames(repo, hidden, foldRendered, exists) };
258
+ }
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) {
272
+ const root = fold(repo.config.knowledge_root.replace(/\/+$/, ""));
273
+ const paths = new Map();
274
+ const under = new Map();
275
+ const names = new Map();
276
+ const spelled = new Map();
277
+ const spelledUnder = new Map();
278
+ const routes = new Map();
279
+ const stems = new Set();
280
+ const urlDirs = new Map();
281
+ let depth = 0;
282
+ // Pages that didn't load count as hidden: their ACL can't be read.
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) {
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
+ }
299
+ paths.set(key, (paths.get(key) ?? false) || isHidden);
300
+ const rel = key.startsWith(`${root}/`) ? key.slice(root.length + 1) : key;
301
+ if (rel.includes("/"))
302
+ under.set(rel, (under.get(rel) ?? false) || isHidden);
303
+ depth = Math.max(depth, key.split("/").length);
304
+ if (!isHidden)
305
+ continue;
306
+ const name = posix.basename(key);
307
+ if (!names.has(name.length))
308
+ names.set(name.length, new Set());
309
+ names.get(name.length).add(name);
310
+ if (!spelled.has(name))
311
+ spelled.set(name, []);
312
+ spelled.get(name).push(key);
313
+ if (!spelledUnder.has(name))
314
+ spelledUnder.set(name, []);
315
+ if (rel !== key && rel.includes("/"))
316
+ spelledUnder.get(name).push(rel);
317
+ }
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;
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;
398
+ /** The knowledge repo's web and raw URL prefixes, when `github.repo` says where it is. */
399
+ function repoUrls(repo) {
400
+ const name = repo.config.github.repo?.toLowerCase();
401
+ if (!name)
402
+ return [];
403
+ const api = repo.config.github.api_url;
404
+ // The host name alone: a URL's port is read past (`hostAndPath`), so this one is too.
405
+ const host = api ? new URL(api).hostname.toLowerCase() : "github.com";
406
+ if (host === "github.com" || host === "api.github.com")
407
+ return [`github.com/${name}/`, `raw.githubusercontent.com/${name}/`];
408
+ return [`${host}/${name}/`, `${host}/raw/${name}/`]; // GitHub Enterprise Server
409
+ }
410
+ const chars = () => ({ c: [], line: [], raw: [] });
411
+ const put = (into, s, at, asWritten) => {
412
+ for (const c of s) {
413
+ into.c.push(c);
414
+ into.line.push(at);
415
+ into.raw.push(asWritten);
416
+ }
417
+ };
418
+ /** A non-ASCII character (or an undecoded reference), as `fold` reads it, on line `at`. */
419
+ // Across lines too: the joined view reads a value broken mid-character as one.
420
+ const putWide = (into, ch, at) => {
421
+ const last = into.c.length - 1;
422
+ if (last >= 0 && into.c[last] === SUB)
423
+ return; // the run goes on
424
+ if (MARK.test(ch) && last >= 0 && BASE.test(into.c[last])) {
425
+ into.c[last] = SUB;
426
+ into.raw[last] = false;
427
+ }
428
+ else
429
+ put(into, SUB, at, false);
430
+ };
431
+ /** A decoded reference: ASCII as is, SUB (a non-ASCII part) through `putWide`. */
432
+ // A `\` decoded from a reference (`&bsol;`, `&#92;`) is a `/` to a browser too.
433
+ const putDecoded = (into, s, at) => {
434
+ for (const c of s)
435
+ if (c === SUB)
436
+ putWide(into, "\u00e9", at);
437
+ else
438
+ put(into, c === "\\" ? "/" : c, at, false);
439
+ };
440
+ /**
441
+ * Character references decoded, as HTML reads them, and with `escapes` backslash escapes
442
+ * too, as CommonMark reads them outside raw HTML. Non-ASCII characters (and references to
443
+ * them) become SUB. Any other `\` is a `/`, as a browser reads it in a relative URL: in
444
+ * raw HTML, `..\..\x.md` is `../../x.md`, where CommonMark reads `\.` as an escaped `.`.
445
+ */
446
+ function unescapeInto(l, at, into, escapes, rendered = false) {
447
+ for (let i = 0; i < l.length;) {
448
+ const c = l[i];
449
+ if (c === "\\") {
450
+ const next = l[i + 1];
451
+ if (escapes && next !== undefined && PUNCT.test(next) && next !== "\\") {
452
+ put(into, next, at, false);
453
+ i += 2;
454
+ }
455
+ else {
456
+ put(into, "/", at, false);
457
+ i += escapes && next === "\\" ? 2 : 1;
458
+ }
459
+ continue;
460
+ }
461
+ if (c === "&") {
462
+ REFERENCE.lastIndex = i;
463
+ const m = REFERENCE.exec(l);
464
+ if (m) {
465
+ const code = m[1] !== undefined ? Number(m[1]) : m[2] !== undefined ? Number.parseInt(m[2], 16) : undefined;
466
+ // Rendered, a reference to an invisible character shows nothing.
467
+ if (rendered && (code === undefined ? INVISIBLE_REFERENCES.has(m[3]) : code <= 0x10ffff && /^\p{Cf}$/u.test(String.fromCodePoint(code)))) {
468
+ i = REFERENCE.lastIndex;
469
+ continue;
470
+ }
471
+ if (code === undefined)
472
+ putDecoded(into, ASCII_REFERENCES[m[3]] ?? SUB, at);
473
+ else if (code > 0 && code < 0x80)
474
+ putDecoded(into, String.fromCharCode(code), at);
475
+ else
476
+ putWide(into, code <= 0x10ffff ? String.fromCodePoint(code) : "\ufffd", at);
477
+ i = REFERENCE.lastIndex;
478
+ continue;
479
+ }
480
+ }
481
+ if (c > "\x7f") {
482
+ const ch = String.fromCodePoint(l.codePointAt(i));
483
+ putWide(into, ch, at);
484
+ i += ch.length;
485
+ continue;
486
+ }
487
+ put(into, c, at, true);
488
+ i++;
489
+ }
490
+ }
491
+ /**
492
+ * Percent-encoding, decoded after the references (`&#37;2F` is `%2F`, then `/`): all of
493
+ * it, `%2F` included. A non-ASCII character's bytes become one SUB. With `dropBreaks`,
494
+ * line breaks and tabs go, as URL parsing drops them from a value.
495
+ */
496
+ function percentInto(written, into, dropBreaks) {
497
+ const keep = (c) => !dropBreaks || (c !== "\n" && c !== "\t" && c !== "\r");
498
+ // A URL parser drops line breaks and tabs before it decodes: `%2`, a break, `E` is `.`.
499
+ let from = written;
500
+ if (dropBreaks && written.c.some((c) => !keep(c))) {
501
+ from = chars();
502
+ for (let k = 0; k < written.c.length; k++)
503
+ if (keep(written.c[k]))
504
+ put(from, written.c[k], written.line[k], written.raw[k]);
505
+ }
506
+ for (let k = 0; k < from.c.length;) {
507
+ if (from.c[k] === "%" && HEX.test(from.c[k + 1] ?? "") && HEX.test(from.c[k + 2] ?? "")) {
508
+ const byte = Number.parseInt(from.c[k + 1] + from.c[k + 2], 16);
509
+ const at = from.line[k];
510
+ k += 3;
511
+ if (byte < 0x80) {
512
+ const c = byte === 0x5c ? "/" : String.fromCharCode(byte);
513
+ if (keep(c))
514
+ put(into, c, at, false);
515
+ continue;
516
+ }
517
+ // A UTF-8 sequence: its lead byte and the continuation bytes after it, as one
518
+ // character (a stray continuation byte stands alone, as an invalid one).
519
+ const bytes = [byte];
520
+ while (byte >= 0xc0 && bytes.length < 4 && from.c[k] === "%" && /^[89ab]$/i.test(from.c[k + 1] ?? "") && HEX.test(from.c[k + 2] ?? "")) {
521
+ bytes.push(Number.parseInt(from.c[k + 1] + from.c[k + 2], 16));
522
+ k += 3;
523
+ }
524
+ putWide(into, [...Buffer.from(bytes).toString("utf8")][0], at);
525
+ continue;
526
+ }
527
+ if (keep(from.c[k]))
528
+ put(into, from.c[k], from.line[k], from.raw[k]);
529
+ k++;
530
+ }
531
+ }
532
+ /**
533
+ * The text as a reader resolves it, with or without CommonMark's escapes, each line past
534
+ * its container markers. As prose: a line break is a space (markdown renders a soft break
535
+ * as one, so a path with spaces wrapped at one still reads whole). Or joined: line breaks
536
+ * and tabs (written or decoded) are dropped, as URL parsing drops them from a value.
537
+ */
538
+ function readerView(lines, joined, escapes, rendered = false, markers = false) {
539
+ const first = chars();
540
+ lines.forEach((whole, at) => {
541
+ const content = markers ? whole : whole.slice(CONTAINER.exec(whole)[0].length);
542
+ unescapeInto((rendered ? rendered(content) : content).normalize("NFC"), at, first, escapes, !!rendered);
543
+ if (!joined)
544
+ put(first, " ", at, true);
545
+ });
546
+ const out = chars();
547
+ percentInto(first, out, joined);
548
+ // Lower case only changes ASCII letters here: offsets stay put.
549
+ const cased = out.c.join("");
550
+ return { text: cased.toLowerCase(), cased, lineOf: (k) => out.line[k], raw: (k) => out.raw[k] };
551
+ }
552
+ /**
553
+ * The same view for a text with nothing to decode (no `&`, `%` or `\`):
554
+ * the text as written, non-ASCII as SUB, built in one pass. Most pages are like this.
555
+ */
556
+ function plainView(lines, joined, rendered = false, markers = false) {
557
+ let parts = lines
558
+ .map((l) => (markers ? l : l.slice(CONTAINER.exec(l)[0].length)))
559
+ .map((l) => (rendered ? fold(rendered(l), false) : fold(joined ? l.replace(/[\t\r]/g, "") : l, false)));
560
+ if (joined) {
561
+ // A run of non-ASCII characters broken across lines is one run once they're joined.
562
+ let lastSub = false;
563
+ parts = parts.map((p) => {
564
+ const q = lastSub && p.startsWith(SUB) ? p.slice(1) : p;
565
+ if (q.length)
566
+ lastSub = q.endsWith(SUB);
567
+ return q;
568
+ });
569
+ }
570
+ const starts = [];
571
+ let at = 0;
572
+ for (const p of parts) {
573
+ starts.push(at);
574
+ at += p.length + (joined ? 0 : 1);
575
+ }
576
+ const lineOf = (k) => {
577
+ let lo = 0;
578
+ let hi = starts.length - 1;
579
+ while (lo < hi) {
580
+ const mid = (lo + hi + 1) >> 1;
581
+ if (starts[mid] <= k)
582
+ lo = mid;
583
+ else
584
+ hi = mid - 1;
585
+ }
586
+ return lo;
587
+ };
588
+ // Lower case only changes ASCII letters here: offsets stay put.
589
+ const cased = parts.join(joined ? "" : " ");
590
+ return { text: cased.toLowerCase(), cased, lineOf, raw: () => true };
591
+ }
592
+ // Anything a reader might decode. Any `&` or `%`, not only a whole reference or `%XX`:
593
+ // joining lines can complete one (`index%2` then `emd` on the next line).
594
+ const ENCODED = /[&%\\]/;
595
+ /**
596
+ * A link destination as the URL it becomes: references (and, in markdown, escapes)
597
+ * decoded, query and fragment cut, then percent-decoded (non-ASCII as SUB).
598
+ */
599
+ function readDestination(dest, html) {
600
+ const first = chars();
601
+ unescapeInto(dest.normalize("NFC"), 0, first, !html);
602
+ const cut = first.c.findIndex((c) => c === "?" || c === "#");
603
+ if (cut >= 0)
604
+ for (const a of [first.c, first.line, first.raw])
605
+ a.length = cut;
606
+ const out = chars();
607
+ percentInto(first, out, true);
608
+ return out.c.join("").toLowerCase().trim();
609
+ }
610
+ /**
611
+ * Whether a link destination, as written on page `from`, leads to a hidden page: one
612
+ * that resolves to it, or a URL that ends with its path. A path under the knowledge root
613
+ * that is no loaded page fails closed: a page that didn't load has no trustworthy ACL.
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.
618
+ */
619
+ export function destinationHides(dest, from, names, html = false, links) {
620
+ let d = dest.trim();
621
+ if (!html && d.startsWith("<") && d.endsWith(">"))
622
+ d = d.slice(1, -1);
623
+ const path = readDestination(d, html);
624
+ if (!path)
625
+ return false;
626
+ const fromKey = fold(from);
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)));
665
+ const known = names.paths.get(resolved);
666
+ if (known !== undefined)
667
+ return known;
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);
726
+ }
727
+ const repoPathOf = (t, from) => (t.startsWith("/") ? posix.normalize(t.slice(1)) : posix.normalize(posix.join(posix.dirname(from), t)));
728
+ /**
729
+ * The lines of `text` holding a link destination that `destinationHides` refuses, read
730
+ * wherever a destination can stand, whatever surrounds it: after `](` (an inline link or
731
+ * image, whatever its text), after `]:` (a definition in any container, its destination
732
+ * on the next line or not), and in a tag's URL attributes, across lines. The name sweep
733
+ * finds only pages that exist; this is how a path under the knowledge root that is no
734
+ * page fails closed in every form, as it does in the common ones (merge gate, PR 13:
735
+ * `[a [b] c](../security/not-loaded.md)` was kept).
736
+ */
737
+ function destinationLines(text, from, names, bases = [], links) {
738
+ const marked = new Set();
739
+ for (const d of destinations(text)) {
740
+ // As written, and as a browser resolves it against each `<base href>` (merge gate, PR
741
+ // 13: `<base href="../security/">` then `<a href="x.md">` is the hidden page's link).
742
+ const hides = destinationHides(d.dest, from, names, d.html, links) || bases.some((b) => {
743
+ const r = againstBase(b, d.dest, d.html, from);
744
+ return r !== undefined && destinationHides(r, from, names, true);
745
+ });
746
+ if (hides)
747
+ for (let l = d.lines[0]; l <= d.lines[1]; l++)
748
+ marked.add(l);
749
+ }
750
+ return marked;
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();
914
+ /** Every destination in `text`, wherever one can stand (see `destinationLines`). */
915
+ function destinations(text) {
916
+ const out = [];
917
+ if (!text.includes("](") && !text.includes("]:") && !/<[A-Za-z]/.test(text))
918
+ return out;
919
+ for (const { t, starts, raw } of joins(text.split("\n"))) {
920
+ const span = (i, j) => [lineAt(starts, i), lineAt(starts, Math.max(i, j - 1))];
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;
925
+ for (let i = t.indexOf(opener); i >= 0;) {
926
+ // Read no further than the next opener, so no stretch is read twice.
927
+ const next = t.indexOf(opener, i + 2);
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) } : {};
939
+ if (e > s)
940
+ out.push({ dest: t.slice(s, e), html: false, lines: span(i, e), ...label });
941
+ i = next;
942
+ }
943
+ }
944
+ for (const tag of tagsIn(t)) {
945
+ for (const [name, value] of tag.attributes) {
946
+ // A refresh's `content` navigates: `0; url=…`.
947
+ const refresh = name === "content" && tag.name === "meta" ? REFRESH.exec(value) : null;
948
+ if (!refresh && !URL_ATTRIBUTES.has(name))
949
+ continue;
950
+ for (const u of refresh ? [refresh[1]] : urlsIn(name, value))
951
+ out.push({ dest: u, html: true, lines: span(tag.at, tag.end) });
952
+ }
953
+ }
954
+ }
955
+ return out;
956
+ }
957
+ /** The `<base href>`s in `text`, as written: each moves what a relative URL resolves against. */
958
+ function basesIn(text) {
959
+ if (!/<base/i.test(text))
960
+ return [];
961
+ const out = new Set();
962
+ for (const { t } of joins(text.split("\n")))
963
+ for (const tag of tagsIn(t))
964
+ if (tag.name === "base")
965
+ for (const [name, value] of tag.attributes)
966
+ if (name === "href")
967
+ out.add(value);
968
+ return [...out];
969
+ }
970
+ // Where a repo page sits, as a URL: what a `<base href>` is resolved against.
971
+ const PAGE_ORIGIN = "https://page.invalid";
972
+ /**
973
+ * `dest` as a browser resolves it with `base` (an attribute's value, as written) in force,
974
+ * on page `from`: a path in the repo as `/path`, or a URL elsewhere; undefined if it isn't
975
+ * one. Parsed by the platform's URL parser, as a browser parses it.
976
+ */
977
+ function againstBase(base, dest, html, from) {
978
+ const plain = (s, escapes) => decodeText(s.trim(), escapes);
979
+ let d = dest.trim();
980
+ if (!html && d.startsWith("<") && d.endsWith(">"))
981
+ d = d.slice(1, -1);
982
+ try {
983
+ const at = new URL(plain(d, !html), new URL(plain(base, false), new URL(from, `${PAGE_ORIGIN}/`)));
984
+ return at.origin === PAGE_ORIGIN ? at.pathname : at.href;
985
+ }
986
+ catch {
987
+ return undefined;
988
+ }
989
+ }
990
+ /**
991
+ * For each line inside a raw HTML block, the block's first line's container markers (a
992
+ * later line is read past them, as written); undefined elsewhere.
993
+ * The starts and ends are CommonMark's (4.6), except that a lone tag (kind 7) is taken to
994
+ * start one even where it can't interrupt a paragraph: that only reads more as written.
995
+ */
996
+ function htmlBlockPrefixes(lines) {
997
+ const out = [];
998
+ let end; // the open block's end, or /^$/ for "a blank line"
999
+ let prefix = "";
1000
+ for (const l of lines) {
1001
+ if (end) {
1002
+ // Inside, only the block's own container markers are markers: the rest is HTML (a
1003
+ // `> ` is no blank line there). A line that leaves the container ends the block.
1004
+ const inside = l.startsWith(prefix) ? l.slice(prefix.length) : !prefix.includes(">") && /^[ \t]*$/.test(l.slice(0, prefix.length)) ? l.slice(prefix.length) : undefined;
1005
+ if (inside === undefined || (end.source === "^$" && !inside.trim()))
1006
+ end = undefined;
1007
+ else {
1008
+ out.push(prefix);
1009
+ if (end.source !== "^$" && end.test(inside))
1010
+ end = undefined;
1011
+ continue;
1012
+ }
1013
+ }
1014
+ const p = CONTAINER.exec(l)[0];
1015
+ const content = l.slice(p.length);
1016
+ const kind = HTML_BLOCK_STARTS.find(([start]) => start.test(content));
1017
+ out.push(kind ? p : undefined);
1018
+ if (!kind)
1019
+ continue;
1020
+ prefix = p;
1021
+ end = kind[1].source !== "^$" && kind[1].test(content) ? undefined : kind[1];
1022
+ }
1023
+ return out;
1024
+ }
1025
+ const BLOCK_TAGS = "address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul";
1026
+ // Each kind of raw HTML block: how it starts, on a line past its container markers, and
1027
+ // how it ends (on the line that holds the end, or, for /^$/, before a blank line).
1028
+ const HTML_BLOCK_STARTS = [
1029
+ [/^ {0,3}<(?:script|pre|style|textarea)(?:\s|>|$)/i, /<\/(?:script|pre|style|textarea)>/i],
1030
+ [/^ {0,3}<!--/, /-->/],
1031
+ [/^ {0,3}<\?/, /\?>/],
1032
+ [/^ {0,3}<![A-Za-z]/, />/],
1033
+ [/^ {0,3}<!\[CDATA\[/, /\]\]>/],
1034
+ [new RegExp(`^ {0,3}<\\/?(?:${BLOCK_TAGS})(?:\\s|\\/?>|$)`, "i"), /^$/],
1035
+ [/^ {0,3}(?:<[A-Za-z][A-Za-z0-9-]*(?:\s+[A-Za-z_:][A-Za-z0-9_.:-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*\s*\/?>|<\/[A-Za-z][A-Za-z0-9-]*\s*>)\s*$/, /^$/],
1036
+ ];
1037
+ /**
1038
+ * The text joined as HTML reads a value broken across lines: with each line's container
1039
+ * markers dropped, as CommonMark drops them in a paragraph; and, when there's a raw HTML
1040
+ * block, again with its lines as written past its first line's markers (`raw`), where the
1041
+ * next line's quote marker or indent is part of the value (`<img …>`, then `> <a
1042
+ * href="../x`, then `> y.md">`). Not as written elsewhere: in a paragraph that reading
1043
+ * makes a path that is no page, and that fails closed.
1044
+ */
1045
+ function joins(lines) {
1046
+ const block = htmlBlockPrefixes(lines);
1047
+ return (block.some((p) => p !== undefined) ? [false, true] : [false]).map((raw) => {
1048
+ const starts = [];
1049
+ let t = "";
1050
+ for (const [i, l] of lines.entries()) {
1051
+ starts.push(t.length);
1052
+ const p = raw ? block[i] : undefined;
1053
+ const cut = p === undefined ? CONTAINER.exec(l)[0].length : l.startsWith(p) ? p.length : /^[ \t]*$/.test(p) ? Math.min(p.length, /^[ \t]*/.exec(l)[0].length) : 0;
1054
+ t += `${l.slice(cut)}\n`;
1055
+ }
1056
+ return { t, starts, raw };
1057
+ });
1058
+ }
1059
+ /**
1060
+ * The lines of `text` holding an iframe whose `srcdoc` (decoded) `names`: a document a
1061
+ * browser shows in its own right, so each sweep reads it as one (merge gate, PR 13: a
1062
+ * path split by markup in it showed whole there, and the rendered view drops the tag).
1063
+ */
1064
+ function srcdocLines(text, depth, names) {
1065
+ const marked = new Set();
1066
+ if (!/srcdoc/i.test(text))
1067
+ return marked;
1068
+ // Each document once, however many readings find its tag (both tag readers, both joins).
1069
+ const docs = new Map();
1070
+ for (const { t, starts } of joins(text.split("\n"))) {
1071
+ for (const tag of tagsIn(t)) {
1072
+ for (const [name, value] of tag.attributes) {
1073
+ if (name !== "srcdoc")
1074
+ continue;
1075
+ const doc = decoded(value);
1076
+ if (!docs.has(doc))
1077
+ docs.set(doc, []);
1078
+ docs.get(doc).push([lineAt(starts, tag.at), lineAt(starts, tag.end - 1)]);
1079
+ }
1080
+ }
1081
+ }
1082
+ // Nested deeper than any page does, it fails closed: each level reads its document
1083
+ // again (merge gate, PR 13).
1084
+ for (const [doc, spans] of docs)
1085
+ if (depth >= SRCDOC_DEPTH || names(doc))
1086
+ for (const [a, b] of spans)
1087
+ for (let l = a; l <= b; l++)
1088
+ marked.add(l);
1089
+ return marked;
1090
+ }
1091
+ const SRCDOC_DEPTH = 3;
1092
+ /** The line holding offset `k`, given each line's start. */
1093
+ function lineAt(starts, k) {
1094
+ let lo = 0;
1095
+ let hi = starts.length - 1;
1096
+ while (lo < hi) {
1097
+ const mid = (lo + hi + 1) >> 1;
1098
+ if (starts[mid] <= k)
1099
+ lo = mid;
1100
+ else
1101
+ hi = mid - 1;
1102
+ }
1103
+ return lo;
1104
+ }
1105
+ /**
1106
+ * The destination after `](` or `]:` (at `at`), read no further than `end`, as [start,
1107
+ * end): past spaces and at most one line break, then `<…>`, or up to whitespace (and in
1108
+ * an inline link, a `)` that closes nothing).
1109
+ */
1110
+ function destinationAt(t, at, end, inline) {
1111
+ let k = at;
1112
+ while (k < end && (t[k] === " " || t[k] === "\t"))
1113
+ k++;
1114
+ if (k < end && t[k] === "\n")
1115
+ k++;
1116
+ while (k < end && (t[k] === " " || t[k] === "\t"))
1117
+ k++;
1118
+ const s = k;
1119
+ if (t[k] === "<") {
1120
+ for (k++; k < end && t[k] !== ">" && t[k] !== "<" && t[k] !== "\n"; k++)
1121
+ if (t[k] === "\\")
1122
+ k++;
1123
+ return k < end && t[k] === ">" ? [s, k + 1] : [s, s];
1124
+ }
1125
+ let depth = 0;
1126
+ for (; k < end; k++) {
1127
+ const c = t.charCodeAt(k);
1128
+ if (c <= 32)
1129
+ break;
1130
+ if (c === 92)
1131
+ k++; // `\`
1132
+ else if (inline && c === 40)
1133
+ depth++; // `(`
1134
+ else if (inline && c === 41 && depth-- === 0)
1135
+ break; // `)`
1136
+ }
1137
+ return [s, Math.min(k, end)];
1138
+ }
1139
+ /**
1140
+ * The tags in `t`, with their attributes: as CommonMark reads a tag (raw HTML in a
1141
+ * paragraph), and as a browser's tokenizer reads them in order (in a raw HTML block a
1142
+ * browser takes tags CommonMark wouldn't: `<a href="x" "y">`). The browser's reading
1143
+ * stops at a tag still open at the end, as a browser does; CommonMark's reads on.
1144
+ */
1145
+ function tagsIn(t) {
1146
+ const out = [];
1147
+ const named = (s) => [...s.matchAll(ATTRIBUTE)].map((a) => [a[1].toLowerCase(), a[2] ?? a[3] ?? a[4]]);
1148
+ for (const m of t.matchAll(TAG))
1149
+ out.push({ at: m.index, end: m.index + m[0].length, name: /^<([A-Za-z0-9-]+)/.exec(m[0])[1].toLowerCase(), attributes: named(m[0]) });
1150
+ // A browser's tokenizer, simplified: a tag is `<`, a letter and a name, then
1151
+ // attributes, each a name (to whitespace, `/`, `>` or `=`) and maybe `=` and a value,
1152
+ // quoted (across lines, `>` included) or up to whitespace or `>`.
1153
+ const space = (c) => c === " " || c === "\t" || c === "\n" || c === "\r" || c === "\f";
1154
+ const open = /<[A-Za-z]/g;
1155
+ for (let m = open.exec(t); m; m = open.exec(t)) {
1156
+ const at = m.index;
1157
+ let k = at + 1;
1158
+ while (k < t.length && !space(t[k]) && t[k] !== "/" && t[k] !== ">")
1159
+ k++;
1160
+ const tag = { at, end: -1, name: t.slice(at + 1, k).toLowerCase(), attributes: [] };
1161
+ while (k < t.length) {
1162
+ while (k < t.length && (space(t[k]) || t[k] === "/"))
1163
+ k++;
1164
+ if (t[k] === ">") {
1165
+ tag.end = k + 1;
1166
+ break;
1167
+ }
1168
+ const n = k++;
1169
+ while (k < t.length && !space(t[k]) && t[k] !== "/" && t[k] !== ">" && t[k] !== "=")
1170
+ k++;
1171
+ const name = t.slice(n, k).toLowerCase();
1172
+ let j = k;
1173
+ while (space(t[j]))
1174
+ j++;
1175
+ if (t[j] !== "=") {
1176
+ tag.attributes.push([name, ""]);
1177
+ continue;
1178
+ }
1179
+ for (j++; space(t[j]); j++)
1180
+ ;
1181
+ if (t[j] === '"' || t[j] === "'") {
1182
+ const close = t.indexOf(t[j], j + 1);
1183
+ if (close < 0)
1184
+ break;
1185
+ tag.attributes.push([name, t.slice(j + 1, close)]);
1186
+ k = close + 1;
1187
+ }
1188
+ else {
1189
+ for (k = j; k < t.length && !space(t[k]) && t[k] !== ">"; k++)
1190
+ ;
1191
+ tag.attributes.push([name, t.slice(j, k)]);
1192
+ }
1193
+ }
1194
+ if (tag.end < 0)
1195
+ break; // still open at the end: a browser drops it and reads no further
1196
+ out.push(tag);
1197
+ open.lastIndex = tag.end;
1198
+ }
1199
+ return out;
1200
+ }
1201
+ // A tag as CommonMark reads one (see UNSHOWN), and each of its attributes with its value.
1202
+ const TAG = /<[A-Za-z][A-Za-z0-9-]*(?:\s+[A-Za-z_:][A-Za-z0-9_.:-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*\s*\/?>/g;
1203
+ const ATTRIBUTE = /\s([A-Za-z_:][A-Za-z0-9_.:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g;
1204
+ const REFRESH = /^\s*[\d.]*\s*[;,]\s*(?:url\s*=\s*)?["']?([^"'\s]*)/i;
1205
+ /** An attribute's value with its character references decoded. */
1206
+ function decoded(value) {
1207
+ return decodeText(value, false);
1208
+ }
1209
+ /**
1210
+ * The URLs an attribute's value holds: a list for some (`srcset` and `imagesrcset` by
1211
+ * commas, each URL then a descriptor; `ping` and `archive` by whitespace), one otherwise.
1212
+ */
1213
+ function urlsIn(name, value) {
1214
+ if (name === "srcset" || name === "imagesrcset")
1215
+ return value.split(",").map((u) => u.trim().split(/\s+/)[0]);
1216
+ if (name === "ping" || name === "archive")
1217
+ return value.split(/\s+/).filter(Boolean);
1218
+ return [value];
1219
+ }
1220
+ // The attributes HTML reads as a URL.
1221
+ const URL_ATTRIBUTES = new Set(["href", "src", "srcset", "imagesrcset", "action", "formaction", "data", "poster", "cite", "background", "longdesc", "usemap", "ping", "archive", "codebase", "manifest", "icon", "xlink:href"]);
1222
+ /**
1223
+ * Whether a URL into the knowledge repo on GitHub (any branch; blob, tree, raw…) names a
1224
+ * path under the knowledge root that is no page here: a page on another branch, say,
1225
+ * which never loads, so its ACL can't be read. It fails closed as a relative link does.
1226
+ * A loaded visible page the URL may also be read as settles it (a branch can be named
1227
+ * `knowledge/…` too). A URL elsewhere can't be told from one to another site's page.
1228
+ */
1229
+ function namesNoPage(url, names, markdown = false) {
1230
+ // A `\` is a `/` to a browser reading it as written (raw HTML, text), but a markdown
1231
+ // destination's reaches the href as `%5C` (renderers percent-encode it), which a
1232
+ // browser keeps: there each is a reading of its own.
1233
+ return (markdown && url.includes("\\") ? [url, url.replace(/\\/g, "%5C")] : [url]).some((u) => namesNoPageAs(u, names));
1234
+ }
1235
+ function namesNoPageAs(url, names) {
1236
+ const web = webUrl(url);
1237
+ if (!web)
1238
+ return false;
1239
+ const u = fold(`${web.host}${web.path}`);
1240
+ const prefix = names.repoUrls.find((p) => u.startsWith(p));
1241
+ if (!prefix)
1242
+ return false;
1243
+ let noPage = false;
1244
+ let readings = 0;
1245
+ for (let at = u.indexOf(`/${names.root}/`, prefix.length - 1); at >= 0; at = u.indexOf(`/${names.root}/`, at + 1)) {
1246
+ // Past a few readings it fails closed: no real branch name holds that many.
1247
+ if (++readings > READINGS)
1248
+ return u.endsWith(".md");
1249
+ const path = posix.normalize(u.slice(at + 1));
1250
+ if (names.paths.get(path) === false)
1251
+ return false;
1252
+ if (path.endsWith(".md"))
1253
+ noPage = true;
1254
+ }
1255
+ return noPage;
1256
+ }
1257
+ const READINGS = 8;
1258
+ /**
1259
+ * A web URL's host and path as a browser reads them, by the platform's URL parser (merge
1260
+ * gate, PR 13: a port, then a fullwidth `g`, got past a prefix): the host mapped as a
1261
+ * browser maps it (to ASCII, lower case, percent-decoded), without userinfo, port, a
1262
+ * trailing dot or `www.`; the path decoded, in lower case. Undefined if it isn't one.
1263
+ * Without a scheme it's read as one written in text (`github.com/acme/…`).
1264
+ */
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) {
1271
+ // A URL parser drops tabs and line breaks first, wherever they are (`h` + break + `ttps:`).
1272
+ const u = url.replace(/[\t\n\r]/g, "").trim();
1273
+ try {
1274
+ const at = new URL(/^[a-z][a-z0-9+.-]*:/i.test(u) ? u : `https://${u.replace(/^[\\/]*/, "")}`);
1275
+ return at.protocol === "https:" || at.protocol === "http:" ? at : undefined;
1276
+ }
1277
+ catch {
1278
+ return undefined;
1279
+ }
1280
+ }
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) => {
1297
+ if (esc !== undefined)
1298
+ return escapes ? esc : m;
1299
+ if (strict && (decEnd === "" || hexEnd === ""))
1300
+ return m;
1301
+ const code = dec !== undefined ? Number(dec) : hex !== undefined ? Number.parseInt(hex, 16) : undefined;
1302
+ if (code !== undefined)
1303
+ return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : "\ufffd";
1304
+ // One for a non-ASCII character this doesn't know stands for it as `fold` does: SUB.
1305
+ return ASCII_REFERENCES[name] ?? SUB;
1306
+ });
1307
+ }
1308
+ /**
1309
+ * The lines of `text` (read as prose, decoded) holding a GitHub URL into the knowledge
1310
+ * repo that names no page (`namesNoPage`), however it's written: a bare URL, an autolink,
1311
+ * in code. Destinations are `destinationLines`', but a URL needs no link around it.
1312
+ */
1313
+ function repoUrlLines(text, names, links) {
1314
+ const marked = new Set();
1315
+ if (!names.repoUrls.length && !names.site)
1316
+ return marked;
1317
+ const lines = text.split("\n");
1318
+ const block = text.includes("\\") ? htmlBlockPrefixes(lines) : [];
1319
+ // Each line decoded (with CommonMark's escapes, but on a raw HTML block's lines without
1320
+ // them, as HTML reads them there), and again as it renders (markup inside a URL is gone
1321
+ // on screen), each with its paths' folding. Then every token that may be a URL is read
1322
+ // by the URL parser, which maps a host as a browser does (`github.com`, `github。com`).
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]);
1328
+ if (names.rendered)
1329
+ for (const show of renderings(text))
1330
+ readings.push([(l) => decodeText(show(l), true), names.rendered]);
1331
+ for (const [read, folded] of readings) {
1332
+ lines.forEach((l, i) => {
1333
+ if (marked.has(i))
1334
+ return;
1335
+ // Not normalized here: the parser maps a host itself, and NFKC would change a path
1336
+ // (`𝔄.md` is `A.md` to it).
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
+ }
1346
+ }
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);
1352
+ });
1353
+ }
1354
+ return marked;
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
+ }
1451
+ /**
1452
+ * Whether a token may be a URL with a path, for the URL parser to read: it has a scheme,
1453
+ * or its first part (past any slashes, as in `//github.com/…`) looks like a host and a
1454
+ * slash follows. Linear: a regex that tried every split backtracked on long tokens.
1455
+ */
1456
+ function urlish(token) {
1457
+ if (/^[a-z][a-z0-9+.-]*:/i.test(token))
1458
+ return true;
1459
+ const start = token.search(/[^/\\]/);
1460
+ if (start < 0)
1461
+ return false;
1462
+ const slash = token.slice(start).search(/[/\\]/);
1463
+ return slash > 0 && /[.%\u3002\uff0e\uff61]/.test(token.slice(start, start + slash));
1464
+ }
1465
+ const AUTHORITY = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^/]*/i;
1466
+ const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
1467
+ const isUrl = (t) => AUTHORITY.test(t.trim()) || SCHEME.test(t.trim());
1468
+ const memoized = (m, key, f) => {
1469
+ if (m.has(key))
1470
+ return m.get(key);
1471
+ const v = f();
1472
+ m.set(key, v);
1473
+ return v;
1474
+ };
1475
+ /**
1476
+ * The page a reference, as written from page `from`, resolves to as a link would: true if
1477
+ * hidden, false if visible, undefined for a URL or a path that is no page. Not cut at `?`
1478
+ * or `#`: the token ends with a hidden page's file name, so one before it is part of that
1479
+ * name (`%23`) or puts the path in a query (`?next=../x.md`).
1480
+ */
1481
+ function resolvesTo(token, from, names) {
1482
+ const t = token.trim();
1483
+ if (isUrl(t))
1484
+ return undefined;
1485
+ return names.paths.get(t.startsWith("/") ? posix.normalize(t.slice(1)) : posix.normalize(posix.join(posix.dirname(from), t)));
1486
+ }
1487
+ /**
1488
+ * The page a URL or an unresolved path names by what it ends with: the longest tail (two
1489
+ * segments or more) that is a page's repo path or its path under the root. Its length
1490
+ * says how specific the match is.
1491
+ */
1492
+ function endsWith(token, names) {
1493
+ const t = token.trim();
1494
+ const authority = AUTHORITY.exec(t);
1495
+ const scheme = SCHEME.exec(t);
1496
+ const path = authority ? t.slice(authority[0].length) : scheme ? t.slice(scheme[0].length) : t;
1497
+ const segs = posix.normalize(path).split("/").filter((s) => s && s !== "." && s !== "..");
1498
+ for (let n = Math.min(segs.length, names.depth); n >= 2; n--) {
1499
+ const tail = segs.slice(-n).join("/");
1500
+ const hidden = names.paths.get(tail) ?? names.under.get(tail);
1501
+ if (hidden !== undefined)
1502
+ return { hidden, length: tail.length };
1503
+ }
1504
+ return undefined;
1505
+ }
1506
+ /**
1507
+ * Whether the references that may end at one place name a hidden page. One that resolves
1508
+ * to a hidden page as a link decides. One that resolves to a visible page says what a
1509
+ * relative path there means: `<../team space/x/y.md>` and, from inside `team space/`,
1510
+ * `x/y.md` are that page, not a hidden `x/y.md`, and a start one character earlier only
1511
+ * glues junk to it. A URL's tail still counts: a later start inside it (after a port's
1512
+ * `:`, say) can resolve to a visible page while the URL names a hidden one. (`url` says
1513
+ * which tokens are URLs; the sweep's leaves out spans no URL could be.) Of what's left,
1514
+ * the most specific tail decides.
1515
+ */
1516
+ function namesHidden(tokens, from, names, memo, url = isUrl, junk = []) {
1517
+ const resolve = (t) => (memo ? memoized(memo.to, t, () => resolvesTo(t, from, names)) : resolvesTo(t, from, names));
1518
+ const tail = (t) => (memo ? memoized(memo.tail, t, () => endsWith(t, names)) : endsWith(t, names));
1519
+ let visible = false;
1520
+ for (const t of tokens) {
1521
+ const to = resolve(t);
1522
+ if (to === true)
1523
+ return true;
1524
+ if (to === false)
1525
+ visible = true;
1526
+ }
1527
+ // Starts that may only be junk in front (`cancelsJunk`), read when no other start
1528
+ // resolves to a page: `(draft)/../../x.md` is a directory's own name, `"../../x.md` isn't.
1529
+ if (!visible)
1530
+ for (const t of junk)
1531
+ if (resolve(t) === true)
1532
+ return true;
1533
+ let best;
1534
+ for (const t of tokens) {
1535
+ if (visible && !url(t))
1536
+ continue;
1537
+ const e = tail(t);
1538
+ if (e && (!best || e.length > best.length || (e.length === best.length && e.hidden)))
1539
+ best = e;
1540
+ }
1541
+ return best?.hidden ?? false;
1542
+ }
1543
+ /** Marks the lines of `v` that name a hidden page. */
1544
+ function scan(v, from, names, marked) {
1545
+ const D = v.text;
1546
+ const memo = { to: new Map(), tail: new Map(), junk: new Map() };
1547
+ // Each hidden page with a file name, as a path from this page's directory.
1548
+ const relative = new Map();
1549
+ const relativeTo = (name) => memoized(relative, name, () => (names.spelled.get(name) ?? []).map((path) => posix.relative(posix.dirname(from), path)).filter((r) => r.includes("/")));
1550
+ // For each offset, the nearest `<`, `"` and `'` as written at or before it (-1 for none),
1551
+ // built once on first use: a reference that may hold spaces starts after the nearest one
1552
+ // of its delimiter.
1553
+ let nearest;
1554
+ const quotes = () => {
1555
+ if (nearest)
1556
+ return nearest;
1557
+ const at = [new Int32Array(D.length), new Int32Array(D.length), new Int32Array(D.length)];
1558
+ const last = [-1, -1, -1];
1559
+ for (let i = 0; i < D.length; i++) {
1560
+ const c = D[i];
1561
+ const x = c === "<" ? 0 : c === '"' ? 1 : c === "'" ? 2 : -1;
1562
+ if (x >= 0 && v.raw(i))
1563
+ last[x] = i;
1564
+ for (let y = 0; y < 3; y++)
1565
+ at[y][i] = last[y];
1566
+ }
1567
+ return (nearest = at);
1568
+ };
1569
+ const mark = (i, j) => {
1570
+ for (let l = v.lineOf(i); l <= v.lineOf(j); l++)
1571
+ marked.add(l);
1572
+ };
1573
+ for (let e = D.indexOf(".md"); e >= 0; e = D.indexOf(".md", e + 1)) {
1574
+ const end = e + 3;
1575
+ // The hidden file names ending here that start a segment; s, the rightmost start.
1576
+ let s = -1;
1577
+ const ending = [];
1578
+ for (const [len, set] of names.names) {
1579
+ const at = end - len;
1580
+ if (at < 0 || !set.has(D.slice(at, end)))
1581
+ continue;
1582
+ if (at === 0 || D[at - 1] === "/" || startsAfter(D, at - 1)) {
1583
+ ending.push(D.slice(at, end));
1584
+ s = Math.max(s, at);
1585
+ }
1586
+ }
1587
+ if (s < 0)
1588
+ continue;
1589
+ // Where the reference could start: after any character a path can't hold, back to
1590
+ // whitespace as written; then after the nearest `<`, `"` and `'`.
1591
+ const starts = [];
1592
+ const floor = Math.max(0, s - REACH);
1593
+ let unsure = false;
1594
+ let k = s - 1;
1595
+ for (; k >= floor; k--) {
1596
+ const c = D[k];
1597
+ if (writtenSpace(v, k))
1598
+ break; // a line break too: a space in the prose view
1599
+ if (startsAfter(D, k))
1600
+ starts.push(k + 1);
1601
+ }
1602
+ if (k < floor && floor > 0)
1603
+ unsure = true; // ran past REACH
1604
+ starts.push(k + 1);
1605
+ if (!unsure && k >= 0) {
1606
+ // A path with spaces in prose or code (`knowledge/team space/x.md`): the starts of the
1607
+ // one or two words before, so the whole path is read too and can name a visible page.
1608
+ for (let q = k, words = 0; words < WORDS_BACK && q >= floor; words++) {
1609
+ while (q >= floor && writtenSpace(v, q))
1610
+ q--;
1611
+ if (q < floor)
1612
+ break;
1613
+ for (; q >= floor && !writtenSpace(v, q); q--)
1614
+ if (startsAfter(D, q))
1615
+ starts.push(q + 1);
1616
+ if (q < floor && floor > 0)
1617
+ break;
1618
+ starts.push(q + 1);
1619
+ }
1620
+ if (k > 0)
1621
+ for (const at of quotes())
1622
+ if (at[k - 1] >= floor)
1623
+ starts.push(at[k - 1] + 1);
1624
+ }
1625
+ if (unsure) {
1626
+ mark(Math.max(floor, k + 1), end - 1);
1627
+ continue;
1628
+ }
1629
+ // A hidden page's path under the root, or from this page's directory, spelled out
1630
+ // (spaces and all) where a path can start: read from there too (with any `./` and
1631
+ // `../` before it), so it resolves from this page like any other start. Not as the
1632
+ // tail of a longer path (`team space/x/y.md`).
1633
+ // A hidden page's repo path spelled out, spaces and all, anywhere (a URL holds it too,
1634
+ // after its `/`): a start, so a visible page whose path ends with it still decides.
1635
+ for (const path of ending.flatMap((n) => names.spelled.get(n) ?? []))
1636
+ if (end - path.length >= 0 && D.slice(end - path.length, end) === path)
1637
+ starts.push(end - path.length);
1638
+ for (const rel of ending.flatMap((n) => [...(names.spelledUnder.get(n) ?? []), ...relativeTo(n)])) {
1639
+ let j = end - rel.length;
1640
+ if (j < 0 || D.slice(j, end) !== rel)
1641
+ continue;
1642
+ for (;;) {
1643
+ if (D.startsWith("../", j - 3))
1644
+ j -= 3;
1645
+ else if (D.startsWith("./", j - 2))
1646
+ j -= 2;
1647
+ else
1648
+ break;
1649
+ }
1650
+ if (j === 0 || startsAfter(D, j - 1))
1651
+ starts.push(j);
1652
+ }
1653
+ const isJunk = (j) => memoized(memo.junk, D.slice(j, end), () => cancelsJunk(D.slice(j, end)));
1654
+ const read = starts.filter((j) => !isJunk(j));
1655
+ const junk = starts.filter(isJunk);
1656
+ // A URL as written holds no space, quote, angle or square bracket, or backtick: a
1657
+ // "URL" spanning those is two things read as one (`…/a.md "title"[b](c/x.md`).
1658
+ const urls = new Set(read.filter((j) => isUrl(D.slice(j, end)) && !writtenDelimiter(v, j, end)).map((j) => D.slice(j, end)));
1659
+ if (namesHidden(read.map((j) => D.slice(j, end)), from, names, memo, (t) => urls.has(t), junk.map((j) => D.slice(j, end))))
1660
+ mark(Math.min(...starts), end - 1);
1661
+ }
1662
+ }
1663
+ const renderings = (text) => (SHORTER.test(text) ? [unshown, (l) => unshown(l, true)] : [unshown]);
1664
+ /** The lines of `text`, on page `from`, that name a hidden page: see the module comment. */
1665
+ export function namingLines(text, from, names, depth = 0, inherited = [], liberal = false, gone) {
1666
+ // Destinations and URLs into the repo first, even with nothing hidden: one that names
1667
+ // no page fails closed. A srcdoc is a document of its own, with its parent's bases.
1668
+ const bases = [...inherited, ...basesIn(text)];
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()))
1674
+ marked.add(l);
1675
+ for (const l of srcdocLines(text, depth, (doc) => namingLines(doc, from, names, depth + 1, bases).size > 0))
1676
+ marked.add(l);
1677
+ if (!names.names.size)
1678
+ return withLabels(text, marked, gone);
1679
+ const lines = text.split("\n");
1680
+ const fromKey = fold(from);
1681
+ // Read with CommonMark's escapes and without (as raw HTML reads `\`); the same text
1682
+ // when there's no backslash, and as written when there's nothing to decode at all.
1683
+ const encoded = ENCODED.test(text);
1684
+ for (const escapes of text.includes("\\") ? [true, false] : [true]) {
1685
+ scan(encoded ? readerView(lines, false, escapes) : plainView(lines, false), fromKey, names, marked);
1686
+ scan(encoded ? readerView(lines, true, escapes) : plainView(lines, true), fromKey, names, marked);
1687
+ // Joined again with every line as written: inside a value broken across lines, what
1688
+ // looks like a list marker is part of it (`href="../x/` then `1. secret.md">`).
1689
+ scan(encoded ? readerView(lines, true, escapes, false, true) : plainView(lines, true, false, true), fromKey, names, marked);
1690
+ }
1691
+ // As rendered prose: markup inside a path (`team/**secret.md**`) is gone on screen.
1692
+ if (names.rendered) {
1693
+ for (const show of renderings(text))
1694
+ scan(encoded ? readerView(lines, false, true, show) : plainView(lines, false, show), foldRendered(from), names.rendered, marked);
1695
+ }
1696
+ return withLabels(text, marked, gone);
1697
+ }
1698
+ /**
1699
+ * The lines of `text` where `find`, run over each view the sweep reads (as prose and
1700
+ * joined, with and without CommonMark's escapes, and as rendered), reports a span. For
1701
+ * names that aren't paths: a mirrored page's id, say.
1702
+ */
1703
+ export function linesWhere(text, find, depth = 0, inherited = []) {
1704
+ const marked = new Set();
1705
+ const lines = text.split("\n");
1706
+ const encoded = ENCODED.test(text);
1707
+ const views = [];
1708
+ for (const escapes of text.includes("\\") ? [true, false] : [true]) {
1709
+ views.push(encoded ? readerView(lines, false, escapes) : plainView(lines, false));
1710
+ views.push(encoded ? readerView(lines, true, escapes) : plainView(lines, true));
1711
+ views.push(encoded ? readerView(lines, true, escapes, false, true) : plainView(lines, true, false, true));
1712
+ }
1713
+ for (const show of renderings(text))
1714
+ views.push(encoded ? readerView(lines, false, true, show) : plainView(lines, false, show));
1715
+ for (const v of views)
1716
+ for (const [i, j] of find(v.text, v.cased))
1717
+ for (let l = v.lineOf(i); l <= v.lineOf(Math.max(i, j - 1)); l++)
1718
+ marked.add(l);
1719
+ // A destination resolved against a `<base href>` (a relative `pages/900` under a wiki's
1720
+ // space URL), and a srcdoc as a document of its own, with its parent's bases.
1721
+ const bases = [...inherited, ...basesIn(text)];
1722
+ if (bases.length) {
1723
+ for (const d of destinations(text)) {
1724
+ for (const b of bases) {
1725
+ const r = againstBase(b, d.dest, d.html, ".knowledge/x.md");
1726
+ if (r !== undefined && [...find(r.toLowerCase(), r)].length)
1727
+ for (let l = d.lines[0]; l <= d.lines[1]; l++)
1728
+ marked.add(l);
1729
+ }
1730
+ }
1731
+ }
1732
+ for (const l of srcdocLines(text, depth, (doc) => linesWhere(doc, find, depth + 1, bases).size > 0))
1733
+ marked.add(l);
1734
+ return marked;
1735
+ }
1736
+ /** `text` with each line in `lines` replaced by its container markers and `placeholder`. */
1737
+ export function replaceLines(text, lines, placeholder) {
1738
+ if (!lines.size)
1739
+ return text;
1740
+ return text
1741
+ .split("\n")
1742
+ .map((l, i) => (lines.has(i) ? `${CONTAINER.exec(l)[0]}${placeholder}` : l))
1743
+ .join("\n");
1744
+ }