@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.
- package/LICENSE +55 -0
- package/README.md +238 -2
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +458 -0
- package/dist/core/acl.d.ts +37 -0
- package/dist/core/acl.js +136 -0
- package/dist/core/adopt.d.ts +68 -0
- package/dist/core/adopt.js +665 -0
- package/dist/core/audit-log.d.ts +133 -0
- package/dist/core/audit-log.js +290 -0
- package/dist/core/blocks.d.ts +35 -0
- package/dist/core/blocks.js +417 -0
- package/dist/core/codeowners.d.ts +62 -0
- package/dist/core/codeowners.js +133 -0
- package/dist/core/config.d.ts +101 -0
- package/dist/core/config.js +175 -0
- package/dist/core/coverage.d.ts +25 -0
- package/dist/core/coverage.js +41 -0
- package/dist/core/duration.d.ts +20 -0
- package/dist/core/duration.js +60 -0
- package/dist/core/edits.d.ts +13 -0
- package/dist/core/edits.js +20 -0
- package/dist/core/errors.d.ts +11 -0
- package/dist/core/errors.js +13 -0
- package/dist/core/frontmatter.d.ts +29 -0
- package/dist/core/frontmatter.js +49 -0
- package/dist/core/gate.d.ts +17 -0
- package/dist/core/gate.js +25 -0
- package/dist/core/gfm.d.ts +13 -0
- package/dist/core/gfm.js +244 -0
- package/dist/core/github.d.ts +152 -0
- package/dist/core/github.js +234 -0
- package/dist/core/host.d.ts +125 -0
- package/dist/core/host.js +22 -0
- package/dist/core/identity.d.ts +39 -0
- package/dist/core/identity.js +127 -0
- package/dist/core/init.d.ts +25 -0
- package/dist/core/init.js +86 -0
- package/dist/core/links.d.ts +82 -0
- package/dist/core/links.js +345 -0
- package/dist/core/lint.d.ts +16 -0
- package/dist/core/lint.js +149 -0
- package/dist/core/mcp/audit-transport.d.ts +16 -0
- package/dist/core/mcp/audit-transport.js +238 -0
- package/dist/core/mcp/server.d.ts +19 -0
- package/dist/core/mcp/server.js +216 -0
- package/dist/core/mcp/text.d.ts +14 -0
- package/dist/core/mcp/text.js +43 -0
- package/dist/core/mirror-view.d.ts +68 -0
- package/dist/core/mirror-view.js +367 -0
- package/dist/core/new-page.d.ts +52 -0
- package/dist/core/new-page.js +49 -0
- package/dist/core/policy.d.ts +10 -0
- package/dist/core/policy.js +30 -0
- package/dist/core/polish.d.ts +42 -0
- package/dist/core/polish.js +1000 -0
- package/dist/core/printable.d.ts +4 -0
- package/dist/core/printable.js +16 -0
- package/dist/core/references.d.ts +87 -0
- package/dist/core/references.js +1744 -0
- package/dist/core/repo.d.ts +46 -0
- package/dist/core/repo.js +118 -0
- package/dist/core/schema.d.ts +50 -0
- package/dist/core/schema.js +51 -0
- package/dist/core/search.d.ts +32 -0
- package/dist/core/search.js +132 -0
- package/dist/core/serve/auth.d.ts +23 -0
- package/dist/core/serve/auth.js +38 -0
- package/dist/core/serve/server.d.ts +21 -0
- package/dist/core/serve/server.js +138 -0
- package/dist/core/serve/sessions.d.ts +40 -0
- package/dist/core/serve/sessions.js +203 -0
- package/dist/core/serve/token.d.ts +10 -0
- package/dist/core/serve/token.js +96 -0
- package/dist/core/service.d.ts +473 -0
- package/dist/core/service.js +973 -0
- package/dist/core/sla.d.ts +16 -0
- package/dist/core/sla.js +20 -0
- package/dist/core/stale.d.ts +16 -0
- package/dist/core/stale.js +24 -0
- package/dist/core/tool-registry.d.ts +31 -0
- package/dist/core/tool-registry.js +80 -0
- package/dist/core/url.d.ts +4 -0
- package/dist/core/url.js +31 -0
- package/dist/core/verify-pages.d.ts +19 -0
- package/dist/core/verify-pages.js +110 -0
- package/dist/core/version.d.ts +22 -0
- package/dist/core/version.js +32 -0
- package/dist/core/writes.d.ts +58 -0
- package/dist/core/writes.js +113 -0
- package/dist/web/assets/index-8J-FUbb0.js +29 -0
- package/dist/web/assets/index-BGpAZzeM.css +1 -0
- package/dist/web/index.html +14 -0
- package/docs/format-spec.md +349 -0
- package/package.json +75 -4
- package/plugin/.claude-plugin/plugin.json +8 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/bin/docsworth-mcp.mjs +46 -0
- package/plugin/skills/draft-runbook/SKILL.md +75 -0
- package/plugin/skills/polish-page/SKILL.md +64 -0
- package/plugin/skills/update-after-incident/SKILL.md +59 -0
- package/plugin/skills/verify-page/SKILL.md +46 -0
- 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 (`\`, `\`) 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 (`%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.md` is no reference
|
|
628
|
+
// without its `;`, so its `#` starts a fragment and the link is to `x&`); in an attribute,
|
|
629
|
+
// HTML's (`&#` then a line break is no reference either). The liberal reading (`x.md`)
|
|
630
|
+
// still hides wherever it names a hidden page; a route naming nothing fails closed only by
|
|
631
|
+
// the browser's reading of a link CommonMark parses (`links`), or of an attribute. And
|
|
632
|
+
// only for routes: a `.md` the browser's reading finds no page (`..\x.md`, whose `\` a
|
|
633
|
+
// renderer writes as `%5C`) is the liberal reading's to judge, as before.
|
|
634
|
+
// (A markdown destination's `\` reaches the href as `%5C`, which a browser keeps.)
|
|
635
|
+
const cm = commonmarkPath(html ? decodeText(d, false).replace(/\\/g, "/") : decodeText(d, true, true).replace(/\\/g, "%5C"));
|
|
636
|
+
// Another reading where it differs (`/%2Fgithub.com/x` is a path, kept so: the liberal
|
|
637
|
+
// reading's `//github.com/x` is a URL).
|
|
638
|
+
const other = cm.folded && cm.folded !== path ? cm : undefined;
|
|
639
|
+
const confirmed = (p) => html || !links || links.has(p);
|
|
640
|
+
const byCommonMark = () => !!other && (other.url ? urlHides(other.cased, fromKey, names, false, confirmed(other.folded)) : readingHides(other.folded, fromKey, names, confirmed(other.folded) ? repoPathOf(other.cased, from) : undefined, false));
|
|
641
|
+
// `%2F` as written: a hidden page that way hides it; naming nothing doesn't.
|
|
642
|
+
if (isUrlPath(path))
|
|
643
|
+
return urlHides(decodeText(d, !html), fromKey, names, html, confirmed(cm.folded), path) || byCommonMark();
|
|
644
|
+
return readingHides(path, fromKey, names, !other && confirmed(path) ? writtenPath(d, html, from) : undefined) || byCommonMark();
|
|
645
|
+
}
|
|
646
|
+
const isUrlPath = (p) => /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(p);
|
|
647
|
+
/**
|
|
648
|
+
* Whether a URL (decoded) names a hidden page: on the docs site, by its path's tail, or one
|
|
649
|
+
* into the repo naming no page. `link` if it's a link (`destinationHides`'s `links`): then a
|
|
650
|
+
* route on the docs site naming nothing fails closed too.
|
|
651
|
+
*/
|
|
652
|
+
function urlHides(url, fromKey, names, html, link, path = readDestination(url, true)) {
|
|
653
|
+
return siteUrlHides([url], names, link) || namesHidden([path], fromKey, names) || namesNoPage(url, names, !html);
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Whether one reading of a destination (folded, not a URL) hides a page. `failClosed`, the
|
|
657
|
+
* reading as written in the repo when it may fail closed: then a route naming nothing does,
|
|
658
|
+
* unless it's a file or directory in the repo outside the knowledge root (`/src`), a link
|
|
659
|
+
* to that, which hides no page. `liberal` for the liberal reading, which alone reads a `.md`
|
|
660
|
+
* under the root that is no page, and a hidden page's path at the end of another, as hiding.
|
|
661
|
+
*/
|
|
662
|
+
function readingHides(path, fromKey, names, failClosed, liberal = true) {
|
|
663
|
+
// Resolved first, then any `%2F` left decoded (`commonmarkPath`).
|
|
664
|
+
const resolved = slashes(path.startsWith("/") ? posix.normalize(path.slice(1)) : posix.normalize(posix.join(posix.dirname(fromKey), path)));
|
|
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 `.` 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
|
+
}
|