@docsworth/cli 0.0.0-stage → 0.2.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 (91) hide show
  1. package/LICENSE +55 -0
  2. package/README.md +186 -2
  3. package/dist/cli.d.ts +11 -0
  4. package/dist/cli.js +409 -0
  5. package/dist/core/acl.d.ts +28 -0
  6. package/dist/core/acl.js +102 -0
  7. package/dist/core/audit-log.d.ts +133 -0
  8. package/dist/core/audit-log.js +290 -0
  9. package/dist/core/blocks.d.ts +35 -0
  10. package/dist/core/blocks.js +417 -0
  11. package/dist/core/codeowners.d.ts +62 -0
  12. package/dist/core/codeowners.js +133 -0
  13. package/dist/core/config.d.ts +96 -0
  14. package/dist/core/config.js +131 -0
  15. package/dist/core/coverage.d.ts +25 -0
  16. package/dist/core/coverage.js +41 -0
  17. package/dist/core/duration.d.ts +20 -0
  18. package/dist/core/duration.js +60 -0
  19. package/dist/core/errors.d.ts +11 -0
  20. package/dist/core/errors.js +13 -0
  21. package/dist/core/frontmatter.d.ts +17 -0
  22. package/dist/core/frontmatter.js +40 -0
  23. package/dist/core/gate.d.ts +17 -0
  24. package/dist/core/gate.js +25 -0
  25. package/dist/core/github.d.ts +152 -0
  26. package/dist/core/github.js +234 -0
  27. package/dist/core/host.d.ts +125 -0
  28. package/dist/core/host.js +22 -0
  29. package/dist/core/identity.d.ts +39 -0
  30. package/dist/core/identity.js +127 -0
  31. package/dist/core/init.d.ts +25 -0
  32. package/dist/core/init.js +86 -0
  33. package/dist/core/lint.d.ts +17 -0
  34. package/dist/core/lint.js +115 -0
  35. package/dist/core/mcp/audit-transport.d.ts +16 -0
  36. package/dist/core/mcp/audit-transport.js +238 -0
  37. package/dist/core/mcp/server.d.ts +19 -0
  38. package/dist/core/mcp/server.js +207 -0
  39. package/dist/core/mcp/text.d.ts +14 -0
  40. package/dist/core/mcp/text.js +43 -0
  41. package/dist/core/mirror-view.d.ts +68 -0
  42. package/dist/core/mirror-view.js +367 -0
  43. package/dist/core/new-page.d.ts +52 -0
  44. package/dist/core/new-page.js +49 -0
  45. package/dist/core/policy.d.ts +10 -0
  46. package/dist/core/policy.js +30 -0
  47. package/dist/core/polish.d.ts +42 -0
  48. package/dist/core/polish.js +1000 -0
  49. package/dist/core/references.d.ts +52 -0
  50. package/dist/core/references.js +1242 -0
  51. package/dist/core/repo.d.ts +42 -0
  52. package/dist/core/repo.js +116 -0
  53. package/dist/core/schema.d.ts +46 -0
  54. package/dist/core/schema.js +49 -0
  55. package/dist/core/search.d.ts +24 -0
  56. package/dist/core/search.js +98 -0
  57. package/dist/core/serve/auth.d.ts +23 -0
  58. package/dist/core/serve/auth.js +38 -0
  59. package/dist/core/serve/server.d.ts +22 -0
  60. package/dist/core/serve/server.js +138 -0
  61. package/dist/core/serve/sessions.d.ts +39 -0
  62. package/dist/core/serve/sessions.js +130 -0
  63. package/dist/core/serve/token.d.ts +10 -0
  64. package/dist/core/serve/token.js +96 -0
  65. package/dist/core/service.d.ts +375 -0
  66. package/dist/core/service.js +815 -0
  67. package/dist/core/sla.d.ts +16 -0
  68. package/dist/core/sla.js +20 -0
  69. package/dist/core/stale.d.ts +16 -0
  70. package/dist/core/stale.js +23 -0
  71. package/dist/core/tool-registry.d.ts +31 -0
  72. package/dist/core/tool-registry.js +77 -0
  73. package/dist/core/url.d.ts +4 -0
  74. package/dist/core/url.js +31 -0
  75. package/dist/core/version.d.ts +22 -0
  76. package/dist/core/version.js +32 -0
  77. package/dist/core/writes.d.ts +58 -0
  78. package/dist/core/writes.js +113 -0
  79. package/dist/web/assets/index-BglLMIyN.css +1 -0
  80. package/dist/web/assets/index-DugFPoRu.js +25 -0
  81. package/dist/web/index.html +14 -0
  82. package/docs/format-spec.md +311 -0
  83. package/package.json +75 -4
  84. package/plugin/.claude-plugin/plugin.json +8 -0
  85. package/plugin/.mcp.json +8 -0
  86. package/plugin/bin/docsworth-mcp.mjs +46 -0
  87. package/plugin/skills/draft-runbook/SKILL.md +75 -0
  88. package/plugin/skills/polish-page/SKILL.md +64 -0
  89. package/plugin/skills/update-after-incident/SKILL.md +59 -0
  90. package/plugin/skills/verify-page/SKILL.md +46 -0
  91. package/templates/github/knowledge.yml +51 -0
@@ -0,0 +1,1242 @@
1
+ import { posix } from "node:path";
2
+ /**
3
+ * Where a text still names a page, read the way its readers read it. Link redaction
4
+ * (`redactLinks` in acl.ts) runs a precise pass first; this is the sweep after it, and it
5
+ * fails closed: any line that names a hidden page, in any form, is redacted whole.
6
+ *
7
+ * "Names" means a link destination, an HTML attribute, a URL or a path in text that
8
+ * resolves to the page, after the decoding a reader applies:
9
+ *
10
+ * - backslash escapes, and character references (`/`, `/`; numeric ones without
11
+ * their `;` too, as HTML reads them in attributes)
12
+ * - percent-encoding, all of it (`%2F` included)
13
+ * - `\` as `/`, as browsers read a relative URL
14
+ * - a line break or tab inside a value, which URL parsing drops, and the quote and list
15
+ * markers before a continuation line
16
+ *
17
+ * A destination always ends with the page's file name, so the sweep looks only where a
18
+ * hidden page's file name ends. From there it reads every place the reference could
19
+ * start, as CommonMark and HTML allow: after any character that can't be part of a path,
20
+ * back to whitespace (a plain destination holds none), and after the nearest `<`, `"`
21
+ * and `'` before that (angle destinations and quoted attributes may hold spaces). It
22
+ * redacts when any of those names a hidden page, either by resolving from the page as a
23
+ * link would, or, for a URL or a path that resolves nowhere, by ending in a hidden page's
24
+ * repo path, or its path under the knowledge root (two segments or more). The longest
25
+ * such tail decides, so a URL to a visible `team/runbooks/x.md` isn't taken for a hidden
26
+ * `runbooks/x.md`.
27
+ *
28
+ * Paths are folded before they're compared (`fold`): case, Unicode composition and
29
+ * non-ASCII runs. A visible page whose path differs from a hidden one only there is
30
+ * redacted as if hidden.
31
+ *
32
+ * Linear: each file name ending is read back at most REACH characters (past that, the line
33
+ * is redacted without resolving), and what a text repeats is resolved once (`Memo`).
34
+ *
35
+ * A link to a path under the knowledge root that is no page (one that didn't load, or no
36
+ * file at all) has no name to look for, so a second pass reads every link destination,
37
+ * in any form, and refuses it as the precise pass does (`destinationLines`).
38
+ */
39
+ /**
40
+ * Stands for a run of non-ASCII characters, with the ASCII letter or digit a combining
41
+ * mark sits on: one SUB however the run is written (composed or not, an astral character,
42
+ * or a character reference this doesn't decode, which may stand for several). A path runs
43
+ * through it (`café.md`), and one may also start right after it (a curly quote or a
44
+ * no-break space before a path).
45
+ */
46
+ const SUB = "\u001a";
47
+ // A combining mark: part of the character before it (`e` and U+0301 are `é`).
48
+ const MARK = /^\p{M}$/u;
49
+ // What a combining mark joins: an ASCII letter or digit.
50
+ const BASE = /^[A-Za-z0-9]$/;
51
+ /** Appends a non-ASCII character to `out`, as `fold` reads it. */
52
+ function foldWide(out, ch) {
53
+ const last = out.length - 1;
54
+ if (last >= 0 && out[last] === SUB)
55
+ return; // the run goes on
56
+ if (MARK.test(ch) && last >= 0 && BASE.test(out[last]))
57
+ out[last] = SUB;
58
+ else
59
+ out.push(SUB);
60
+ }
61
+ /**
62
+ * A text as the sweep compares it: NFC, ASCII letters in lower case (so a path differing
63
+ * only in case, which a case-insensitive file system opens, matches), and each run of
64
+ * other characters as one SUB. So `café` matches whether it's composed, decomposed, or
65
+ * encoded either way. Page paths and texts are both read through this, and a visible
66
+ * page whose path differs from a hidden one only inside such a run is taken for it.
67
+ */
68
+ // Inline markup a renderer removes, where it pairs as a renderer pairs it: emphasis,
69
+ // strikethrough, code and link text. `knowledge/team/**secret.md**` shows
70
+ // `knowledge/team/secret.md`; a lone `_` (`_secret.md`) shows as written, and so does
71
+ // `_` between letters or digits (`on_call`), which is never emphasis. Not escaped ones.
72
+ // Spans are bounded, so a line of unpaired markers stays linear.
73
+ // A code span may hold spaces at its edges (`` on` call.md` `` shows `on call.md`);
74
+ // CommonMark strips one from each end when both have one.
75
+ // Each marker run is matched whole (no marker before or after it), so a long run isn't
76
+ // retried at every shorter length: a line of 64 KB of backticks took 1.7 s that way.
77
+ 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;
78
+ const codeText = (c) => (/^ [\s\S]* $/.test(c) && /\S/.test(c) ? c.slice(1, -1) : c);
79
+ /** The text with paired markup gone, to a nesting of a few levels (`**_x_**`). */
80
+ function unmark(s) {
81
+ for (let i = 0; i < 4; i++) {
82
+ const next = s.replace(PAIRED, (_m, _a, code, _c, star, _e, tilde, _g, under, link) => code !== undefined ? codeText(code) : (star ?? tilde ?? under ?? link ?? ""));
83
+ if (next === s)
84
+ break;
85
+ s = next;
86
+ }
87
+ return s;
88
+ }
89
+ // Syntax that renders as nothing: inline tags, comments (`team/<!--x-->secret.md` shows
90
+ // `team/secret.md`), processing instructions and declarations, on one line; a link's
91
+ // `](destination "title")` or `][label]`, and an image's `!` (`[team/sec](x)ret.md`).
92
+ // Invisible format characters too: a soft hyphen or a zero-width space inside a path.
93
+ const INVISIBLE = /\p{Cf}/gu;
94
+ // What renders as nothing, read left to right as CommonMark reads it, so the leftmost
95
+ // wins: a `<!--` inside a tag's quoted value is part of the tag, and a `<![CDATA[` inside a
96
+ // comment part of the comment. First the openers of comments, processing instructions,
97
+ // CDATA and declarations (`unshown` finds where each ends); then a tag, whose quoted value
98
+ // may hold `<` or `>` (`<span title="a<b">`), and any other `<…>` that starts like one. Each
99
+ // attribute starts after whitespace with a name character, so the attribute list can't
100
+ // backtrack against the trailing space.
101
+ 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;
102
+ /**
103
+ * `fold`, as rendered text: paired markup gone first (page paths are read the same way),
104
+ * so a run of non-ASCII characters split by markup is one run.
105
+ */
106
+ const foldRendered = (s) => fold(unmark(s.replace(INVISIBLE, "")));
107
+ /**
108
+ * A line as it shows: what renders as nothing gone (a link's tail leaves its `]`, so the
109
+ * text pairs), then paired markup. A comment, processing instruction, CDATA section or
110
+ * declaration goes however long it is (merge gate, PR 13: one over a bound of 999 was
111
+ * kept, and its spaces cut the path in two), and only where CommonMark reads one: one with
112
+ * no end is text. Where it ends: CommonMark's end, or with `shortest`, a browser's, which
113
+ * can be sooner (a comment at `--!>`, a processing instruction or CDATA, bogus comments in
114
+ * HTML, at its first `>`), so the rest shows. Linear: each end is found by `indexOf`, and
115
+ * reused while the scan hasn't passed it.
116
+ */
117
+ function unshown(line, shortest = false) {
118
+ const l = line.replace(INVISIBLE, "");
119
+ const found = new Map();
120
+ const find = (needle, from) => {
121
+ const seen = found.get(needle);
122
+ if (seen && from >= seen[0] && (seen[1] < 0 || from <= seen[1]))
123
+ return seen[1];
124
+ const at = l.indexOf(needle, from);
125
+ found.set(needle, [from, at]);
126
+ return at;
127
+ };
128
+ let out = "";
129
+ let at = 0;
130
+ UNSHOWN.lastIndex = 0;
131
+ for (let m; (m = UNSHOWN.exec(l));) {
132
+ const i = m.index;
133
+ let end = i + m[0].length;
134
+ if (m[0].startsWith("<!") || m[0] === "<?") {
135
+ end = spanEnd(m[0], i, find, shortest);
136
+ if (end < 0) {
137
+ UNSHOWN.lastIndex = i + 1; // CommonMark reads it as text
138
+ continue;
139
+ }
140
+ UNSHOWN.lastIndex = end;
141
+ }
142
+ out += l.slice(at, i) + (m[0].startsWith("]") ? "]" : "");
143
+ at = end;
144
+ }
145
+ return unmark(out + l.slice(at));
146
+ }
147
+ /** Where the span `open` starts at `i` ends (see `unshown`), or -1 if it has no end. */
148
+ function spanEnd(open, i, find, shortest) {
149
+ // `<!-->` and `<!--->` are whole comments, so `-->` is looked for from the opener's `--`.
150
+ const [close, from] = open === "<!--" ? ["-->", i + 2] : open === "<?" ? ["?>", i + 2] : open === "<![CDATA[" ? ["]]>", i + 9] : [">", i + 2];
151
+ const j = find(close, from);
152
+ if (j < 0 || !shortest || close === ">")
153
+ return j < 0 ? -1 : j + close.length;
154
+ if (open !== "<!--")
155
+ return find(">", i + 2) + 1;
156
+ const k = find("--!>", i + 4);
157
+ return k >= 0 && k < j ? k + 4 : j + 3;
158
+ }
159
+ /** Could a browser end a span sooner than CommonMark does in `text`? Then the sweep reads both. */
160
+ const SHORTER = /<\?|<!\[CDATA\[|--!>/;
161
+ function fold(s, lower = true) {
162
+ if (/^[\x00-\x7f]*$/.test(s))
163
+ return lower ? s.toLowerCase() : s; // most lines: nothing else to do
164
+ const out = [];
165
+ for (const ch of s.normalize("NFC")) {
166
+ if (ch <= "\x7f")
167
+ out.push(lower ? ch.toLowerCase() : ch);
168
+ else
169
+ foldWide(out, ch);
170
+ }
171
+ return out.join("");
172
+ }
173
+ /**
174
+ * HTML's named character references that hold an ASCII character: all 49 of them. Every
175
+ * other name stands for non-ASCII characters only. test/references.test.ts checks this
176
+ * table against the `entities` package.
177
+ */
178
+ export const ASCII_REFERENCES = {
179
+ amp: "&", AMP: "&", apos: "'", ast: "*", midast: "*", bsol: "\\", colon: ":", comma: ",", commat: "@",
180
+ DiacriticalGrave: "`", grave: "`", dollar: "$", equals: "=", excl: "!", fjlig: "fj", gt: ">", GT: ">",
181
+ Hat: "^", lbrace: "{", lcub: "{", lbrack: "[", lsqb: "[", lowbar: "_", UnderBar: "_", lpar: "(",
182
+ lt: "<", LT: "<", NewLine: "\n", num: "#", percnt: "%", period: ".", plus: "+", quest: "?",
183
+ quot: '"', QUOT: '"', rbrace: "}", rcub: "}", rbrack: "]", rsqb: "]", rpar: ")", semi: ";",
184
+ sol: "/", Tab: "\t", verbar: "|", vert: "|", VerticalLine: "|",
185
+ bne: `=${SUB}`, nvgt: `>${SUB}`, nvlt: `<${SUB}`,
186
+ };
187
+ /**
188
+ * HTML's named references to invisible format characters only (soft hyphen, zero-width
189
+ * space and joiners, word joiner, bidi marks): all 17. The rendered view drops them.
190
+ * test/references.test.ts checks this list against the `entities` package.
191
+ */
192
+ export const INVISIBLE_REFERENCES = new Set([
193
+ "af", "ApplyFunction", "ic", "InvisibleComma", "InvisibleTimes", "it", "lrm", "NegativeMediumSpace", "NegativeThickSpace",
194
+ "NegativeThinSpace", "NegativeVeryThinSpace", "NoBreak", "rlm", "shy", "ZeroWidthSpace", "zwj", "zwnj",
195
+ ]);
196
+ /** A reference as HTML reads one in an attribute: numeric ones with or without `;`. */
197
+ const REFERENCE = /&(?:#(\d+);?|#[xX]([0-9a-fA-F]+);?|([A-Za-z][A-Za-z0-9]{0,31});)/y;
198
+ const PUNCT = /[!-/:-@[-`{-~]/;
199
+ const HEX = /^[0-9a-fA-F]$/;
200
+ // Quote and list markers before a line's content.
201
+ const CONTAINER = /^(?:[ \t]*(?:>|[-*+](?=[ \t]|$)|\d{1,9}[.)](?=[ \t]|$)))*[ \t]*/;
202
+ // Characters a path runs through. Anything else may come right before one starts.
203
+ const PATH_CHAR = /[A-Za-z0-9._~%/\-\u001a]/;
204
+ // The same as a table, for the loops: the views hold only ASCII and SUB.
205
+ const PATH_CODE = new Uint8Array(128);
206
+ for (let c = 0; c < 128; c++)
207
+ PATH_CODE[c] = PATH_CHAR.test(String.fromCharCode(c)) ? 1 : 0;
208
+ /** Where a path may start after this character: anything a path can't hold, and SUB. */
209
+ const startsAfter = (D, k) => {
210
+ const c = D.charCodeAt(k);
211
+ return c === 0x1a || !PATH_CODE[c];
212
+ };
213
+ /** Whether `v` holds, as written, a character no URL holds, between `i` and `j`. */
214
+ const writtenDelimiter = (v, i, j) => {
215
+ for (let k = i; k < j; k++)
216
+ if (v.raw(k) && " \t\n\r\f\v\"'<>[]`".includes(v.text[k]))
217
+ return true;
218
+ return false;
219
+ };
220
+ /** Whitespace as written (not decoded from something else): it ends a plain path. */
221
+ const writtenSpace = (v, k) => {
222
+ const c = v.text.charCodeAt(k);
223
+ return (c === 32 || (c >= 9 && c <= 13)) && v.raw(k);
224
+ };
225
+ /**
226
+ * Whether a `..` in a candidate cancels a segment holding a delimiter: whitespace or one
227
+ * of the characters markdown and HTML put before a destination (a quote, `<`, `(`, `]`,
228
+ * `=`, `:`, a backtick...). A start too early leaves such segments in front
229
+ * (`"../../x.md`, or from a quote two lines up, `>see</a>text \`zz/../../x.md`), and
230
+ * cancelling them would resolve the rest too deep. Such a start is read second
231
+ * (`namesHidden`): only when no other start resolves to a page, since a directory may
232
+ * hold a delimiter of its own (`(draft)/../../x.md`, `../scratch folder/../x.md`).
233
+ */
234
+ const DELIMITER = /[\s"'<>()[\]{}=:`*|]/;
235
+ function cancelsJunk(token) {
236
+ const kept = []; // per segment kept so far: does it hold a delimiter?
237
+ for (const seg of token.split("/")) {
238
+ if (seg === "" || seg === ".")
239
+ continue;
240
+ if (seg === "..") {
241
+ if (kept.pop())
242
+ return true;
243
+ continue;
244
+ }
245
+ kept.push(DELIMITER.test(seg));
246
+ }
247
+ return false;
248
+ }
249
+ const REACH = 512;
250
+ const WORDS_BACK = 2;
251
+ export function pageNames(repo, hidden) {
252
+ return { ...buildNames(repo, hidden, fold), rendered: buildNames(repo, hidden, foldRendered) };
253
+ }
254
+ function buildNames(repo, hidden, fold) {
255
+ const root = fold(repo.config.knowledge_root.replace(/\/+$/, ""));
256
+ const paths = new Map();
257
+ const under = new Map();
258
+ const names = new Map();
259
+ const spelled = new Map();
260
+ const spelledUnder = new Map();
261
+ let depth = 0;
262
+ // Pages that didn't load count as hidden: their ACL can't be read.
263
+ const all = [...repo.pages.map((p) => [p.path, hidden(p)]), ...repo.invalid.map((p) => [p.path, true])];
264
+ for (const [path, isHidden] of all) {
265
+ const key = fold(path);
266
+ paths.set(key, (paths.get(key) ?? false) || isHidden);
267
+ const rel = key.startsWith(`${root}/`) ? key.slice(root.length + 1) : key;
268
+ if (rel.includes("/"))
269
+ under.set(rel, (under.get(rel) ?? false) || isHidden);
270
+ depth = Math.max(depth, key.split("/").length);
271
+ if (!isHidden)
272
+ continue;
273
+ const name = posix.basename(key);
274
+ if (!names.has(name.length))
275
+ names.set(name.length, new Set());
276
+ names.get(name.length).add(name);
277
+ if (!spelled.has(name))
278
+ spelled.set(name, []);
279
+ spelled.get(name).push(key);
280
+ if (!spelledUnder.has(name))
281
+ spelledUnder.set(name, []);
282
+ if (rel !== key && rel.includes("/"))
283
+ spelledUnder.get(name).push(rel);
284
+ }
285
+ return { root, paths, under, names, spelled, spelledUnder, depth, repoUrls: repoUrls(repo) };
286
+ }
287
+ /** The knowledge repo's web and raw URL prefixes, when `github.repo` says where it is. */
288
+ function repoUrls(repo) {
289
+ const name = repo.config.github.repo?.toLowerCase();
290
+ if (!name)
291
+ return [];
292
+ const api = repo.config.github.api_url;
293
+ // The host name alone: a URL's port is read past (`hostAndPath`), so this one is too.
294
+ const host = api ? new URL(api).hostname.toLowerCase() : "github.com";
295
+ if (host === "github.com" || host === "api.github.com")
296
+ return [`github.com/${name}/`, `raw.githubusercontent.com/${name}/`];
297
+ return [`${host}/${name}/`, `${host}/raw/${name}/`]; // GitHub Enterprise Server
298
+ }
299
+ const chars = () => ({ c: [], line: [], raw: [] });
300
+ const put = (into, s, at, asWritten) => {
301
+ for (const c of s) {
302
+ into.c.push(c);
303
+ into.line.push(at);
304
+ into.raw.push(asWritten);
305
+ }
306
+ };
307
+ /** A non-ASCII character (or an undecoded reference), as `fold` reads it, on line `at`. */
308
+ // Across lines too: the joined view reads a value broken mid-character as one.
309
+ const putWide = (into, ch, at) => {
310
+ const last = into.c.length - 1;
311
+ if (last >= 0 && into.c[last] === SUB)
312
+ return; // the run goes on
313
+ if (MARK.test(ch) && last >= 0 && BASE.test(into.c[last])) {
314
+ into.c[last] = SUB;
315
+ into.raw[last] = false;
316
+ }
317
+ else
318
+ put(into, SUB, at, false);
319
+ };
320
+ /** A decoded reference: ASCII as is, SUB (a non-ASCII part) through `putWide`. */
321
+ // A `\` decoded from a reference (`&bsol;`, `&#92;`) is a `/` to a browser too.
322
+ const putDecoded = (into, s, at) => {
323
+ for (const c of s)
324
+ if (c === SUB)
325
+ putWide(into, "\u00e9", at);
326
+ else
327
+ put(into, c === "\\" ? "/" : c, at, false);
328
+ };
329
+ /**
330
+ * Character references decoded, as HTML reads them, and with `escapes` backslash escapes
331
+ * too, as CommonMark reads them outside raw HTML. Non-ASCII characters (and references to
332
+ * them) become SUB. Any other `\` is a `/`, as a browser reads it in a relative URL: in
333
+ * raw HTML, `..\..\x.md` is `../../x.md`, where CommonMark reads `\.` as an escaped `.`.
334
+ */
335
+ function unescapeInto(l, at, into, escapes, rendered = false) {
336
+ for (let i = 0; i < l.length;) {
337
+ const c = l[i];
338
+ if (c === "\\") {
339
+ const next = l[i + 1];
340
+ if (escapes && next !== undefined && PUNCT.test(next) && next !== "\\") {
341
+ put(into, next, at, false);
342
+ i += 2;
343
+ }
344
+ else {
345
+ put(into, "/", at, false);
346
+ i += escapes && next === "\\" ? 2 : 1;
347
+ }
348
+ continue;
349
+ }
350
+ if (c === "&") {
351
+ REFERENCE.lastIndex = i;
352
+ const m = REFERENCE.exec(l);
353
+ if (m) {
354
+ const code = m[1] !== undefined ? Number(m[1]) : m[2] !== undefined ? Number.parseInt(m[2], 16) : undefined;
355
+ // Rendered, a reference to an invisible character shows nothing.
356
+ if (rendered && (code === undefined ? INVISIBLE_REFERENCES.has(m[3]) : code <= 0x10ffff && /^\p{Cf}$/u.test(String.fromCodePoint(code)))) {
357
+ i = REFERENCE.lastIndex;
358
+ continue;
359
+ }
360
+ if (code === undefined)
361
+ putDecoded(into, ASCII_REFERENCES[m[3]] ?? SUB, at);
362
+ else if (code > 0 && code < 0x80)
363
+ putDecoded(into, String.fromCharCode(code), at);
364
+ else
365
+ putWide(into, code <= 0x10ffff ? String.fromCodePoint(code) : "\ufffd", at);
366
+ i = REFERENCE.lastIndex;
367
+ continue;
368
+ }
369
+ }
370
+ if (c > "\x7f") {
371
+ const ch = String.fromCodePoint(l.codePointAt(i));
372
+ putWide(into, ch, at);
373
+ i += ch.length;
374
+ continue;
375
+ }
376
+ put(into, c, at, true);
377
+ i++;
378
+ }
379
+ }
380
+ /**
381
+ * Percent-encoding, decoded after the references (`&#37;2F` is `%2F`, then `/`): all of
382
+ * it, `%2F` included. A non-ASCII character's bytes become one SUB. With `dropBreaks`,
383
+ * line breaks and tabs go, as URL parsing drops them from a value.
384
+ */
385
+ function percentInto(written, into, dropBreaks) {
386
+ const keep = (c) => !dropBreaks || (c !== "\n" && c !== "\t" && c !== "\r");
387
+ // A URL parser drops line breaks and tabs before it decodes: `%2`, a break, `E` is `.`.
388
+ let from = written;
389
+ if (dropBreaks && written.c.some((c) => !keep(c))) {
390
+ from = chars();
391
+ for (let k = 0; k < written.c.length; k++)
392
+ if (keep(written.c[k]))
393
+ put(from, written.c[k], written.line[k], written.raw[k]);
394
+ }
395
+ for (let k = 0; k < from.c.length;) {
396
+ if (from.c[k] === "%" && HEX.test(from.c[k + 1] ?? "") && HEX.test(from.c[k + 2] ?? "")) {
397
+ const byte = Number.parseInt(from.c[k + 1] + from.c[k + 2], 16);
398
+ const at = from.line[k];
399
+ k += 3;
400
+ if (byte < 0x80) {
401
+ const c = byte === 0x5c ? "/" : String.fromCharCode(byte);
402
+ if (keep(c))
403
+ put(into, c, at, false);
404
+ continue;
405
+ }
406
+ // A UTF-8 sequence: its lead byte and the continuation bytes after it, as one
407
+ // character (a stray continuation byte stands alone, as an invalid one).
408
+ const bytes = [byte];
409
+ while (byte >= 0xc0 && bytes.length < 4 && from.c[k] === "%" && /^[89ab]$/i.test(from.c[k + 1] ?? "") && HEX.test(from.c[k + 2] ?? "")) {
410
+ bytes.push(Number.parseInt(from.c[k + 1] + from.c[k + 2], 16));
411
+ k += 3;
412
+ }
413
+ putWide(into, [...Buffer.from(bytes).toString("utf8")][0], at);
414
+ continue;
415
+ }
416
+ if (keep(from.c[k]))
417
+ put(into, from.c[k], from.line[k], from.raw[k]);
418
+ k++;
419
+ }
420
+ }
421
+ /**
422
+ * The text as a reader resolves it, with or without CommonMark's escapes, each line past
423
+ * its container markers. As prose: a line break is a space (markdown renders a soft break
424
+ * as one, so a path with spaces wrapped at one still reads whole). Or joined: line breaks
425
+ * and tabs (written or decoded) are dropped, as URL parsing drops them from a value.
426
+ */
427
+ function readerView(lines, joined, escapes, rendered = false, markers = false) {
428
+ const first = chars();
429
+ lines.forEach((whole, at) => {
430
+ const content = markers ? whole : whole.slice(CONTAINER.exec(whole)[0].length);
431
+ unescapeInto((rendered ? rendered(content) : content).normalize("NFC"), at, first, escapes, !!rendered);
432
+ if (!joined)
433
+ put(first, " ", at, true);
434
+ });
435
+ const out = chars();
436
+ percentInto(first, out, joined);
437
+ // Lower case only changes ASCII letters here: offsets stay put.
438
+ const cased = out.c.join("");
439
+ return { text: cased.toLowerCase(), cased, lineOf: (k) => out.line[k], raw: (k) => out.raw[k] };
440
+ }
441
+ /**
442
+ * The same view for a text with nothing to decode (no `&`, `%` or `\`):
443
+ * the text as written, non-ASCII as SUB, built in one pass. Most pages are like this.
444
+ */
445
+ function plainView(lines, joined, rendered = false, markers = false) {
446
+ let parts = lines
447
+ .map((l) => (markers ? l : l.slice(CONTAINER.exec(l)[0].length)))
448
+ .map((l) => (rendered ? fold(rendered(l), false) : fold(joined ? l.replace(/[\t\r]/g, "") : l, false)));
449
+ if (joined) {
450
+ // A run of non-ASCII characters broken across lines is one run once they're joined.
451
+ let lastSub = false;
452
+ parts = parts.map((p) => {
453
+ const q = lastSub && p.startsWith(SUB) ? p.slice(1) : p;
454
+ if (q.length)
455
+ lastSub = q.endsWith(SUB);
456
+ return q;
457
+ });
458
+ }
459
+ const starts = [];
460
+ let at = 0;
461
+ for (const p of parts) {
462
+ starts.push(at);
463
+ at += p.length + (joined ? 0 : 1);
464
+ }
465
+ const lineOf = (k) => {
466
+ let lo = 0;
467
+ let hi = starts.length - 1;
468
+ while (lo < hi) {
469
+ const mid = (lo + hi + 1) >> 1;
470
+ if (starts[mid] <= k)
471
+ lo = mid;
472
+ else
473
+ hi = mid - 1;
474
+ }
475
+ return lo;
476
+ };
477
+ // Lower case only changes ASCII letters here: offsets stay put.
478
+ const cased = parts.join(joined ? "" : " ");
479
+ return { text: cased.toLowerCase(), cased, lineOf, raw: () => true };
480
+ }
481
+ // Anything a reader might decode. Any `&` or `%`, not only a whole reference or `%XX`:
482
+ // joining lines can complete one (`index%2` then `emd` on the next line).
483
+ const ENCODED = /[&%\\]/;
484
+ /**
485
+ * A link destination as the URL it becomes: references (and, in markdown, escapes)
486
+ * decoded, query and fragment cut, then percent-decoded (non-ASCII as SUB).
487
+ */
488
+ function readDestination(dest, html) {
489
+ const first = chars();
490
+ unescapeInto(dest.normalize("NFC"), 0, first, !html);
491
+ const cut = first.c.findIndex((c) => c === "?" || c === "#");
492
+ if (cut >= 0)
493
+ for (const a of [first.c, first.line, first.raw])
494
+ a.length = cut;
495
+ const out = chars();
496
+ percentInto(first, out, true);
497
+ return out.c.join("").toLowerCase().trim();
498
+ }
499
+ /**
500
+ * Whether a link destination, as written on page `from`, leads to a hidden page: one
501
+ * that resolves to it, or a URL that ends with its path. A path under the knowledge root
502
+ * that is no loaded page fails closed: a page that didn't load has no trustworthy ACL.
503
+ * `html` for an attribute in raw HTML, where backslash escapes don't apply.
504
+ */
505
+ export function destinationHides(dest, from, names, html = false) {
506
+ let d = dest.trim();
507
+ if (!html && d.startsWith("<") && d.endsWith(">"))
508
+ d = d.slice(1, -1);
509
+ const path = readDestination(d, html);
510
+ if (!path)
511
+ return false;
512
+ const fromKey = fold(from);
513
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(path))
514
+ return namesHidden([path], fromKey, names) || namesNoPage(decodeText(d, !html), names, !html);
515
+ const resolved = path.startsWith("/") ? posix.normalize(path.slice(1)) : posix.normalize(posix.join(posix.dirname(fromKey), path));
516
+ const known = names.paths.get(resolved);
517
+ if (known !== undefined)
518
+ return known;
519
+ return (resolved.startsWith(`${names.root}/`) && resolved.endsWith(".md")) || namesHidden([path], fromKey, names);
520
+ }
521
+ /**
522
+ * The lines of `text` holding a link destination that `destinationHides` refuses, read
523
+ * wherever a destination can stand, whatever surrounds it: after `](` (an inline link or
524
+ * image, whatever its text), after `]:` (a definition in any container, its destination
525
+ * on the next line or not), and in a tag's URL attributes, across lines. The name sweep
526
+ * finds only pages that exist; this is how a path under the knowledge root that is no
527
+ * page fails closed in every form, as it does in the common ones (merge gate, PR 13:
528
+ * `[a [b] c](../security/not-loaded.md)` was kept).
529
+ */
530
+ function destinationLines(text, from, names, bases = []) {
531
+ const marked = new Set();
532
+ for (const d of destinations(text)) {
533
+ // As written, and as a browser resolves it against each `<base href>` (merge gate, PR
534
+ // 13: `<base href="../security/">` then `<a href="x.md">` is the hidden page's link).
535
+ const hides = destinationHides(d.dest, from, names, d.html) || bases.some((b) => {
536
+ const r = againstBase(b, d.dest, d.html, from);
537
+ return r !== undefined && destinationHides(r, from, names, true);
538
+ });
539
+ if (hides)
540
+ for (let l = d.lines[0]; l <= d.lines[1]; l++)
541
+ marked.add(l);
542
+ }
543
+ return marked;
544
+ }
545
+ /** Every destination in `text`, wherever one can stand (see `destinationLines`). */
546
+ function destinations(text) {
547
+ const out = [];
548
+ if (!text.includes("](") && !text.includes("]:") && !/<[A-Za-z]/.test(text))
549
+ return out;
550
+ for (const { t, starts, raw } of joins(text.split("\n"))) {
551
+ const span = (i, j) => [lineAt(starts, i), lineAt(starts, Math.max(i, j - 1))];
552
+ for (const opener of raw ? [] : ["](", "]:"]) {
553
+ for (let i = t.indexOf(opener); i >= 0;) {
554
+ // Read no further than the next opener, so no stretch is read twice.
555
+ const next = t.indexOf(opener, i + 2);
556
+ const [s, e] = destinationAt(t, i + 2, next < 0 ? t.length : next, opener === "](");
557
+ if (e > s)
558
+ out.push({ dest: t.slice(s, e), html: false, lines: span(i, e) });
559
+ i = next;
560
+ }
561
+ }
562
+ for (const tag of tagsIn(t)) {
563
+ for (const [name, value] of tag.attributes) {
564
+ // A refresh's `content` navigates: `0; url=…`.
565
+ const refresh = name === "content" && tag.name === "meta" ? REFRESH.exec(value) : null;
566
+ if (!refresh && !URL_ATTRIBUTES.has(name))
567
+ continue;
568
+ for (const u of refresh ? [refresh[1]] : urlsIn(name, value))
569
+ out.push({ dest: u, html: true, lines: span(tag.at, tag.end) });
570
+ }
571
+ }
572
+ }
573
+ return out;
574
+ }
575
+ /** The `<base href>`s in `text`, as written: each moves what a relative URL resolves against. */
576
+ function basesIn(text) {
577
+ if (!/<base/i.test(text))
578
+ return [];
579
+ const out = new Set();
580
+ for (const { t } of joins(text.split("\n")))
581
+ for (const tag of tagsIn(t))
582
+ if (tag.name === "base")
583
+ for (const [name, value] of tag.attributes)
584
+ if (name === "href")
585
+ out.add(value);
586
+ return [...out];
587
+ }
588
+ // Where a repo page sits, as a URL: what a `<base href>` is resolved against.
589
+ const PAGE_ORIGIN = "https://page.invalid";
590
+ /**
591
+ * `dest` as a browser resolves it with `base` (an attribute's value, as written) in force,
592
+ * on page `from`: a path in the repo as `/path`, or a URL elsewhere; undefined if it isn't
593
+ * one. Parsed by the platform's URL parser, as a browser parses it.
594
+ */
595
+ function againstBase(base, dest, html, from) {
596
+ const plain = (s, escapes) => decodeText(s.trim(), escapes);
597
+ let d = dest.trim();
598
+ if (!html && d.startsWith("<") && d.endsWith(">"))
599
+ d = d.slice(1, -1);
600
+ try {
601
+ const at = new URL(plain(d, !html), new URL(plain(base, false), new URL(from, `${PAGE_ORIGIN}/`)));
602
+ return at.origin === PAGE_ORIGIN ? at.pathname : at.href;
603
+ }
604
+ catch {
605
+ return undefined;
606
+ }
607
+ }
608
+ /**
609
+ * For each line inside a raw HTML block, the block's first line's container markers (a
610
+ * later line is read past them, as written); undefined elsewhere.
611
+ * The starts and ends are CommonMark's (4.6), except that a lone tag (kind 7) is taken to
612
+ * start one even where it can't interrupt a paragraph: that only reads more as written.
613
+ */
614
+ function htmlBlockPrefixes(lines) {
615
+ const out = [];
616
+ let end; // the open block's end, or /^$/ for "a blank line"
617
+ let prefix = "";
618
+ for (const l of lines) {
619
+ if (end) {
620
+ // Inside, only the block's own container markers are markers: the rest is HTML (a
621
+ // `> ` is no blank line there). A line that leaves the container ends the block.
622
+ const inside = l.startsWith(prefix) ? l.slice(prefix.length) : !prefix.includes(">") && /^[ \t]*$/.test(l.slice(0, prefix.length)) ? l.slice(prefix.length) : undefined;
623
+ if (inside === undefined || (end.source === "^$" && !inside.trim()))
624
+ end = undefined;
625
+ else {
626
+ out.push(prefix);
627
+ if (end.source !== "^$" && end.test(inside))
628
+ end = undefined;
629
+ continue;
630
+ }
631
+ }
632
+ const p = CONTAINER.exec(l)[0];
633
+ const content = l.slice(p.length);
634
+ const kind = HTML_BLOCK_STARTS.find(([start]) => start.test(content));
635
+ out.push(kind ? p : undefined);
636
+ if (!kind)
637
+ continue;
638
+ prefix = p;
639
+ end = kind[1].source !== "^$" && kind[1].test(content) ? undefined : kind[1];
640
+ }
641
+ return out;
642
+ }
643
+ 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";
644
+ // Each kind of raw HTML block: how it starts, on a line past its container markers, and
645
+ // how it ends (on the line that holds the end, or, for /^$/, before a blank line).
646
+ const HTML_BLOCK_STARTS = [
647
+ [/^ {0,3}<(?:script|pre|style|textarea)(?:\s|>|$)/i, /<\/(?:script|pre|style|textarea)>/i],
648
+ [/^ {0,3}<!--/, /-->/],
649
+ [/^ {0,3}<\?/, /\?>/],
650
+ [/^ {0,3}<![A-Za-z]/, />/],
651
+ [/^ {0,3}<!\[CDATA\[/, /\]\]>/],
652
+ [new RegExp(`^ {0,3}<\\/?(?:${BLOCK_TAGS})(?:\\s|\\/?>|$)`, "i"), /^$/],
653
+ [/^ {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*$/, /^$/],
654
+ ];
655
+ /**
656
+ * The text joined as HTML reads a value broken across lines: with each line's container
657
+ * markers dropped, as CommonMark drops them in a paragraph; and, when there's a raw HTML
658
+ * block, again with its lines as written past its first line's markers (`raw`), where the
659
+ * next line's quote marker or indent is part of the value (`<img …>`, then `> <a
660
+ * href="../x`, then `> y.md">`). Not as written elsewhere: in a paragraph that reading
661
+ * makes a path that is no page, and that fails closed.
662
+ */
663
+ function joins(lines) {
664
+ const block = htmlBlockPrefixes(lines);
665
+ return (block.some((p) => p !== undefined) ? [false, true] : [false]).map((raw) => {
666
+ const starts = [];
667
+ let t = "";
668
+ for (const [i, l] of lines.entries()) {
669
+ starts.push(t.length);
670
+ const p = raw ? block[i] : undefined;
671
+ 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;
672
+ t += `${l.slice(cut)}\n`;
673
+ }
674
+ return { t, starts, raw };
675
+ });
676
+ }
677
+ /**
678
+ * The lines of `text` holding an iframe whose `srcdoc` (decoded) `names`: a document a
679
+ * browser shows in its own right, so each sweep reads it as one (merge gate, PR 13: a
680
+ * path split by markup in it showed whole there, and the rendered view drops the tag).
681
+ */
682
+ function srcdocLines(text, depth, names) {
683
+ const marked = new Set();
684
+ if (!/srcdoc/i.test(text))
685
+ return marked;
686
+ // Each document once, however many readings find its tag (both tag readers, both joins).
687
+ const docs = new Map();
688
+ for (const { t, starts } of joins(text.split("\n"))) {
689
+ for (const tag of tagsIn(t)) {
690
+ for (const [name, value] of tag.attributes) {
691
+ if (name !== "srcdoc")
692
+ continue;
693
+ const doc = decoded(value);
694
+ if (!docs.has(doc))
695
+ docs.set(doc, []);
696
+ docs.get(doc).push([lineAt(starts, tag.at), lineAt(starts, tag.end - 1)]);
697
+ }
698
+ }
699
+ }
700
+ // Nested deeper than any page does, it fails closed: each level reads its document
701
+ // again (merge gate, PR 13).
702
+ for (const [doc, spans] of docs)
703
+ if (depth >= SRCDOC_DEPTH || names(doc))
704
+ for (const [a, b] of spans)
705
+ for (let l = a; l <= b; l++)
706
+ marked.add(l);
707
+ return marked;
708
+ }
709
+ const SRCDOC_DEPTH = 3;
710
+ /** The line holding offset `k`, given each line's start. */
711
+ function lineAt(starts, k) {
712
+ let lo = 0;
713
+ let hi = starts.length - 1;
714
+ while (lo < hi) {
715
+ const mid = (lo + hi + 1) >> 1;
716
+ if (starts[mid] <= k)
717
+ lo = mid;
718
+ else
719
+ hi = mid - 1;
720
+ }
721
+ return lo;
722
+ }
723
+ /**
724
+ * The destination after `](` or `]:` (at `at`), read no further than `end`, as [start,
725
+ * end): past spaces and at most one line break, then `<…>`, or up to whitespace (and in
726
+ * an inline link, a `)` that closes nothing).
727
+ */
728
+ function destinationAt(t, at, end, inline) {
729
+ let k = at;
730
+ while (k < end && (t[k] === " " || t[k] === "\t"))
731
+ k++;
732
+ if (k < end && t[k] === "\n")
733
+ k++;
734
+ while (k < end && (t[k] === " " || t[k] === "\t"))
735
+ k++;
736
+ const s = k;
737
+ if (t[k] === "<") {
738
+ for (k++; k < end && t[k] !== ">" && t[k] !== "<" && t[k] !== "\n"; k++)
739
+ if (t[k] === "\\")
740
+ k++;
741
+ return k < end && t[k] === ">" ? [s, k + 1] : [s, s];
742
+ }
743
+ let depth = 0;
744
+ for (; k < end; k++) {
745
+ const c = t.charCodeAt(k);
746
+ if (c <= 32)
747
+ break;
748
+ if (c === 92)
749
+ k++; // `\`
750
+ else if (inline && c === 40)
751
+ depth++; // `(`
752
+ else if (inline && c === 41 && depth-- === 0)
753
+ break; // `)`
754
+ }
755
+ return [s, Math.min(k, end)];
756
+ }
757
+ /**
758
+ * The tags in `t`, with their attributes: as CommonMark reads a tag (raw HTML in a
759
+ * paragraph), and as a browser's tokenizer reads them in order (in a raw HTML block a
760
+ * browser takes tags CommonMark wouldn't: `<a href="x" "y">`). The browser's reading
761
+ * stops at a tag still open at the end, as a browser does; CommonMark's reads on.
762
+ */
763
+ function tagsIn(t) {
764
+ const out = [];
765
+ const named = (s) => [...s.matchAll(ATTRIBUTE)].map((a) => [a[1].toLowerCase(), a[2] ?? a[3] ?? a[4]]);
766
+ for (const m of t.matchAll(TAG))
767
+ 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]) });
768
+ // A browser's tokenizer, simplified: a tag is `<`, a letter and a name, then
769
+ // attributes, each a name (to whitespace, `/`, `>` or `=`) and maybe `=` and a value,
770
+ // quoted (across lines, `>` included) or up to whitespace or `>`.
771
+ const space = (c) => c === " " || c === "\t" || c === "\n" || c === "\r" || c === "\f";
772
+ const open = /<[A-Za-z]/g;
773
+ for (let m = open.exec(t); m; m = open.exec(t)) {
774
+ const at = m.index;
775
+ let k = at + 1;
776
+ while (k < t.length && !space(t[k]) && t[k] !== "/" && t[k] !== ">")
777
+ k++;
778
+ const tag = { at, end: -1, name: t.slice(at + 1, k).toLowerCase(), attributes: [] };
779
+ while (k < t.length) {
780
+ while (k < t.length && (space(t[k]) || t[k] === "/"))
781
+ k++;
782
+ if (t[k] === ">") {
783
+ tag.end = k + 1;
784
+ break;
785
+ }
786
+ const n = k++;
787
+ while (k < t.length && !space(t[k]) && t[k] !== "/" && t[k] !== ">" && t[k] !== "=")
788
+ k++;
789
+ const name = t.slice(n, k).toLowerCase();
790
+ let j = k;
791
+ while (space(t[j]))
792
+ j++;
793
+ if (t[j] !== "=") {
794
+ tag.attributes.push([name, ""]);
795
+ continue;
796
+ }
797
+ for (j++; space(t[j]); j++)
798
+ ;
799
+ if (t[j] === '"' || t[j] === "'") {
800
+ const close = t.indexOf(t[j], j + 1);
801
+ if (close < 0)
802
+ break;
803
+ tag.attributes.push([name, t.slice(j + 1, close)]);
804
+ k = close + 1;
805
+ }
806
+ else {
807
+ for (k = j; k < t.length && !space(t[k]) && t[k] !== ">"; k++)
808
+ ;
809
+ tag.attributes.push([name, t.slice(j, k)]);
810
+ }
811
+ }
812
+ if (tag.end < 0)
813
+ break; // still open at the end: a browser drops it and reads no further
814
+ out.push(tag);
815
+ open.lastIndex = tag.end;
816
+ }
817
+ return out;
818
+ }
819
+ // A tag as CommonMark reads one (see UNSHOWN), and each of its attributes with its value.
820
+ const TAG = /<[A-Za-z][A-Za-z0-9-]*(?:\s+[A-Za-z_:][A-Za-z0-9_.:-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*\s*\/?>/g;
821
+ const ATTRIBUTE = /\s([A-Za-z_:][A-Za-z0-9_.:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g;
822
+ const REFRESH = /^\s*[\d.]*\s*[;,]\s*(?:url\s*=\s*)?["']?([^"'\s]*)/i;
823
+ /** An attribute's value with its character references decoded. */
824
+ function decoded(value) {
825
+ return decodeText(value, false);
826
+ }
827
+ /**
828
+ * The URLs an attribute's value holds: a list for some (`srcset` and `imagesrcset` by
829
+ * commas, each URL then a descriptor; `ping` and `archive` by whitespace), one otherwise.
830
+ */
831
+ function urlsIn(name, value) {
832
+ if (name === "srcset" || name === "imagesrcset")
833
+ return value.split(",").map((u) => u.trim().split(/\s+/)[0]);
834
+ if (name === "ping" || name === "archive")
835
+ return value.split(/\s+/).filter(Boolean);
836
+ return [value];
837
+ }
838
+ // The attributes HTML reads as a URL.
839
+ const URL_ATTRIBUTES = new Set(["href", "src", "srcset", "imagesrcset", "action", "formaction", "data", "poster", "cite", "background", "longdesc", "usemap", "ping", "archive", "codebase", "manifest", "icon", "xlink:href"]);
840
+ /**
841
+ * Whether a URL into the knowledge repo on GitHub (any branch; blob, tree, raw…) names a
842
+ * path under the knowledge root that is no page here: a page on another branch, say,
843
+ * which never loads, so its ACL can't be read. It fails closed as a relative link does.
844
+ * A loaded visible page the URL may also be read as settles it (a branch can be named
845
+ * `knowledge/…` too). A URL elsewhere can't be told from one to another site's page.
846
+ */
847
+ function namesNoPage(url, names, markdown = false) {
848
+ // A `\` is a `/` to a browser reading it as written (raw HTML, text), but a markdown
849
+ // destination's reaches the href as `%5C` (renderers percent-encode it), which a
850
+ // browser keeps: there each is a reading of its own.
851
+ return (markdown && url.includes("\\") ? [url, url.replace(/\\/g, "%5C")] : [url]).some((u) => namesNoPageAs(u, names));
852
+ }
853
+ function namesNoPageAs(url, names) {
854
+ const web = webUrl(url);
855
+ if (!web)
856
+ return false;
857
+ const u = fold(`${web.host}${web.path}`);
858
+ const prefix = names.repoUrls.find((p) => u.startsWith(p));
859
+ if (!prefix)
860
+ return false;
861
+ let noPage = false;
862
+ let readings = 0;
863
+ for (let at = u.indexOf(`/${names.root}/`, prefix.length - 1); at >= 0; at = u.indexOf(`/${names.root}/`, at + 1)) {
864
+ // Past a few readings it fails closed: no real branch name holds that many.
865
+ if (++readings > READINGS)
866
+ return u.endsWith(".md");
867
+ const path = posix.normalize(u.slice(at + 1));
868
+ if (names.paths.get(path) === false)
869
+ return false;
870
+ if (path.endsWith(".md"))
871
+ noPage = true;
872
+ }
873
+ return noPage;
874
+ }
875
+ const READINGS = 8;
876
+ /**
877
+ * A web URL's host and path as a browser reads them, by the platform's URL parser (merge
878
+ * gate, PR 13: a port, then a fullwidth `g`, got past a prefix): the host mapped as a
879
+ * browser maps it (to ASCII, lower case, percent-decoded), without userinfo, port, a
880
+ * trailing dot or `www.`; the path decoded, in lower case. Undefined if it isn't one.
881
+ * Without a scheme it's read as one written in text (`github.com/acme/…`).
882
+ */
883
+ function webUrl(url) {
884
+ // A URL parser drops tabs and line breaks first, wherever they are (`h` + break + `ttps:`).
885
+ const u = url.replace(/[\t\n\r]/g, "").trim();
886
+ try {
887
+ const at = new URL(/^[a-z][a-z0-9+.-]*:/i.test(u) ? u : `https://${u.replace(/^[\\/]*/, "")}`);
888
+ if (at.protocol !== "https:" && at.protocol !== "http:")
889
+ return undefined;
890
+ let path = at.pathname;
891
+ try {
892
+ path = decodeURIComponent(path);
893
+ }
894
+ catch {
895
+ /* a malformed escape: as written */
896
+ }
897
+ return { host: at.hostname.replace(/\.$/, "").replace(/^www\./, ""), path: path.toLowerCase() };
898
+ }
899
+ catch {
900
+ return undefined;
901
+ }
902
+ }
903
+ /** `s` with character references, and with `escapes` CommonMark's backslash escapes, decoded; non-ASCII kept, for a URL parser to read. */
904
+ function decodeText(s, escapes) {
905
+ return s.replace(/\\([!-/:-@[-`{-~])|&(?:#(\d+);?|#[xX]([0-9a-fA-F]+);?|([A-Za-z][A-Za-z0-9]{0,31});)/g, (m, esc, dec, hex, name) => {
906
+ if (esc !== undefined)
907
+ return escapes ? esc : m;
908
+ const code = dec !== undefined ? Number(dec) : hex !== undefined ? Number.parseInt(hex, 16) : undefined;
909
+ if (code !== undefined)
910
+ return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : "\ufffd";
911
+ // One for a non-ASCII character this doesn't know stands for it as `fold` does: SUB.
912
+ return ASCII_REFERENCES[name] ?? SUB;
913
+ });
914
+ }
915
+ /**
916
+ * The lines of `text` (read as prose, decoded) holding a GitHub URL into the knowledge
917
+ * repo that names no page (`namesNoPage`), however it's written: a bare URL, an autolink,
918
+ * in code. Destinations are `destinationLines`', but a URL needs no link around it.
919
+ */
920
+ function repoUrlLines(text, names) {
921
+ const marked = new Set();
922
+ if (!names.repoUrls.length)
923
+ return marked;
924
+ const lines = text.split("\n");
925
+ const block = text.includes("\\") ? htmlBlockPrefixes(lines) : [];
926
+ // Each line decoded (with CommonMark's escapes, but on a raw HTML block's lines without
927
+ // them, as HTML reads them there), and again as it renders (markup inside a URL is gone
928
+ // on screen), each with its paths' folding. Then every token that may be a URL is read
929
+ // by the URL parser, which maps a host as a browser does (`github.com`, `github。com`).
930
+ const readings = [[(l, i) => decodeText(l, block[i] === undefined), names]];
931
+ if (names.rendered)
932
+ for (const show of renderings(text))
933
+ readings.push([(l) => decodeText(show(l), true), names.rendered]);
934
+ for (const [read, folded] of readings) {
935
+ lines.forEach((l, i) => {
936
+ if (marked.has(i))
937
+ return;
938
+ // Not normalized here: the parser maps a host itself, and NFKC would change a path
939
+ // (`𝔄.md` is `A.md` to it).
940
+ for (const token of read(l, i).split(/[\s"'<>`|[\]()]+/)) {
941
+ if (!urlish(token))
942
+ continue;
943
+ if (namesNoPage(token.replace(/[).,;:!*_~]+$/, ""), folded)) {
944
+ marked.add(i);
945
+ break;
946
+ }
947
+ }
948
+ });
949
+ }
950
+ return marked;
951
+ }
952
+ /**
953
+ * Whether a token may be a URL with a path, for the URL parser to read: it has a scheme,
954
+ * or its first part (past any slashes, as in `//github.com/…`) looks like a host and a
955
+ * slash follows. Linear: a regex that tried every split backtracked on long tokens.
956
+ */
957
+ function urlish(token) {
958
+ if (/^[a-z][a-z0-9+.-]*:/i.test(token))
959
+ return true;
960
+ const start = token.search(/[^/\\]/);
961
+ if (start < 0)
962
+ return false;
963
+ const slash = token.slice(start).search(/[/\\]/);
964
+ return slash > 0 && /[.%\u3002\uff0e\uff61]/.test(token.slice(start, start + slash));
965
+ }
966
+ const AUTHORITY = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^/]*/i;
967
+ const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
968
+ const isUrl = (t) => AUTHORITY.test(t.trim()) || SCHEME.test(t.trim());
969
+ const memoized = (m, key, f) => {
970
+ if (m.has(key))
971
+ return m.get(key);
972
+ const v = f();
973
+ m.set(key, v);
974
+ return v;
975
+ };
976
+ /**
977
+ * The page a reference, as written from page `from`, resolves to as a link would: true if
978
+ * hidden, false if visible, undefined for a URL or a path that is no page. Not cut at `?`
979
+ * or `#`: the token ends with a hidden page's file name, so one before it is part of that
980
+ * name (`%23`) or puts the path in a query (`?next=../x.md`).
981
+ */
982
+ function resolvesTo(token, from, names) {
983
+ const t = token.trim();
984
+ if (isUrl(t))
985
+ return undefined;
986
+ return names.paths.get(t.startsWith("/") ? posix.normalize(t.slice(1)) : posix.normalize(posix.join(posix.dirname(from), t)));
987
+ }
988
+ /**
989
+ * The page a URL or an unresolved path names by what it ends with: the longest tail (two
990
+ * segments or more) that is a page's repo path or its path under the root. Its length
991
+ * says how specific the match is.
992
+ */
993
+ function endsWith(token, names) {
994
+ const t = token.trim();
995
+ const authority = AUTHORITY.exec(t);
996
+ const scheme = SCHEME.exec(t);
997
+ const path = authority ? t.slice(authority[0].length) : scheme ? t.slice(scheme[0].length) : t;
998
+ const segs = posix.normalize(path).split("/").filter((s) => s && s !== "." && s !== "..");
999
+ for (let n = Math.min(segs.length, names.depth); n >= 2; n--) {
1000
+ const tail = segs.slice(-n).join("/");
1001
+ const hidden = names.paths.get(tail) ?? names.under.get(tail);
1002
+ if (hidden !== undefined)
1003
+ return { hidden, length: tail.length };
1004
+ }
1005
+ return undefined;
1006
+ }
1007
+ /**
1008
+ * Whether the references that may end at one place name a hidden page. One that resolves
1009
+ * to a hidden page as a link decides. One that resolves to a visible page says what a
1010
+ * relative path there means: `<../team space/x/y.md>` and, from inside `team space/`,
1011
+ * `x/y.md` are that page, not a hidden `x/y.md`, and a start one character earlier only
1012
+ * glues junk to it. A URL's tail still counts: a later start inside it (after a port's
1013
+ * `:`, say) can resolve to a visible page while the URL names a hidden one. (`url` says
1014
+ * which tokens are URLs; the sweep's leaves out spans no URL could be.) Of what's left,
1015
+ * the most specific tail decides.
1016
+ */
1017
+ function namesHidden(tokens, from, names, memo, url = isUrl, junk = []) {
1018
+ const resolve = (t) => (memo ? memoized(memo.to, t, () => resolvesTo(t, from, names)) : resolvesTo(t, from, names));
1019
+ const tail = (t) => (memo ? memoized(memo.tail, t, () => endsWith(t, names)) : endsWith(t, names));
1020
+ let visible = false;
1021
+ for (const t of tokens) {
1022
+ const to = resolve(t);
1023
+ if (to === true)
1024
+ return true;
1025
+ if (to === false)
1026
+ visible = true;
1027
+ }
1028
+ // Starts that may only be junk in front (`cancelsJunk`), read when no other start
1029
+ // resolves to a page: `(draft)/../../x.md` is a directory's own name, `"../../x.md` isn't.
1030
+ if (!visible)
1031
+ for (const t of junk)
1032
+ if (resolve(t) === true)
1033
+ return true;
1034
+ let best;
1035
+ for (const t of tokens) {
1036
+ if (visible && !url(t))
1037
+ continue;
1038
+ const e = tail(t);
1039
+ if (e && (!best || e.length > best.length || (e.length === best.length && e.hidden)))
1040
+ best = e;
1041
+ }
1042
+ return best?.hidden ?? false;
1043
+ }
1044
+ /** Marks the lines of `v` that name a hidden page. */
1045
+ function scan(v, from, names, marked) {
1046
+ const D = v.text;
1047
+ const memo = { to: new Map(), tail: new Map(), junk: new Map() };
1048
+ // Each hidden page with a file name, as a path from this page's directory.
1049
+ const relative = new Map();
1050
+ const relativeTo = (name) => memoized(relative, name, () => (names.spelled.get(name) ?? []).map((path) => posix.relative(posix.dirname(from), path)).filter((r) => r.includes("/")));
1051
+ // For each offset, the nearest `<`, `"` and `'` as written at or before it (-1 for none),
1052
+ // built once on first use: a reference that may hold spaces starts after the nearest one
1053
+ // of its delimiter.
1054
+ let nearest;
1055
+ const quotes = () => {
1056
+ if (nearest)
1057
+ return nearest;
1058
+ const at = [new Int32Array(D.length), new Int32Array(D.length), new Int32Array(D.length)];
1059
+ const last = [-1, -1, -1];
1060
+ for (let i = 0; i < D.length; i++) {
1061
+ const c = D[i];
1062
+ const x = c === "<" ? 0 : c === '"' ? 1 : c === "'" ? 2 : -1;
1063
+ if (x >= 0 && v.raw(i))
1064
+ last[x] = i;
1065
+ for (let y = 0; y < 3; y++)
1066
+ at[y][i] = last[y];
1067
+ }
1068
+ return (nearest = at);
1069
+ };
1070
+ const mark = (i, j) => {
1071
+ for (let l = v.lineOf(i); l <= v.lineOf(j); l++)
1072
+ marked.add(l);
1073
+ };
1074
+ for (let e = D.indexOf(".md"); e >= 0; e = D.indexOf(".md", e + 1)) {
1075
+ const end = e + 3;
1076
+ // The hidden file names ending here that start a segment; s, the rightmost start.
1077
+ let s = -1;
1078
+ const ending = [];
1079
+ for (const [len, set] of names.names) {
1080
+ const at = end - len;
1081
+ if (at < 0 || !set.has(D.slice(at, end)))
1082
+ continue;
1083
+ if (at === 0 || D[at - 1] === "/" || startsAfter(D, at - 1)) {
1084
+ ending.push(D.slice(at, end));
1085
+ s = Math.max(s, at);
1086
+ }
1087
+ }
1088
+ if (s < 0)
1089
+ continue;
1090
+ // Where the reference could start: after any character a path can't hold, back to
1091
+ // whitespace as written; then after the nearest `<`, `"` and `'`.
1092
+ const starts = [];
1093
+ const floor = Math.max(0, s - REACH);
1094
+ let unsure = false;
1095
+ let k = s - 1;
1096
+ for (; k >= floor; k--) {
1097
+ const c = D[k];
1098
+ if (writtenSpace(v, k))
1099
+ break; // a line break too: a space in the prose view
1100
+ if (startsAfter(D, k))
1101
+ starts.push(k + 1);
1102
+ }
1103
+ if (k < floor && floor > 0)
1104
+ unsure = true; // ran past REACH
1105
+ starts.push(k + 1);
1106
+ if (!unsure && k >= 0) {
1107
+ // A path with spaces in prose or code (`knowledge/team space/x.md`): the starts of the
1108
+ // one or two words before, so the whole path is read too and can name a visible page.
1109
+ for (let q = k, words = 0; words < WORDS_BACK && q >= floor; words++) {
1110
+ while (q >= floor && writtenSpace(v, q))
1111
+ q--;
1112
+ if (q < floor)
1113
+ break;
1114
+ for (; q >= floor && !writtenSpace(v, q); q--)
1115
+ if (startsAfter(D, q))
1116
+ starts.push(q + 1);
1117
+ if (q < floor && floor > 0)
1118
+ break;
1119
+ starts.push(q + 1);
1120
+ }
1121
+ if (k > 0)
1122
+ for (const at of quotes())
1123
+ if (at[k - 1] >= floor)
1124
+ starts.push(at[k - 1] + 1);
1125
+ }
1126
+ if (unsure) {
1127
+ mark(Math.max(floor, k + 1), end - 1);
1128
+ continue;
1129
+ }
1130
+ // A hidden page's path under the root, or from this page's directory, spelled out
1131
+ // (spaces and all) where a path can start: read from there too (with any `./` and
1132
+ // `../` before it), so it resolves from this page like any other start. Not as the
1133
+ // tail of a longer path (`team space/x/y.md`).
1134
+ // A hidden page's repo path spelled out, spaces and all, anywhere (a URL holds it too,
1135
+ // after its `/`): a start, so a visible page whose path ends with it still decides.
1136
+ for (const path of ending.flatMap((n) => names.spelled.get(n) ?? []))
1137
+ if (end - path.length >= 0 && D.slice(end - path.length, end) === path)
1138
+ starts.push(end - path.length);
1139
+ for (const rel of ending.flatMap((n) => [...(names.spelledUnder.get(n) ?? []), ...relativeTo(n)])) {
1140
+ let j = end - rel.length;
1141
+ if (j < 0 || D.slice(j, end) !== rel)
1142
+ continue;
1143
+ for (;;) {
1144
+ if (D.startsWith("../", j - 3))
1145
+ j -= 3;
1146
+ else if (D.startsWith("./", j - 2))
1147
+ j -= 2;
1148
+ else
1149
+ break;
1150
+ }
1151
+ if (j === 0 || startsAfter(D, j - 1))
1152
+ starts.push(j);
1153
+ }
1154
+ const isJunk = (j) => memoized(memo.junk, D.slice(j, end), () => cancelsJunk(D.slice(j, end)));
1155
+ const read = starts.filter((j) => !isJunk(j));
1156
+ const junk = starts.filter(isJunk);
1157
+ // A URL as written holds no space, quote, angle or square bracket, or backtick: a
1158
+ // "URL" spanning those is two things read as one (`…/a.md "title"[b](c/x.md`).
1159
+ const urls = new Set(read.filter((j) => isUrl(D.slice(j, end)) && !writtenDelimiter(v, j, end)).map((j) => D.slice(j, end)));
1160
+ if (namesHidden(read.map((j) => D.slice(j, end)), from, names, memo, (t) => urls.has(t), junk.map((j) => D.slice(j, end))))
1161
+ mark(Math.min(...starts), end - 1);
1162
+ }
1163
+ }
1164
+ const renderings = (text) => (SHORTER.test(text) ? [unshown, (l) => unshown(l, true)] : [unshown]);
1165
+ /** The lines of `text`, on page `from`, that name a hidden page: see the module comment. */
1166
+ export function namingLines(text, from, names, depth = 0, inherited = []) {
1167
+ // Destinations and URLs into the repo first, even with nothing hidden: one that names
1168
+ // no page fails closed. A srcdoc is a document of its own, with its parent's bases.
1169
+ const bases = [...inherited, ...basesIn(text)];
1170
+ const marked = destinationLines(text, from, names, bases);
1171
+ for (const l of repoUrlLines(text, names))
1172
+ marked.add(l);
1173
+ for (const l of srcdocLines(text, depth, (doc) => namingLines(doc, from, names, depth + 1, bases).size > 0))
1174
+ marked.add(l);
1175
+ if (!names.names.size)
1176
+ return marked;
1177
+ const lines = text.split("\n");
1178
+ const fromKey = fold(from);
1179
+ // Read with CommonMark's escapes and without (as raw HTML reads `\`); the same text
1180
+ // when there's no backslash, and as written when there's nothing to decode at all.
1181
+ const encoded = ENCODED.test(text);
1182
+ for (const escapes of text.includes("\\") ? [true, false] : [true]) {
1183
+ scan(encoded ? readerView(lines, false, escapes) : plainView(lines, false), fromKey, names, marked);
1184
+ scan(encoded ? readerView(lines, true, escapes) : plainView(lines, true), fromKey, names, marked);
1185
+ // Joined again with every line as written: inside a value broken across lines, what
1186
+ // looks like a list marker is part of it (`href="../x/` then `1. secret.md">`).
1187
+ scan(encoded ? readerView(lines, true, escapes, false, true) : plainView(lines, true, false, true), fromKey, names, marked);
1188
+ }
1189
+ // As rendered prose: markup inside a path (`team/**secret.md**`) is gone on screen.
1190
+ if (names.rendered) {
1191
+ for (const show of renderings(text))
1192
+ scan(encoded ? readerView(lines, false, true, show) : plainView(lines, false, show), foldRendered(from), names.rendered, marked);
1193
+ }
1194
+ return marked;
1195
+ }
1196
+ /**
1197
+ * The lines of `text` where `find`, run over each view the sweep reads (as prose and
1198
+ * joined, with and without CommonMark's escapes, and as rendered), reports a span. For
1199
+ * names that aren't paths: a mirrored page's id, say.
1200
+ */
1201
+ export function linesWhere(text, find, depth = 0, inherited = []) {
1202
+ const marked = new Set();
1203
+ const lines = text.split("\n");
1204
+ const encoded = ENCODED.test(text);
1205
+ const views = [];
1206
+ for (const escapes of text.includes("\\") ? [true, false] : [true]) {
1207
+ views.push(encoded ? readerView(lines, false, escapes) : plainView(lines, false));
1208
+ views.push(encoded ? readerView(lines, true, escapes) : plainView(lines, true));
1209
+ views.push(encoded ? readerView(lines, true, escapes, false, true) : plainView(lines, true, false, true));
1210
+ }
1211
+ for (const show of renderings(text))
1212
+ views.push(encoded ? readerView(lines, false, true, show) : plainView(lines, false, show));
1213
+ for (const v of views)
1214
+ for (const [i, j] of find(v.text, v.cased))
1215
+ for (let l = v.lineOf(i); l <= v.lineOf(Math.max(i, j - 1)); l++)
1216
+ marked.add(l);
1217
+ // A destination resolved against a `<base href>` (a relative `pages/900` under a wiki's
1218
+ // space URL), and a srcdoc as a document of its own, with its parent's bases.
1219
+ const bases = [...inherited, ...basesIn(text)];
1220
+ if (bases.length) {
1221
+ for (const d of destinations(text)) {
1222
+ for (const b of bases) {
1223
+ const r = againstBase(b, d.dest, d.html, ".knowledge/x.md");
1224
+ if (r !== undefined && [...find(r.toLowerCase(), r)].length)
1225
+ for (let l = d.lines[0]; l <= d.lines[1]; l++)
1226
+ marked.add(l);
1227
+ }
1228
+ }
1229
+ }
1230
+ for (const l of srcdocLines(text, depth, (doc) => linesWhere(doc, find, depth + 1, bases).size > 0))
1231
+ marked.add(l);
1232
+ return marked;
1233
+ }
1234
+ /** `text` with each line in `lines` replaced by its container markers and `placeholder`. */
1235
+ export function replaceLines(text, lines, placeholder) {
1236
+ if (!lines.size)
1237
+ return text;
1238
+ return text
1239
+ .split("\n")
1240
+ .map((l, i) => (lines.has(i) ? `${CONTAINER.exec(l)[0]}${placeholder}` : l))
1241
+ .join("\n");
1242
+ }