@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,1000 @@
|
|
|
1
|
+
// The polish guard (v0.2 L1). `propose_edit` with `polish: true` may restructure and
|
|
2
|
+
// reword a page, but not change the things a reader acts on. This is a structural check,
|
|
3
|
+
// not proof that the meaning is unchanged. It compares:
|
|
4
|
+
// - the frontmatter, byte for byte
|
|
5
|
+
// - code blocks (fenced and indented, as CommonMark finds them), in order
|
|
6
|
+
// - inline code, effective link targets and bare URLs, as multisets
|
|
7
|
+
// - struck-through text, as a multiset
|
|
8
|
+
// - each number (with its sign) and the word after it ("5 minutes"), so swaps show
|
|
9
|
+
// - the count of each negation and modal word ("not", "never", "must", ...)
|
|
10
|
+
// What it can't see (a renamed service, two same-unit numbers trading places, a command
|
|
11
|
+
// in plain prose, a reworded sentence that now means something else) is why the PR
|
|
12
|
+
// still goes to the owner team.
|
|
13
|
+
import { FENCE_MARK, codeBlocks } from "./blocks.js";
|
|
14
|
+
const FRONTMATTER = /^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
15
|
+
// An HTML comment renders as nothing, so it's compared on its own and kept out of every
|
|
16
|
+
// other fact: a fact moved into a comment has left the page a reader sees.
|
|
17
|
+
/**
|
|
18
|
+
* Spans from `open` to the next `close`, found with indexOf: linear, where a lazy regex
|
|
19
|
+
* would rescan to the end for every unclosed opener. Once a close is missing, no later
|
|
20
|
+
* opener can be closed either, so the scan stops.
|
|
21
|
+
*/
|
|
22
|
+
function spans(text, open, close) {
|
|
23
|
+
const out = [];
|
|
24
|
+
for (let i = text.indexOf(open); i >= 0;) {
|
|
25
|
+
const j = text.indexOf(close, i + open.length);
|
|
26
|
+
if (j < 0)
|
|
27
|
+
break;
|
|
28
|
+
out.push([i, j + close.length]);
|
|
29
|
+
i = text.indexOf(open, j + close.length);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
const cut = (text, ss, keep) => {
|
|
34
|
+
let out = "";
|
|
35
|
+
let at = 0;
|
|
36
|
+
for (const [i, j] of ss) {
|
|
37
|
+
if (i < at)
|
|
38
|
+
continue; // inside one already cut
|
|
39
|
+
out += text.slice(at, i) + keep(text.slice(i, j));
|
|
40
|
+
at = j;
|
|
41
|
+
}
|
|
42
|
+
return out + text.slice(at);
|
|
43
|
+
};
|
|
44
|
+
// A code span: a run of backticks, closed by a run of the same length. It may wrap onto
|
|
45
|
+
// the next line, but not across a blank one (that ends the paragraph).
|
|
46
|
+
const INLINE_CODE = /(?<!`)(`+)(?!`)((?:[^\n]|\n(?![ \t]*\n))+?)(?<!`)\1(?!`)/g;
|
|
47
|
+
// A reference definition; its destination may sit on the next line (CommonMark).
|
|
48
|
+
// Not a footnote (`[^1]: …`), whose text renders and so is read like any other.
|
|
49
|
+
// Container markers (quotes, list items) may come first, on the line and on its
|
|
50
|
+
// continuation lines: `> [x]: safe.md` defines a link too. A title must close, and
|
|
51
|
+
// nothing may follow it: otherwise the line isn't a definition, renders as text, and is
|
|
52
|
+
// compared as text.
|
|
53
|
+
// Each space has one place to match: the spaces before a marker go with it, and the
|
|
54
|
+
// pattern after the markers takes the rest. (`>[ \t]?` then `[ \t]*` could split a
|
|
55
|
+
// space two ways per marker, and `> > > …` backtracked exponentially.) A list marker
|
|
56
|
+
// still needs a space after it; four spaces or a tab anywhere in the prefix refuses the
|
|
57
|
+
// polish (definitionSpans), so `[ \t]{0,3}` after the markers loses nothing.
|
|
58
|
+
const CONTAINER = String.raw `(?:[ \t]*(?:>|(?:[-*+]|\d{1,9}[.)])(?=[ \t])))*`;
|
|
59
|
+
const CONTINUE = String.raw `(?:[ \t]*>)*`;
|
|
60
|
+
const REF_DEF = new RegExp(String.raw `^${CONTAINER}[ \t]{0,3}\[(?!\^)((?:\\.|[^\[\]\\\n])+)\]:[ \t]*(?:\n${CONTINUE}[ \t]*)?(<(?:\\.|[^<>\\\n])*>|\S+)(?:(?:[ \t]+|[ \t]*\n${CONTINUE}[ \t]+)("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|\((?:\\.|[^()\\\n])*\)))?[ \t]*$`, "gm");
|
|
61
|
+
// Raw HTML tags. Markdown can't hide text; HTML can (`<span hidden>`, a style, a
|
|
62
|
+
// <template>), so a polish may not add, drop or change a single tag.
|
|
63
|
+
// Every raw HTML form CommonMark passes through: tags, processing instructions
|
|
64
|
+
// (`<? … ?>`), declarations (`<!DOCTYPE …>`) and CDATA. Comments are compared on their own.
|
|
65
|
+
// Every raw HTML opener, closed or not: an unclosed `<!--`, `<?` or `<div` hides what
|
|
66
|
+
// follows it, so openers are counted whether or not a closer comes.
|
|
67
|
+
const OPENER = /<(?:!--|!\[CDATA\[|![a-z]|\?|\/?[a-z][a-z0-9-]*)/gi;
|
|
68
|
+
// Processing instructions and CDATA are found by `spans` (see rawHtml).
|
|
69
|
+
const TAG = /<(?:\/?[a-z][a-z0-9-]*(?:\s[^<>]*)?\/?|![a-z][^<>]*)>/gi;
|
|
70
|
+
// Characters a reader can't tell apart from ASCII, or can't see at all.
|
|
71
|
+
// Allowed: ASCII, Latin-1 (not the invisible soft hyphen), typographic punctuation,
|
|
72
|
+
// arrows, maths symbols and box drawing.
|
|
73
|
+
// (ASCII controls other than tab and line breaks count as unusual: NUL, ESC, DEL.)
|
|
74
|
+
const UNUSUAL = /[^\t\n\r\x20-\x7e\u00a0-\u00ac\u00ae-\u00ff\u2010-\u2027\u2030-\u205e\u2190-\u21ff\u2200-\u22ff\u2500-\u25ff]/gu;
|
|
75
|
+
// Labels hold no unescaped brackets, so a scan stops at the next one (linear).
|
|
76
|
+
// (Backslash escapes, such as `\]`, count as label text.)
|
|
77
|
+
// The shown text may hold one level of brackets (`[the [production] guide][a]`).
|
|
78
|
+
// A label or link text can't hold a blank line: that ends the paragraph.
|
|
79
|
+
const FULL_REF = /(?<=(?:^|[^\\])(?:\\\\)*)\[((?:\\.|[^\[\]\\\n]|\n(?!\n)|\[(?:\\.|[^\[\]\\\n]|\n(?!\n))*\])*)\]\[((?:\\.|[^\[\]\\\n]|\n(?!\n))*)\]/g;
|
|
80
|
+
// \u0000 marks where an inline link's `](target)` was, so its text isn't read as a use.
|
|
81
|
+
// `[a]: x` in paragraph text is a use of `[a]` too: only a real definition is cut out.
|
|
82
|
+
// Nor does a `(` that didn't open an inline link stop one (those are cut out first), nor
|
|
83
|
+
// a `[` that doesn't open a well-formed label (`[prod][` then a blank line).
|
|
84
|
+
// Neither starts at an escaped `\[`.
|
|
85
|
+
const SHORTCUT_REF = /(?<=(?:^|[^\\])(?:\\\\)*)\[((?:\\.|[^\[\]\\\n]|\n(?!\n))+)\](?!\[(?:\\.|[^\[\]\\\n]|\n(?!\n))*\]|\u0000)/g;
|
|
86
|
+
// A scheme is case-insensitive: HTTPS://… is a URL too.
|
|
87
|
+
// Where GFM autolinks one: at a line's start or after a space, `*`, `_`, `~` or `(`
|
|
88
|
+
// (`\https://…` is text), and as far as it runs: to the next space or `<` (trimUrl takes
|
|
89
|
+
// the tail off). Both ways matter: a link that appears or goes is a change.
|
|
90
|
+
const URL = /(?<=^|[\s*_~(])https?:\/\/[^\s<]+/gim;
|
|
91
|
+
// Autolinks: `<scheme:anything>` and `<user@host>` (CommonMark), bare emails and `www.`
|
|
92
|
+
// links (GFM), and HTML anchors and images. Each is a target a reader can follow.
|
|
93
|
+
// Not after an odd run of backslashes: `\<https://…>` is text.
|
|
94
|
+
const AUTOLINK = /(?<=(?:^|[^\\])(?:\\\\)*)<([a-z][a-z0-9+.-]{1,31}:[^\s<>]*|[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+)>/gi;
|
|
95
|
+
const BARE_EMAIL = /(?<![\w.+-])[a-z0-9.+_-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+/gi;
|
|
96
|
+
const WWW = /(?<=^|[\s*_~(])www\.[^\s<]+/gim;
|
|
97
|
+
// The attribute value quoted or not: `<a href=/safe>` is a link too.
|
|
98
|
+
const HTML_LINK = /<(?:a|img)\b[^<>]*?\b(?:href|src)\s*=\s*(?:"([^"<>]*)"|'([^'<>]*)'|([^\s"'=<>`]+))[^<>]*>/gi;
|
|
99
|
+
const STRUCK = /~~(?!~)([\s\S]+?)~~|<(del|s)>([^<]*)<\/\2>/gi;
|
|
100
|
+
// A number, with a sign only where one can be (not the hyphen of a range like 5-10), and
|
|
101
|
+
// the word after it. That word may sit on the next line of the same paragraph (rewrapping
|
|
102
|
+
// is a polish), but not across a blank line, a list item or a heading.
|
|
103
|
+
// A hyphen joins a unit too: `5-minute` is 5 with minute.
|
|
104
|
+
// The token is the whole run from its first digit (`1e3`, `0xff`, `30s`, `10:30`), so
|
|
105
|
+
// no part of it can change unseen.
|
|
106
|
+
// An identifier's digits (`ADR-0001`, `INC-77`) are taken with its prefix, and never
|
|
107
|
+
// paired with a unit: "ADR-0001 and" says nothing about "and".
|
|
108
|
+
// Any script's digits (`\p{Nd}`): `٥` to `٩` changes a number as `5` to `9` does.
|
|
109
|
+
const NUMBER = /(?<!\w)(?<!\p{Nd}\.)((?:[a-z][a-z0-9]*-(?=\p{Nd}))?(?:[-+\u2212](?=\.?\p{Nd}))?(?:\p{Nd}|\.\p{Nd})(?:[\w%]|\p{Nd}|[.,:](?=\p{Nd}))*)(?:(?:[ \t]+|[ \t]*\n[ \t]*|-(?=\p{L}))(\p{L}+))?/giu;
|
|
110
|
+
// A plural is the same unit: `5-minute timeout` and `5 minutes` say the same thing.
|
|
111
|
+
// Case counts in a unit (`MB` and `Mb`, `mW` and `MW`), except that a word of three
|
|
112
|
+
// letters or more may be capitalised (`5 Minutes` in a heading is `5 minutes`; merge
|
|
113
|
+
// gate, PR 3, round 36).
|
|
114
|
+
const unit = (w) => (/^\p{Lu}?\p{Ll}{2,}$/u.test(w) ? w.toLowerCase() : w).replace(/(?<=\w\w)s$/, "");
|
|
115
|
+
// Bold and italics are presentation: `5 **minutes**` says what `5 minutes` says.
|
|
116
|
+
// Strikethrough isn't (it withdraws the text), so it's compared, not stripped.
|
|
117
|
+
const EMPHASIS = /\*+|(?<!\w)_+|_+(?!\w)/g;
|
|
118
|
+
export const NEGATIONS = ["not", "never", "no", "must", "should", "cannot", "can't", "don't", "doesn't", "won't", "shouldn't", "mustn't", "isn't", "aren't", "only", "without", "unless", "except", "nor", "none", "neither", "always"];
|
|
119
|
+
const SCRIPTS = ["Latin", "Cyrillic", "Greek", "Armenian", "Georgian", "Hebrew", "Arabic", "Devanagari", "Bengali", "Tamil", "Thai", "Hangul", "Hiragana", "Katakana", "Han"];
|
|
120
|
+
const SCRIPT_RES = SCRIPTS.map((name) => [name, new RegExp(`\\p{Script=${name}}`, "u")]);
|
|
121
|
+
/** The script of a letter, if it's one of the common ones. */
|
|
122
|
+
const scriptOf = (c) => SCRIPT_RES.find(([, re]) => re.test(c))?.[0];
|
|
123
|
+
function frontmatter(text) {
|
|
124
|
+
return FRONTMATTER.exec(text)?.[0] ?? "";
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Fenced code blocks and the prose around them. Line by line, as CommonMark does: a fence
|
|
128
|
+
* of 3+ backticks or tildes, closed by a fence of the same character at least as long;
|
|
129
|
+
* an unclosed fence runs to the end. Each content line loses up to the opening fence's
|
|
130
|
+
* own indentation, as CommonMark does, so moving a block into a list item (which indents
|
|
131
|
+
* fence and content together) is a polish, while shifting the code inside it isn't.
|
|
132
|
+
* Trailing whitespace is ignored.
|
|
133
|
+
*/
|
|
134
|
+
/** Columns of leading whitespace, a tab reaching the next multiple of 4 (CommonMark). */
|
|
135
|
+
const columns = (ws) => [...ws].reduce((c, ch) => (ch === "\t" ? c + 4 - (c % 4) : c + 1), 0);
|
|
136
|
+
/**
|
|
137
|
+
* Code spans and HTML comments in one left-to-right pass, as CommonMark reads them:
|
|
138
|
+
* whichever starts first wins (a comment inside a code span is code, and a backtick
|
|
139
|
+
* inside a comment is comment). Each code span becomes CODE_MARK plus a private-use
|
|
140
|
+
* character naming it (no letter or digit, so no other check reads it), keeping its
|
|
141
|
+
* line breaks; each comment becomes its line breaks. Linear: a run length or a comment
|
|
142
|
+
* opener that found no closer once is not searched for again in that paragraph.
|
|
143
|
+
*/
|
|
144
|
+
const CODE_MARK = "\u0004";
|
|
145
|
+
const CODE_REF = /\u0004([\u{F0000}-\u{FFFFD}])/gu;
|
|
146
|
+
function inlineTokens(text) {
|
|
147
|
+
const codes = [];
|
|
148
|
+
const raws = []; // each span as written, backticks and all
|
|
149
|
+
const comments = [];
|
|
150
|
+
let commentHidesCode = false;
|
|
151
|
+
let out = "";
|
|
152
|
+
let at = 0;
|
|
153
|
+
let noCommentClose = false;
|
|
154
|
+
const noCloser = new Map(); // run length → paragraph end it failed in
|
|
155
|
+
let paraEnd = -1;
|
|
156
|
+
const keepBreaks = (t) => t.replace(/[^\n]/g, "");
|
|
157
|
+
// Open brackets in this paragraph: at a `]` that closes one, CommonMark parses a link's
|
|
158
|
+
// `(destination)` before it reads any backtick inside it, so `](a`b)` isn't code.
|
|
159
|
+
let open = 0;
|
|
160
|
+
const linkTail = linkTails(text);
|
|
161
|
+
for (let i = 0; i < text.length;) {
|
|
162
|
+
const c = text[i];
|
|
163
|
+
if (c === "\\") {
|
|
164
|
+
i += 2;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (c === "\n" && text[i + 1] === "\n")
|
|
168
|
+
open = 0;
|
|
169
|
+
else if (c === "[")
|
|
170
|
+
open++;
|
|
171
|
+
else if (c === "]" && open > 0) {
|
|
172
|
+
open--;
|
|
173
|
+
const tail = text[i + 1] === "(" ? linkTail(i) : null;
|
|
174
|
+
if (tail) {
|
|
175
|
+
i = tail.end;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (c === "<" && !noCommentClose && text.startsWith("<!--", i)) {
|
|
180
|
+
const j = text.indexOf("-->", i + 4);
|
|
181
|
+
if (j < 0) {
|
|
182
|
+
noCommentClose = true;
|
|
183
|
+
i += 4;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const comment = text.slice(i, j + 3);
|
|
187
|
+
comments.push(comment.replace(/\s+/g, " "));
|
|
188
|
+
if (comment.includes(FENCE_MARK))
|
|
189
|
+
commentHidesCode = true;
|
|
190
|
+
out += text.slice(at, i) + keepBreaks(comment);
|
|
191
|
+
at = i = j + 3;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (c === "`") {
|
|
195
|
+
let n = 0;
|
|
196
|
+
while (text[i + n] === "`")
|
|
197
|
+
n++;
|
|
198
|
+
if (i >= paraEnd) {
|
|
199
|
+
const para = text.indexOf("\n\n", i); // positions only move forward: linear
|
|
200
|
+
paraEnd = para < 0 ? text.length : para;
|
|
201
|
+
}
|
|
202
|
+
const end = paraEnd;
|
|
203
|
+
if ((noCloser.get(n) ?? -1) === end) {
|
|
204
|
+
i += n;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
let close = -1;
|
|
208
|
+
for (let k = i + n; k < end;) {
|
|
209
|
+
if (text[k] !== "`") {
|
|
210
|
+
k++;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
let m = 0;
|
|
214
|
+
while (text[k + m] === "`")
|
|
215
|
+
m++;
|
|
216
|
+
if (m === n) {
|
|
217
|
+
close = k;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
k += m;
|
|
221
|
+
}
|
|
222
|
+
if (close < 0) {
|
|
223
|
+
noCloser.set(n, end);
|
|
224
|
+
i += n;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const code = text.slice(i + n, close);
|
|
228
|
+
out += text.slice(at, i) + CODE_MARK + String.fromCodePoint(0xf0000 + codes.length) + keepBreaks(code);
|
|
229
|
+
// As CommonMark renders it: the line break becomes a space, and spaces before it
|
|
230
|
+
// stay. A continuation line's indentation is already gone by then: a paragraph's
|
|
231
|
+
// raw content is "formed by concatenating the lines and removing initial and final
|
|
232
|
+
// spaces or tabs" (spec §4.8; `aaa\n bbb` renders `aaa\nbbb`), before inline
|
|
233
|
+
// parsing finds the span. So `printf 'a\n b'` renders `printf 'a b'`.
|
|
234
|
+
codes.push(code.replace(/\n[ \t]*/g, " "));
|
|
235
|
+
raws.push(text.slice(i, close + n));
|
|
236
|
+
at = i = close + n;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
i++;
|
|
240
|
+
}
|
|
241
|
+
return { masked: out + text.slice(at), codes, raws, comments, commentHidesCode };
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* A destination CommonMark takes: `<…>`, or a bare one that doesn't start with `<` and
|
|
245
|
+
* holds no space or control character, with its parentheses balanced (escaped ones
|
|
246
|
+
* aside) to cmark's depth of 32. Otherwise the line isn't a definition: it renders as
|
|
247
|
+
* text and is compared as text (merge gate, PR 3, round 31).
|
|
248
|
+
*/
|
|
249
|
+
function destination(d) {
|
|
250
|
+
if (d.startsWith("<"))
|
|
251
|
+
return /^<(?:\\.|[^<>\\\n])*>$/.test(d);
|
|
252
|
+
if (/[\x00-\x20\x7f]/.test(d))
|
|
253
|
+
return false; // ASCII space and controls only, as cmark
|
|
254
|
+
let depth = 0;
|
|
255
|
+
for (let i = 0; i < d.length; i++) {
|
|
256
|
+
if (d[i] === "\\")
|
|
257
|
+
i++;
|
|
258
|
+
else if (d[i] === "(" && ++depth > 32)
|
|
259
|
+
return false;
|
|
260
|
+
else if (d[i] === ")" && depth-- === 0)
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
return depth === 0;
|
|
264
|
+
}
|
|
265
|
+
// The tail of a label that began on an earlier line (`[foo` then `bar]: …`).
|
|
266
|
+
const LABEL_TAIL = /^[^\[\n]*\]:/m;
|
|
267
|
+
// A definition's title that opens, on its line or the next, and maybe closes later: a
|
|
268
|
+
// title may span lines, and then it's hidden text (merge gate, PR 3, round 33).
|
|
269
|
+
const TITLE_OPEN = new RegExp(String.raw `^${CONTAINER}[ \t]{0,3}\[(?!\^)(?:\\.|[^\[\]\\\n])+\]:[ \t]*(?:\n${CONTINUE}[ \t]*)?(<(?:\\.|[^<>\\\n])*>|\S+)(?:[ \t]+|[ \t]*\n${CONTINUE}[ \t]*)(["'(])([^\n]*)$`, "gm");
|
|
270
|
+
const closes = (rest, open) => {
|
|
271
|
+
const close = open === "(" ? ")" : open;
|
|
272
|
+
for (let i = 0; i < rest.length; i++)
|
|
273
|
+
if (rest[i] === "\\")
|
|
274
|
+
i++;
|
|
275
|
+
else if (rest[i] === close)
|
|
276
|
+
return true;
|
|
277
|
+
return false;
|
|
278
|
+
};
|
|
279
|
+
// Quote and list markers at a line's start: what's left is the block the line holds.
|
|
280
|
+
/**
|
|
281
|
+
* Reference definitions that CommonMark would take as such: not interrupting a
|
|
282
|
+
* paragraph (the line before holds nothing, a heading, a fence, a thematic break or
|
|
283
|
+
* another definition, or the definition opens a new quote or list item). One that
|
|
284
|
+
* follows paragraph text renders as text, and is compared as text.
|
|
285
|
+
*
|
|
286
|
+
* `unsure` is set where telling the two apart takes more structure than this reads: a
|
|
287
|
+
* definition-like line indented by a tab or four spaces in prose (a deep list item's),
|
|
288
|
+
* a label or title that spans lines, or a definition after a `===` line (a setext
|
|
289
|
+
* heading, or paragraph text). A polish refuses those pages rather than guess (merge
|
|
290
|
+
* gate, PR 3, round 32). Indented code is found by the block pass and isn't prose.
|
|
291
|
+
*/
|
|
292
|
+
// A definition on a line's content (its container markers off), as commonmark.js reads
|
|
293
|
+
// it: up to three spaces, the label, a colon, spaces (and one line break), the
|
|
294
|
+
// destination, then a title after spaces or a line break, and nothing after it.
|
|
295
|
+
const DEF_AT = /^ {0,3}\[(?!\^)((?:\\.|[^\[\]\\\n])+)\]:[ ]*(?:\n[ ]*)?(<(?:\\.|[^<>\\\n])*>|\S+)(?:(?:[ ]+|[ ]*\n[ ]*)("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|\((?:\\.|[^()\\\n])*\)))?[ ]*(?=\n|$)/;
|
|
296
|
+
// A title that opens on a candidate's line (or the next) and doesn't close there may
|
|
297
|
+
// span lines, as CommonMark allows; checked on the same view the definitions are read
|
|
298
|
+
// on, so an indented continuation definition is seen too (found by fuzzing).
|
|
299
|
+
const TITLE_AT = /^ {0,3}\[(?!\^)(?:\\.|[^\[\]\\\n])+\]:[ ]*(?:\n[ ]*)?(<(?:\\.|[^<>\\\n])*>|\S+)(?:[ ]+|[ ]*\n[ ]*)(["'(])([^\n]*)/;
|
|
300
|
+
/**
|
|
301
|
+
* Reference definitions as CommonMark takes them. One may begin only where a paragraph
|
|
302
|
+
* begins (the block pass says where), or right after another; it's read on the line's
|
|
303
|
+
* content (the text given is past quote and list markers); its destination or title may sit on the
|
|
304
|
+
* next line only if that line continues the paragraph. One that follows paragraph text
|
|
305
|
+
* renders as text, and is compared as text.
|
|
306
|
+
*
|
|
307
|
+
* `unsure` is set where CommonMark implementations differ or this doesn't read the
|
|
308
|
+
* form: a tab between a definition's parts (commonmark.js takes spaces only), or a
|
|
309
|
+
* label or title that spans lines. A polish refuses those pages rather than guess.
|
|
310
|
+
*/
|
|
311
|
+
function definitionSpans(text, unmask, blocks) {
|
|
312
|
+
const spans = [];
|
|
313
|
+
const defs = [];
|
|
314
|
+
let unsure = LABEL_TAIL.test(text);
|
|
315
|
+
for (const m of text.matchAll(TITLE_OPEN))
|
|
316
|
+
if (destination(unmask(m[1])) && !closes(m[3], m[2]))
|
|
317
|
+
unsure = true;
|
|
318
|
+
const lines = text.split("\n");
|
|
319
|
+
const at = [];
|
|
320
|
+
for (let k = 0, o = 0; k < lines.length; o += lines[k].length + 1, k++)
|
|
321
|
+
at.push(o);
|
|
322
|
+
// `text` is the content view: each line past its container markers already.
|
|
323
|
+
const body = (k) => lines[k];
|
|
324
|
+
// A line continues the paragraph the previous one is in.
|
|
325
|
+
const continues = (k) => k < lines.length && Boolean(blocks.para[k]) && !blocks.paraStart[k];
|
|
326
|
+
let afterDef = false;
|
|
327
|
+
for (let k = 0; k < lines.length;) {
|
|
328
|
+
if (!(blocks.paraStart[k] || (afterDef && continues(k)))) {
|
|
329
|
+
afterDef = false;
|
|
330
|
+
k++;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
// A paragraph's continuation lines lose their indentation before definitions are
|
|
334
|
+
// read: ` [a]: x` right after another definition is one (CommonMark §4.8).
|
|
335
|
+
let view = blocks.paraStart[k] ? body(k) : body(k).replace(/^[ \t]+/, "");
|
|
336
|
+
if (/^ {0,3}\[(?!\^)[^\]\n]*\]:/.test(view) && view.includes("\t"))
|
|
337
|
+
unsure = true;
|
|
338
|
+
if (continues(k + 1))
|
|
339
|
+
view += `\n${body(k + 1)}`;
|
|
340
|
+
if (continues(k + 1) && continues(k + 2))
|
|
341
|
+
view += `\n${body(k + 2)}`;
|
|
342
|
+
const opened = TITLE_AT.exec(view);
|
|
343
|
+
if (opened && destination(unmask(opened[1])) && !closes(opened[3], opened[2]))
|
|
344
|
+
unsure = true;
|
|
345
|
+
const m = DEF_AT.exec(view);
|
|
346
|
+
const name = m ? unmask(m[1]) : "";
|
|
347
|
+
// A label holds something other than spaces, and at most 999 characters.
|
|
348
|
+
if (!m || !/\S/.test(name) || name.length > 999 || !destination(unmask(m[2]))) {
|
|
349
|
+
afterDef = false;
|
|
350
|
+
k++;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
const used = m[0].split("\n").length;
|
|
354
|
+
// A setext underline after a paragraph of only definitions isn't one in CommonMark:
|
|
355
|
+
// the definitions come out first, and `-` or `===` starts a new paragraph. The block
|
|
356
|
+
// pass doesn't read definitions, so such a page isn't polished.
|
|
357
|
+
if (k + used < lines.length && !blocks.para[k + used] && /^ {0,3}(?:=+|-+)[ \t]*$/.test(lines[k + used]))
|
|
358
|
+
unsure = true;
|
|
359
|
+
spans.push([at[k], at[k + used - 1] + lines[k + used - 1].length]);
|
|
360
|
+
defs.push({ label: m[1], dest: m[2], title: m[3] ?? "" });
|
|
361
|
+
afterDef = true;
|
|
362
|
+
k += used;
|
|
363
|
+
}
|
|
364
|
+
return { spans, defs, unsure };
|
|
365
|
+
}
|
|
366
|
+
// Code in reading order: a code block's mark, or an inline code span's.
|
|
367
|
+
const CODE_IN_ORDER = /\u0002|\u0004([\u{F0000}-\u{FFFFD}])/gu;
|
|
368
|
+
// Code blocks as CommonMark finds them, fenced and indented, in containers or not.
|
|
369
|
+
const splitFences = codeBlocks;
|
|
370
|
+
/**
|
|
371
|
+
* A parser for the `(destination "title")` after a `]`, as CommonMark parses it (null
|
|
372
|
+
* when it doesn't). `tab` says a tab sat around the destination or title (commonmark.js
|
|
373
|
+
* takes spaces only). Linear over a text: a title that ran to its paragraph's end
|
|
374
|
+
* without closing isn't scanned again from a later `](` (`[a](b (t` repeated took 1.8 s).
|
|
375
|
+
* A `<destination` scan stops at the next `<`, so it's linear already.
|
|
376
|
+
*/
|
|
377
|
+
function linkTails(text) {
|
|
378
|
+
const titleFail = {}; // no closer before this paragraph end
|
|
379
|
+
return (i) => linkTail(text, i, titleFail);
|
|
380
|
+
}
|
|
381
|
+
function linkTail(text, i, titleFail) {
|
|
382
|
+
let tab = false;
|
|
383
|
+
const space = (j) => {
|
|
384
|
+
const from = j;
|
|
385
|
+
while (j < text.length && /[ \t]/.test(text[j]))
|
|
386
|
+
j++;
|
|
387
|
+
if (text[j] === "\n")
|
|
388
|
+
j++;
|
|
389
|
+
while (j < text.length && /[ \t]/.test(text[j]))
|
|
390
|
+
j++;
|
|
391
|
+
if (text.slice(from, j).includes("\t"))
|
|
392
|
+
tab = true;
|
|
393
|
+
return j;
|
|
394
|
+
};
|
|
395
|
+
let j = space(i + 2);
|
|
396
|
+
let dest;
|
|
397
|
+
if (text[j] === "<") {
|
|
398
|
+
let k = j + 1;
|
|
399
|
+
for (; k < text.length && text[k] !== ">" && text[k] !== "\n" && text[k] !== "<"; k++)
|
|
400
|
+
if (text[k] === "\\")
|
|
401
|
+
k++;
|
|
402
|
+
if (text[k] !== ">")
|
|
403
|
+
return null;
|
|
404
|
+
dest = text.slice(j + 1, k);
|
|
405
|
+
j = k + 1;
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
const from = j;
|
|
409
|
+
let depth = 0;
|
|
410
|
+
for (; j < text.length; j++) {
|
|
411
|
+
const c = text[j];
|
|
412
|
+
if (c === "\\")
|
|
413
|
+
j++;
|
|
414
|
+
else if (/\s/.test(c))
|
|
415
|
+
break;
|
|
416
|
+
else if (c === "(") {
|
|
417
|
+
if (++depth > 32)
|
|
418
|
+
return null; // cmark's own limit; deeper isn't a link
|
|
419
|
+
}
|
|
420
|
+
else if (c === ")") {
|
|
421
|
+
if (depth === 0)
|
|
422
|
+
break;
|
|
423
|
+
depth--;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (depth !== 0)
|
|
427
|
+
return null;
|
|
428
|
+
dest = text.slice(from, j);
|
|
429
|
+
}
|
|
430
|
+
const afterDest = j;
|
|
431
|
+
j = space(j);
|
|
432
|
+
const opener = text[j];
|
|
433
|
+
let title = "";
|
|
434
|
+
if (j > afterDest && (opener === '"' || opener === "'" || opener === "(")) {
|
|
435
|
+
const close = opener === "(" ? ")" : opener;
|
|
436
|
+
if (j < (titleFail[close] ?? -1))
|
|
437
|
+
return null;
|
|
438
|
+
let k = j + 1;
|
|
439
|
+
// A title ends with its paragraph: it can't hold a blank line.
|
|
440
|
+
for (; k < text.length && text[k] !== close; k++) {
|
|
441
|
+
if (text[k] === "\\")
|
|
442
|
+
k++;
|
|
443
|
+
else if (text[k] === "\n" && /^[ \t]*(?:\n|$)/.test(text.slice(k + 1, k + 80))) {
|
|
444
|
+
titleFail[close] = k;
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (k >= text.length) {
|
|
449
|
+
titleFail[close] = k;
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
title = text.slice(j + 1, k);
|
|
453
|
+
j = space(k + 1);
|
|
454
|
+
}
|
|
455
|
+
return text[j] === ")" ? { end: j + 1, dest, title, tab } : null;
|
|
456
|
+
}
|
|
457
|
+
function inlineLinks(text) {
|
|
458
|
+
const out = [];
|
|
459
|
+
// A `](` is a link only after a `[` it closes (`](x)` alone is text).
|
|
460
|
+
const opened = bracketMatches(text);
|
|
461
|
+
const tail = linkTails(text);
|
|
462
|
+
let literalEnd = -1; // the unparsed literal already covers up to here
|
|
463
|
+
for (let i = text.indexOf("]("); i >= 0; i = text.indexOf("](", i + 1)) {
|
|
464
|
+
if (!opened.has(i))
|
|
465
|
+
continue;
|
|
466
|
+
const link = tail(i);
|
|
467
|
+
if (link) {
|
|
468
|
+
const { tab, ...rest } = link;
|
|
469
|
+
out.push({ start: i, ...rest, ...(tab ? { tab: true } : {}) });
|
|
470
|
+
i = link.end - 1; // a `](` inside a destination doesn't start another link
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
// Fail closed: what this scanner can't read is compared as written, to the line's
|
|
474
|
+
// end, so a form it misses can't hide a changed target. Scanning goes on after it:
|
|
475
|
+
// a later `](` may close an outer `[` and be the real link (CommonMark drops the
|
|
476
|
+
// failed opener; found by differential fuzzing).
|
|
477
|
+
if (i < literalEnd)
|
|
478
|
+
continue; // inside the literal already recorded
|
|
479
|
+
const eol = text.indexOf("\n", i);
|
|
480
|
+
const end = eol < 0 ? text.length : eol;
|
|
481
|
+
out.push({ start: i, end, dest: `unparsed ${text.slice(i, end)}`, title: "" });
|
|
482
|
+
literalEnd = end;
|
|
483
|
+
}
|
|
484
|
+
return out;
|
|
485
|
+
}
|
|
486
|
+
/** `text` with each inline link's `](…)` replaced, keeping the link text. */
|
|
487
|
+
function withoutDestinations(text, links, replacement) {
|
|
488
|
+
let out = "";
|
|
489
|
+
let at = 0;
|
|
490
|
+
for (const l of links) {
|
|
491
|
+
if (l.start < at)
|
|
492
|
+
continue;
|
|
493
|
+
out += text.slice(at, l.start) + replacement;
|
|
494
|
+
at = l.end;
|
|
495
|
+
}
|
|
496
|
+
return out + text.slice(at);
|
|
497
|
+
}
|
|
498
|
+
// An entity renders as its character: `not` is "not" to a reader, so decode it first.
|
|
499
|
+
// `−` and `+` too: they are signs, and a sign is part of a number.
|
|
500
|
+
// Not after an odd run of backslashes: `\5` is the text `5` (found by fuzzing).
|
|
501
|
+
const ENTITY = /(?<=(?:^|[^\\])(?:\\\\)*)&(#\d{1,7}|#x[0-9a-f]{1,6}|amp|lt|gt|quot|apos|nbsp|minus|plus);/gi;
|
|
502
|
+
const NAMED = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", minus: "\u2212", plus: "+" };
|
|
503
|
+
function decodeEntity(_whole, e) {
|
|
504
|
+
if (e[0] !== "#")
|
|
505
|
+
return NAMED[e.toLowerCase()];
|
|
506
|
+
const n = e[1] === "x" || e[1] === "X" ? Number.parseInt(e.slice(2), 16) : Number(e.slice(1));
|
|
507
|
+
return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : " ";
|
|
508
|
+
}
|
|
509
|
+
// A reference label as CommonMark matches it: spacing collapsed and Unicode case folded,
|
|
510
|
+
// so `[STRASSE]` uses `[straße]: …` (folded as commonmark.js does, lower then upper).
|
|
511
|
+
const label = (s) => s.trim().replace(/\s+/g, " ").toLowerCase().toUpperCase();
|
|
512
|
+
// Only the angle form loses its brackets: a bare `safe.md>` keeps its `>`.
|
|
513
|
+
const dest = (s) => (s.startsWith("<") && s.endsWith(">") ? s.slice(1, -1) : s);
|
|
514
|
+
/** Every link a reader can follow: inline targets, and reference uses resolved to theirs. */
|
|
515
|
+
/** The text a link shows: case, spacing and emphasis aside. */
|
|
516
|
+
// Emphasis markers only: an underscore inside a word (`prod_api`) is text.
|
|
517
|
+
// As written, spacing aside: case counts (`[US]` and `[us]` are different links), and
|
|
518
|
+
// so does every `*` (`delete *.log` isn't `delete .log`).
|
|
519
|
+
const shownText = (t) => t.replace(/\s+/g, " ").trim();
|
|
520
|
+
/**
|
|
521
|
+
* For every `]`, where its matching `[` is: one forward pass with a stack, honouring
|
|
522
|
+
* backslash escapes, and reset at a blank line (a label can't cross paragraphs). Linear,
|
|
523
|
+
* where looking back from each `](` was linear per link.
|
|
524
|
+
*/
|
|
525
|
+
function bracketMatches(text) {
|
|
526
|
+
const match = new Map();
|
|
527
|
+
const open = [];
|
|
528
|
+
for (let k = 0; k < text.length; k++) {
|
|
529
|
+
const c = text[k];
|
|
530
|
+
if (c === "\\")
|
|
531
|
+
k++;
|
|
532
|
+
else if (c === "[")
|
|
533
|
+
open.push(k);
|
|
534
|
+
else if (c === "]") {
|
|
535
|
+
const start = open.pop();
|
|
536
|
+
if (start !== undefined)
|
|
537
|
+
match.set(k, start);
|
|
538
|
+
}
|
|
539
|
+
else if (c === "\n" && text[k + 1] === "\n")
|
|
540
|
+
open.length = 0;
|
|
541
|
+
}
|
|
542
|
+
return match;
|
|
543
|
+
}
|
|
544
|
+
/** The visible text of the link whose closing `]` is at `i`, or "" without a match. */
|
|
545
|
+
const labelAt = (text, match, i) => {
|
|
546
|
+
const start = match.get(i);
|
|
547
|
+
return start === undefined ? "" : text.slice(start + 1, i);
|
|
548
|
+
};
|
|
549
|
+
/**
|
|
550
|
+
* Every link a reader can follow: inline targets, and reference uses resolved to theirs.
|
|
551
|
+
* `pairs` keeps each target with the text that shows it, so two links can't trade
|
|
552
|
+
* destinations without the change showing.
|
|
553
|
+
*/
|
|
554
|
+
function linkTargets(text, parsed, unmask) {
|
|
555
|
+
// A title shows on hover, so it's part of what a link says.
|
|
556
|
+
const titled = (t) => (t.trim() ? ` "${t.trim().replace(/\s+/g, " ")}"` : "");
|
|
557
|
+
const defs = new Map();
|
|
558
|
+
const defTitles = new Map();
|
|
559
|
+
// Only definitions CommonMark takes as such (definitionSpans), each read on its own.
|
|
560
|
+
// Code spans count as written: a definition is read before inline code, and a label,
|
|
561
|
+
// a destination or a title can hold backticks (`[`x`]: https://…`).
|
|
562
|
+
for (const d of parsed)
|
|
563
|
+
if (!defs.has(label(unmask(d.label)))) {
|
|
564
|
+
defs.set(label(unmask(d.label)), dest(unmask(d.dest)));
|
|
565
|
+
defTitles.set(label(unmask(d.label)), titled(unmask(d.title.replace(/^["'(]|["')]$/g, ""))));
|
|
566
|
+
}
|
|
567
|
+
const inline = inlineLinks(text);
|
|
568
|
+
const targets = inline.map((l) => dest(unmask(l.dest)));
|
|
569
|
+
const textMatch = bracketMatches(text);
|
|
570
|
+
// An image (``) loads by itself where a link waits for a click: it's marked.
|
|
571
|
+
// Unless the `!` is escaped (`\` is a link).
|
|
572
|
+
const bang = (t, open) => {
|
|
573
|
+
if (open === undefined || t[open - 1] !== "!")
|
|
574
|
+
return "";
|
|
575
|
+
let k = open - 2;
|
|
576
|
+
while (k >= 0 && t[k] === "\\")
|
|
577
|
+
k--;
|
|
578
|
+
return (open - 2 - k) % 2 ? "" : "!";
|
|
579
|
+
};
|
|
580
|
+
const pairs = inline.map((l) => `${bang(text, textMatch.get(l.start))}${shownText(unmask(labelAt(text, textMatch, l.start)))} → ${dest(unmask(l.dest))}${titled(unmask(l.title))}`);
|
|
581
|
+
// Reference uses, once inline links and definitions are out of the way. An unused
|
|
582
|
+
// definition shows nothing, and a use with no definition is plain text.
|
|
583
|
+
// (Definitions were blanked out of `text` already.)
|
|
584
|
+
// Only parsed links are cut: an unparsed `](` stays a literal fact above, and the
|
|
585
|
+
// reference uses after it are still read (`[r](n` then `[prod]`; found by fuzzing).
|
|
586
|
+
let rest = withoutDestinations(text, inline.filter((l) => !l.dest.startsWith("unparsed ")), "]\u0000");
|
|
587
|
+
// Full and collapsed references (`[text][label]`, `[label][]`), found like inline
|
|
588
|
+
// links: back from each `][` to the matching `[`, at any depth of nesting.
|
|
589
|
+
const uses = [];
|
|
590
|
+
const restMatch = bracketMatches(rest);
|
|
591
|
+
for (let i = rest.indexOf("]["); i >= 0; i = rest.indexOf("][", i + 1)) {
|
|
592
|
+
let k = i + 2;
|
|
593
|
+
for (; k < rest.length && rest[k] !== "]" && rest[k] !== "[" && !(rest[k] === "\n" && rest[k + 1] === "\n"); k++)
|
|
594
|
+
if (rest[k] === "\\")
|
|
595
|
+
k++;
|
|
596
|
+
if (rest[k] !== "]")
|
|
597
|
+
continue;
|
|
598
|
+
const shown = labelAt(rest, restMatch, i);
|
|
599
|
+
if (!shown && rest[i - 1] !== "[")
|
|
600
|
+
continue; // no opening bracket: not a reference
|
|
601
|
+
const key = label(unmask(rest.slice(i + 2, k) || shown));
|
|
602
|
+
const target = defs.get(key);
|
|
603
|
+
// Unresolved, it's text, and a shortcut inside it (`[r [a] x][]`) can still be a
|
|
604
|
+
// link (commonmark.js parseCloseBracket); so only a resolved one is taken out.
|
|
605
|
+
if (target === undefined)
|
|
606
|
+
continue;
|
|
607
|
+
targets.push(target);
|
|
608
|
+
pairs.push(`${bang(rest, restMatch.get(i))}${shownText(unmask(shown))} → ${target}${defTitles.get(key)}`);
|
|
609
|
+
uses.push([i - shown.length - 1, k + 1]);
|
|
610
|
+
i = k;
|
|
611
|
+
}
|
|
612
|
+
rest = cut(rest, uses, () => " ");
|
|
613
|
+
for (const m of rest.matchAll(SHORTCUT_REF)) {
|
|
614
|
+
const key = label(unmask(m[1]));
|
|
615
|
+
const target = defs.get(key);
|
|
616
|
+
if (target !== undefined) {
|
|
617
|
+
targets.push(target);
|
|
618
|
+
pairs.push(`${bang(rest, m.index)}${shownText(unmask(m[1]))} → ${target}${defTitles.get(key)}`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return { targets, pairs };
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* A bare URL as GFM links it: without the punctuation after it, or an unbalanced closing
|
|
625
|
+
* paren. GFM's autolink extension: "Trailing punctuation (specifically, ?, !, ., ,, :,
|
|
626
|
+
* *, _, and ~) will not be considered part of the autolink", and cmark-gfm's
|
|
627
|
+
* `autolink_delim` strips them (and `;`). So `…/runbook_` and `…/runbook~` both link to
|
|
628
|
+
* `…/runbook`: only a literal character after the link differs, which is prose.
|
|
629
|
+
*/
|
|
630
|
+
function trimUrl(u) {
|
|
631
|
+
// Parentheses counted once (recounting per `)` was quadratic on `https://x/)))…`).
|
|
632
|
+
let open = 0;
|
|
633
|
+
let close = 0;
|
|
634
|
+
for (const c of u)
|
|
635
|
+
if (c === "(")
|
|
636
|
+
open++;
|
|
637
|
+
else if (c === ")")
|
|
638
|
+
close++;
|
|
639
|
+
let end = u.length;
|
|
640
|
+
for (;;) {
|
|
641
|
+
const c = u[end - 1];
|
|
642
|
+
if (c === ")" && close > open) {
|
|
643
|
+
close--;
|
|
644
|
+
end--;
|
|
645
|
+
}
|
|
646
|
+
else if (c !== undefined && "?!.,:*_~'\"".includes(c))
|
|
647
|
+
end--;
|
|
648
|
+
else if (c === ";") {
|
|
649
|
+
// GFM's autolinker reads decoded text: a real entity (`&`, `.`) is its
|
|
650
|
+
// character there, which is then trimmed like any other (`.` goes, `&` stays).
|
|
651
|
+
// cmark-gfm drops an entity-like tail it sees raw (`&hl;`, the GFM spec's example);
|
|
652
|
+
// an entity this can't decode is kept, so the URL here is never shorter than
|
|
653
|
+
// GitHub's (found by differential fuzzing and the review).
|
|
654
|
+
let k = end - 2;
|
|
655
|
+
while (k >= 0 && /[a-zA-Z0-9#]/.test(u[k]))
|
|
656
|
+
k--;
|
|
657
|
+
const name = u.slice(k + 1, end - 1);
|
|
658
|
+
if (!(k >= 0 && u[k] === "&" && name))
|
|
659
|
+
end--;
|
|
660
|
+
else {
|
|
661
|
+
const num = /^#(\d{1,7}|x[0-9a-f]{1,6})$/i.exec(name);
|
|
662
|
+
const code = num ? (num[1][0].toLowerCase() === "x" ? Number.parseInt(num[1].slice(1), 16) : Number(num[1])) : null;
|
|
663
|
+
const ch = code !== null ? (code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : "\ufffd") : Object.hasOwn(NAMED, name) ? NAMED[name] : null;
|
|
664
|
+
if (ch === null)
|
|
665
|
+
return u.slice(0, end);
|
|
666
|
+
u = u.slice(0, k) + ch;
|
|
667
|
+
end = u.length;
|
|
668
|
+
if (ch === "(")
|
|
669
|
+
open++;
|
|
670
|
+
else if (ch === ")")
|
|
671
|
+
close++;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
else
|
|
675
|
+
return u.slice(0, end);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
/** What the guard compares, taken from one version of the page. */
|
|
679
|
+
export function facts(text) {
|
|
680
|
+
const fm = frontmatter(text);
|
|
681
|
+
// Line endings are formatting: compare the body with LF throughout.
|
|
682
|
+
const body = text.slice(fm.length).replace(/\r\n?/g, "\n");
|
|
683
|
+
// Fences first: a comment inside code is code. Then comments, outside code only; one
|
|
684
|
+
// that wraps a fenced block hides it, which a polish may neither do nor be checked
|
|
685
|
+
// around (`commentHidesCode`).
|
|
686
|
+
const split = splitFences(body);
|
|
687
|
+
const fences = split.fences;
|
|
688
|
+
// Everything below reads each line's content, past its quote and list markers, as
|
|
689
|
+
// CommonMark's inline parsing does: a link's text or a code span that wraps inside a
|
|
690
|
+
// quote doesn't take the `> ` with it (found by differential fuzzing).
|
|
691
|
+
const view = split.prose
|
|
692
|
+
.split("\n")
|
|
693
|
+
.map((l, i) => l.slice(split.content[i] ?? 0).replace(/^[ \t]+$/, ""))
|
|
694
|
+
.join("\n");
|
|
695
|
+
// Reference definitions render as nothing: read them for their targets, then blank
|
|
696
|
+
// them out of everything a reader sees (a command moved into an unused definition's
|
|
697
|
+
// title has left the page). They're block structure, read before any inline
|
|
698
|
+
// construct: in `[a]: ```sh` the backticks are the destination, and can't pair with
|
|
699
|
+
// a backtick on the next line into a code span (found by differential fuzzing).
|
|
700
|
+
const raw = (s) => s;
|
|
701
|
+
const defs = definitionSpans(view, raw, split);
|
|
702
|
+
const defined = new Set(defs.defs.map((d) => label(d.label)));
|
|
703
|
+
const tokens = inlineTokens(cut(view, defs.spans, (d) => d.replace(/[^\n]/g, "")));
|
|
704
|
+
const { comments, commentHidesCode } = tokens;
|
|
705
|
+
const unmask = (s) => s.replace(CODE_REF, (_, c) => tokens.raws[c.codePointAt(0) - 0xf0000]);
|
|
706
|
+
const prose = tokens.masked;
|
|
707
|
+
// Inline code still on the page (a line break inside a span renders as a space, so
|
|
708
|
+
// rewrapping one is a polish).
|
|
709
|
+
const inline = [...prose.matchAll(CODE_REF)].map((m) => tokens.codes[m[1].codePointAt(0) - 0xf0000]);
|
|
710
|
+
// All code in reading order, fenced and inline: running `drain` and `delete` in each
|
|
711
|
+
// other's step is a different runbook. (Per section wouldn't do: two sections trading
|
|
712
|
+
// commands looks the same as two sections trading places, headings and all.)
|
|
713
|
+
let fenceAt = 0;
|
|
714
|
+
const codeOf = (m) => (m[0] === FENCE_MARK ? `fence ${fences[fenceAt++]}` : `code ${tokens.codes[m[1].codePointAt(0) - 0xf0000]}`);
|
|
715
|
+
// And the code under each heading, keyed by its text: a command moved from "Steps"
|
|
716
|
+
// to "Rollback" keeps the page's order but not its meaning. Only headings both
|
|
717
|
+
// versions have are compared, so adding or rewording a heading is still a polish.
|
|
718
|
+
// A heading's code includes its subsections', so adding `## Steps` under an existing
|
|
719
|
+
// heading is still a polish.
|
|
720
|
+
const codeBySection = new Map();
|
|
721
|
+
const order = [];
|
|
722
|
+
const open = [];
|
|
723
|
+
const seen = new Map();
|
|
724
|
+
// Quote and list markers off first, and any indentation allowed: `> ## Steps`,
|
|
725
|
+
// `- ## Steps` and a heading on a deep list item's continuation line are headings too
|
|
726
|
+
// (merge gate, PR 3, round 34). A setext heading (`Steps` over `=====` or `-----`) is
|
|
727
|
+
// a heading too. Taking a line for a heading that CommonMark wouldn't only adds a
|
|
728
|
+
// section to compare: stricter, never blinder.
|
|
729
|
+
const atx = prose
|
|
730
|
+
.replace(/^(?:[ \t]*(?:>[ \t]?|(?:[-*+]|\d{1,9}[.)])[ \t]+))+/gm, "")
|
|
731
|
+
.replace(/^([ \t]*\S[^\n]*)\n[ \t]*(=+|-+)[ \t]*$/gm, (whole, text, bar) => /^[ \t]*#/.test(text) ? whole : `${bar[0] === "=" ? "#" : "##"} ${text.trim()}`);
|
|
732
|
+
for (const section of atx.split(/^(?=[ \t]*#{1,6}(?:[ \t]|$))/m)) {
|
|
733
|
+
const heading = /^[ \t]*(#{1,6})[ \t]*(.*)$/m.exec(section);
|
|
734
|
+
if (heading && section.startsWith(heading[0])) {
|
|
735
|
+
const level = heading[1].length;
|
|
736
|
+
while (open.length && open.at(-1).level >= level)
|
|
737
|
+
open.pop();
|
|
738
|
+
// Keyed as written (spacing and a closing `##` aside, code spans restored) and
|
|
739
|
+
// numbered when it repeats: no two headings share a key, so code can't move
|
|
740
|
+
// between them unseen (`prod_api` and `prodapi`, or two `Steps`; merge gate, PR 3,
|
|
741
|
+
// round 35). Restyling a heading stops its section being compared, as rewording
|
|
742
|
+
// one does. Repeats are compared occurrence by occurrence only while both versions
|
|
743
|
+
// have as many, so renaming one of two `Steps` unshares it the same way.
|
|
744
|
+
const text = unmask(heading[2]).replace(/(?:^|[ \t]+)#+[ \t]*$/, "").replace(/\s+/g, " ").trim();
|
|
745
|
+
let key = text;
|
|
746
|
+
if (text) {
|
|
747
|
+
const n = (seen.get(text) ?? 0) + 1;
|
|
748
|
+
seen.set(text, n);
|
|
749
|
+
key = `${text}\u0001${n}`;
|
|
750
|
+
}
|
|
751
|
+
open.push({ level, key });
|
|
752
|
+
}
|
|
753
|
+
const seq = [...section.matchAll(CODE_IN_ORDER)].map(codeOf);
|
|
754
|
+
order.push(...seq);
|
|
755
|
+
for (const { key } of open) {
|
|
756
|
+
if (!key)
|
|
757
|
+
continue;
|
|
758
|
+
const list = codeBySection.get(key) ?? [];
|
|
759
|
+
if (!codeBySection.has(key))
|
|
760
|
+
codeBySection.set(key, list);
|
|
761
|
+
for (const c of seq)
|
|
762
|
+
list.push(c);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
const codeOrder = order.join("\u0001");
|
|
766
|
+
// An ordered-list number where CommonMark opens an item is a container marker, not in
|
|
767
|
+
// the content view; anywhere else (`retry\n3. times`) it's text, and counted.
|
|
768
|
+
const noCode = prose.replace(CODE_REF, " ");
|
|
769
|
+
// Links read code spans as written, so `[`a`](u)` and `[`b`](u)` say different things.
|
|
770
|
+
const { targets, pairs: linkPairs } = linkTargets(prose, defs.defs, unmask);
|
|
771
|
+
const links = [...targets, ...[...noCode.matchAll(AUTOLINK)].map((m) => m[1]), ...[...noCode.matchAll(HTML_LINK)].map((m) => m[1] ?? m[2] ?? m[3])];
|
|
772
|
+
const scanned = inlineLinks(noCode);
|
|
773
|
+
const linkTab = scanned.some((l) => l.tab);
|
|
774
|
+
// Parsed links only: an unparsed `](` is a literal fact above, and its text stays in
|
|
775
|
+
// what's read for HTML and words (`[p](rod]: <prod` then `page.md>` on the next line
|
|
776
|
+
// is a raw tag; found by differential fuzzing).
|
|
777
|
+
const noLinks = withoutDestinations(noCode, scanned.filter((l) => !l.dest.startsWith("unparsed ")), "] ");
|
|
778
|
+
// Bare URLs, emails and www. links, outside autolinks and anchors (counted as links).
|
|
779
|
+
const bare = noLinks.replace(AUTOLINK, " ").replace(HTML_LINK, " ");
|
|
780
|
+
const urls = [...bare.matchAll(URL), ...bare.replace(URL, " ").matchAll(WWW), ...bare.replace(URL, " ").replace(WWW, " ").matchAll(BARE_EMAIL)].map((m) => trimUrl(m[0]));
|
|
781
|
+
// Tags outside code and link destinations (`[a](<x y>)` isn't HTML; autolinks look
|
|
782
|
+
// like tags, and are links).
|
|
783
|
+
const htmlText = noLinks.replace(AUTOLINK, " ");
|
|
784
|
+
// Closed comments were cut out above, so an opener left here is real raw HTML.
|
|
785
|
+
const openers = [...htmlText.matchAll(OPENER)].map((m) => m[0].toLowerCase());
|
|
786
|
+
const special = [...spans(htmlText, "<?", "?>"), ...spans(htmlText, "<![CDATA[", "]]>")].sort((x, y) => x[0] - y[0]);
|
|
787
|
+
const tags = [...special.map(([i, j]) => htmlText.slice(i, j)), ...[...htmlText.matchAll(TAG)].map((m) => m[0])].map((t) => t.replace(/\s+/g, " ").toLowerCase());
|
|
788
|
+
// Characters that look like ASCII or are invisible: compared as a set, so a polish
|
|
789
|
+
// can't spell "n\u043et" with a Cyrillic o, or hide a zero-width space in a word.
|
|
790
|
+
// Entities decoded first: `​` is a zero-width space too.
|
|
791
|
+
const decoded = body.replace(ENTITY, decodeEntity);
|
|
792
|
+
const unusual = [...new Set(decoded.match(UNUSUAL) ?? [])].sort();
|
|
793
|
+
// Invisible characters are counted, not just noted: one already on the page doesn't
|
|
794
|
+
// license another.
|
|
795
|
+
const invisibles = decoded.match(/[\p{Cf}\p{Cc}\u00ad]/gu)?.filter((c) => !"\t\n\r".includes(c)) ?? [];
|
|
796
|
+
// Named entities beyond the few decoded above (`−` renders as a sign): this check
|
|
797
|
+
// can't read them, so a polish may not add one.
|
|
798
|
+
// Each with what follows it, so moving one onto another word or number is a change.
|
|
799
|
+
const entities = [...body.matchAll(/&([a-z][a-z0-9]{1,31});([^\s<>&]{0,32})/gi)].filter((m) => !(m[1].toLowerCase() in NAMED)).map((m) => m[0]);
|
|
800
|
+
const struck = [...noLinks.matchAll(STRUCK)].map((m) => (m[1] ?? m[3] ?? "").trim().replace(/\s+/g, " "));
|
|
801
|
+
// Numbers and words in running text only: not inside code or URLs, and not list markers.
|
|
802
|
+
// Link text reads as text: `5 [minutes](units.md)` pairs 5 with minutes.
|
|
803
|
+
const hidden = [...spans(noLinks, "<?", "?>"), ...spans(noLinks, "<![CDATA[", "]]>")].sort((x, y) => x[0] - y[0]);
|
|
804
|
+
const unmarked = cut(noLinks, hidden, () => " ")
|
|
805
|
+
// Links first, on the same text the URL facts read: `[https://x/5` isn't a GFM link
|
|
806
|
+
// (a `[` before it), so its `5` stays text (found by differential fuzzing).
|
|
807
|
+
.replace(AUTOLINK, " ")
|
|
808
|
+
.replace(HTML_LINK, " ")
|
|
809
|
+
.replace(URL, " ")
|
|
810
|
+
.replace(WWW, " ")
|
|
811
|
+
// A full reference shows its text; its label shows too when it resolves to nothing
|
|
812
|
+
// (`[run][never]` with no definition is all text in CommonMark).
|
|
813
|
+
.replace(FULL_REF, (whole, text, lab) => (defined.has(label(lab || text)) ? ` ${text} ` : whole))
|
|
814
|
+
// A bracket that isn't a link's is text: `202[4` isn't `2024`, nor `no[t` `not`.
|
|
815
|
+
.replace(/!?\[|\]/g, " ")
|
|
816
|
+
.replace(TAG, " ");
|
|
817
|
+
const words = unmarked.replace(EMPHASIS, "").replace(ENTITY, decodeEntity);
|
|
818
|
+
// The same text with each emphasis marker read as a break. An unclosed `**` is literal
|
|
819
|
+
// in CommonMark (`no**t` isn't `not`), and closed ones read either way, so counts are
|
|
820
|
+
// compared both ways (found by differential fuzzing against commonmark.js).
|
|
821
|
+
const spaced = unmarked.replace(EMPHASIS, " ").replace(ENTITY, decodeEntity);
|
|
822
|
+
// Every run of digits in the text, whatever it's attached to (`on-c3`, `ra5te`):
|
|
823
|
+
// a net under the number tokens, which pair a number with its unit.
|
|
824
|
+
const digitRuns = (t) => [...t.matchAll(/\p{Nd}+/gu)].map((m) => m[0]);
|
|
825
|
+
const digits = digitRuns(words);
|
|
826
|
+
const digitsSpaced = digitRuns(spaced);
|
|
827
|
+
// Compared as `5 minute`, named as the page wrote it (`5 minutes`).
|
|
828
|
+
const shown = new Map();
|
|
829
|
+
const numbers = [...words.matchAll(NUMBER)].map((m) => {
|
|
830
|
+
// As written: an attached unit (`500MB`) is part of the token, and its case counts.
|
|
831
|
+
const n = m[1].replace("\u2212", "-");
|
|
832
|
+
const word = /^[a-z]/i.test(n) ? undefined : m[2];
|
|
833
|
+
const key = `${n}${word ? ` ${unit(word)}` : ""}`;
|
|
834
|
+
if (!shown.has(key))
|
|
835
|
+
shown.set(key, `${n}${word ? ` ${word}` : ""}`);
|
|
836
|
+
return key;
|
|
837
|
+
});
|
|
838
|
+
// Words with digits in them (`TLSv1.2`, `IPv4`, `RHEL8`, `p99`): a version or a size
|
|
839
|
+
// as much as any number is.
|
|
840
|
+
const digitWords = [...words.matchAll(/(?<![\w.-])[a-z][\w\p{Nd}]*\p{Nd}[\w.\p{Nd}]*/giu)].map((m) => m[0].replace(/\.+$/, "").toLowerCase());
|
|
841
|
+
const count = (t) => {
|
|
842
|
+
const lower = t.toLowerCase().replace(/[\u2018\u2019]/g, "'");
|
|
843
|
+
return [
|
|
844
|
+
...NEGATIONS.map((w) => `${w}×${(lower.match(new RegExp(`(?<![\\w'])${w}(?![\\w'])`, "g")) ?? []).length}`),
|
|
845
|
+
// Every other "n't" (didn't, wasn't, wouldn't, …), counted together.
|
|
846
|
+
`n't×${(lower.match(/\b[a-z]+n't\b/g) ?? []).filter((w) => !NEGATIONS.includes(w)).length}`,
|
|
847
|
+
];
|
|
848
|
+
};
|
|
849
|
+
const negations = count(words);
|
|
850
|
+
const negationsSpaced = count(spaced);
|
|
851
|
+
// A task list's boxes: ticking one says a step is done.
|
|
852
|
+
// Each with its task's text: tasks may move with their boxes, but a tick may not move
|
|
853
|
+
// to another task (so a task's text stays as written).
|
|
854
|
+
// A task's text runs on over its indented continuation lines, up to a blank line or
|
|
855
|
+
// the next item. Any quote and list markers may come first: `- - [ ] x` is a task in
|
|
856
|
+
// a nested list (merge gate, PR 3, round 37). Each space has one place to match.
|
|
857
|
+
const lines = prose.split("\n");
|
|
858
|
+
const boxes = [];
|
|
859
|
+
for (let k = 0; k < lines.length; k++) {
|
|
860
|
+
// A task is a list item whose first paragraph starts with its box: on the item's
|
|
861
|
+
// marker line, or on the next when the marker stood alone (`-` then ` [ ] x`).
|
|
862
|
+
// The paragraph's own indentation comes off first (`-` then ` [ ] x`: the item takes
|
|
863
|
+
// two columns, CommonMark strips the third; merge gate, PR 10, round 1).
|
|
864
|
+
const m = split.itemPara[k] ? /^[ \t]*\[([ xX])\](?:[ \t]+|$)/.exec(lines[k]) : null;
|
|
865
|
+
if (!m)
|
|
866
|
+
continue;
|
|
867
|
+
let text = lines[k].slice(m[0].length);
|
|
868
|
+
for (let n = k + 1; split.para[n] && !split.paraStart[n]; n++)
|
|
869
|
+
text += `\n${lines[n]}`;
|
|
870
|
+
boxes.push(`${m[1] === " " ? "[ ]" : "[x]"} ${shownText(unmask(text))}`);
|
|
871
|
+
}
|
|
872
|
+
return { frontmatter: fm, fences, comments, tags, openers, commentHidesCode, unsureDefinitions: defs.unsure, linkTab, unusual, invisibles, entities, boxes, linkPairs, codeOrder, codeBySection, headingCounts: seen, digitWords, digits, digitsSpaced, inline, links, urls, struck, numbers, shown, negations, negationsSpaced };
|
|
873
|
+
}
|
|
874
|
+
function multisetDiff(before, after) {
|
|
875
|
+
const count = new Map();
|
|
876
|
+
for (const x of before)
|
|
877
|
+
count.set(x, (count.get(x) ?? 0) + 1);
|
|
878
|
+
const added = [];
|
|
879
|
+
for (const x of after) {
|
|
880
|
+
const n = count.get(x) ?? 0;
|
|
881
|
+
if (n > 0)
|
|
882
|
+
count.set(x, n - 1);
|
|
883
|
+
else
|
|
884
|
+
added.push(x);
|
|
885
|
+
}
|
|
886
|
+
const removed = [...count].flatMap(([x, n]) => Array.from({ length: n }, () => x));
|
|
887
|
+
return { removed, added };
|
|
888
|
+
}
|
|
889
|
+
const short = (s) => (s.length > 60 ? `${s.slice(0, 57)}…` : s);
|
|
890
|
+
/** Larger pages aren't polished: some patterns here cost more than linear time. */
|
|
891
|
+
export const POLISH_MAX_CHARS = 64 * 1024;
|
|
892
|
+
/** Every change the guard sees between two versions, in words. Empty means it passes. */
|
|
893
|
+
export function polishChanges(before, after) {
|
|
894
|
+
if (before.length > POLISH_MAX_CHARS || after.length > POLISH_MAX_CHARS)
|
|
895
|
+
return [`is over ${POLISH_MAX_CHARS / 1024} KB, too large to check as a polish; propose specific changes with \`edits\` instead`];
|
|
896
|
+
// This check marks fences and code spans with U+0000 to U+0004. A page holding one
|
|
897
|
+
// could pass it off as a fence (a block moves one way, the character the other), so
|
|
898
|
+
// such a page isn't polished (found alongside merge gate round 39).
|
|
899
|
+
if (/[\u0000-\u0004]/.test(before) || /[\u0000-\u0004]/.test(after))
|
|
900
|
+
return ["the page contains a control character (U+0000 to U+0004) that this check uses as its own mark; propose specific changes with `edits` instead"];
|
|
901
|
+
const a = facts(before);
|
|
902
|
+
const b = facts(after);
|
|
903
|
+
const out = [];
|
|
904
|
+
// Task boxes in order: which step is ticked matters, not only how many are.
|
|
905
|
+
// Where a tag sits decides what it hides (`<span hidden>…</span>`), and comparing
|
|
906
|
+
// positions would mean rendering the page. A page with raw HTML isn't polished.
|
|
907
|
+
if (a.commentHidesCode || b.commentHidesCode)
|
|
908
|
+
out.push("an HTML comment wraps a code block, hiding it; this check can't compare around that, so propose specific changes with `edits` instead");
|
|
909
|
+
if (a.linkTab || b.linkTab)
|
|
910
|
+
out.push("a link has a tab around its destination or title, which CommonMark implementations read differently (cmark takes it, commonmark.js doesn't); propose specific changes with `edits` instead");
|
|
911
|
+
if (a.unsureDefinitions || b.unsureDefinitions)
|
|
912
|
+
out.push("the page has a reference definition (`[label]: …`) in a form CommonMark implementations read differently, or this check doesn't read (a tab between its parts, a label or title spanning lines, a heading underline right after it); propose specific changes with `edits` instead");
|
|
913
|
+
if (a.tags.length || a.openers.length)
|
|
914
|
+
out.push("the page contains raw HTML, and where its tags sit decides what a reader sees, which this check can't compare; propose specific changes with `edits` instead");
|
|
915
|
+
if (a.frontmatter !== b.frontmatter)
|
|
916
|
+
out.push("changed the frontmatter (polish leaves it byte-for-byte)");
|
|
917
|
+
const fenceDiff = multisetDiff(a.fences, b.fences);
|
|
918
|
+
if (fenceDiff.removed.length || fenceDiff.added.length)
|
|
919
|
+
out.push("changed a code block");
|
|
920
|
+
const inlineDiff = multisetDiff(a.inline, b.inline);
|
|
921
|
+
// Only when the code itself is unchanged: otherwise the messages below say what changed.
|
|
922
|
+
if (!fenceDiff.removed.length && !fenceDiff.added.length && !inlineDiff.removed.length && !inlineDiff.added.length && a.codeOrder !== b.codeOrder)
|
|
923
|
+
out.push("changed the order of the page's code (commands keep their order; move only sections that have none)");
|
|
924
|
+
for (const [heading, code] of a.codeBySection) {
|
|
925
|
+
const now = b.codeBySection.get(heading);
|
|
926
|
+
const base = heading.replace(/\u0001\d+$/, "");
|
|
927
|
+
if (a.headingCounts.get(base) !== b.headingCounts.get(base))
|
|
928
|
+
continue; // a repeat renamed or added
|
|
929
|
+
const name = heading.replace(/\u0001(\d+)/g, (_, n) => (n === "1" ? "" : ` (${n})`));
|
|
930
|
+
if (now && now.join("\u0001") !== code.join("\u0001"))
|
|
931
|
+
out.push(`moved code into or out of the "${short(name)}" section`);
|
|
932
|
+
}
|
|
933
|
+
for (const [what, x, y] of [
|
|
934
|
+
["HTML comment", a.comments, b.comments],
|
|
935
|
+
["HTML tag", a.tags, b.tags],
|
|
936
|
+
["raw HTML opener", a.openers, b.openers],
|
|
937
|
+
["inline code", a.inline, b.inline],
|
|
938
|
+
// One multiset: `<https://x>` rewritten as a bare `https://x` is the same target.
|
|
939
|
+
["link", [...a.links, ...a.urls], [...b.links, ...b.urls]],
|
|
940
|
+
["struck-through text", a.struck, b.struck],
|
|
941
|
+
["link text", a.linkPairs, b.linkPairs],
|
|
942
|
+
["task", a.boxes, b.boxes],
|
|
943
|
+
// Entities this check can't read (`−` is a sign): added or removed, either
|
|
944
|
+
// could change what a number says.
|
|
945
|
+
["entity", a.entities, b.entities],
|
|
946
|
+
["number", a.numbers, b.numbers],
|
|
947
|
+
["word with digits", a.digitWords, b.digitWords],
|
|
948
|
+
]) {
|
|
949
|
+
const d = multisetDiff([...x], [...y]);
|
|
950
|
+
const as = (f, v) => (what === "number" ? (f.shown.get(v) ?? v) : v);
|
|
951
|
+
for (const r of d.removed)
|
|
952
|
+
out.push(`removed ${what} \`${short(as(a, r))}\``);
|
|
953
|
+
for (const n of d.added)
|
|
954
|
+
out.push(`added ${what} \`${short(as(b, n))}\``);
|
|
955
|
+
}
|
|
956
|
+
// A new invisible character, or a letter from a script the page doesn't use (a
|
|
957
|
+
// Cyrillic о in an English word). A new letter of a script already there is just
|
|
958
|
+
// rewording a page written in it. Dropping a stray character is fine.
|
|
959
|
+
const scripts = new Set(["Latin", ...a.unusual.map(scriptOf).filter((x) => Boolean(x))]);
|
|
960
|
+
for (const c of multisetDiff(a.invisibles, b.invisibles).added)
|
|
961
|
+
out.push(`added the character U+${c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}, which reads like another or not at all`);
|
|
962
|
+
for (const c of b.unusual) {
|
|
963
|
+
if (a.unusual.includes(c) || /[\p{Cf}\p{Cc}\u00ad]/u.test(c))
|
|
964
|
+
continue; // invisibles: counted above
|
|
965
|
+
const script = /\p{Cf}/u.test(c) ? undefined : scriptOf(c);
|
|
966
|
+
if (!script || !scripts.has(script))
|
|
967
|
+
out.push(`added the character U+${c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}, which reads like another or not at all`);
|
|
968
|
+
}
|
|
969
|
+
// Digits the number tokens didn't pair (`on-c3`, `ra5te`), read both ways.
|
|
970
|
+
const tokensSaw = out.some((m) => /(?:number|word with digits) `/.test(m));
|
|
971
|
+
if (!tokensSaw)
|
|
972
|
+
for (const [x, y] of [
|
|
973
|
+
[a.digits, b.digits],
|
|
974
|
+
[a.digitsSpaced, b.digitsSpaced],
|
|
975
|
+
]) {
|
|
976
|
+
const d = multisetDiff([...x], [...y]);
|
|
977
|
+
if (d.removed.length || d.added.length) {
|
|
978
|
+
out.push(`changed digits in the text${d.removed.length ? `: removed \`${d.removed.join(" ")}\`` : ""}${d.added.length ? `${d.removed.length ? "," : ":"} added \`${d.added.join(" ")}\`` : ""}`);
|
|
979
|
+
break;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
for (const [x, y] of [
|
|
983
|
+
[a.negations, b.negations],
|
|
984
|
+
[a.negationsSpaced, b.negationsSpaced],
|
|
985
|
+
]) {
|
|
986
|
+
const was = Object.fromEntries(x.map((v) => v.split("×")));
|
|
987
|
+
const now = Object.fromEntries(y.map((v) => v.split("×")));
|
|
988
|
+
const changed = [...NEGATIONS, "n't"].filter((w) => was[w] !== now[w]);
|
|
989
|
+
for (const w of changed)
|
|
990
|
+
out.push(`changed how often "${w}" appears (${was[w]} → ${now[w]})`);
|
|
991
|
+
if (changed.length)
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
return out;
|
|
995
|
+
}
|
|
996
|
+
/** What the guard covered, for the PR body. */
|
|
997
|
+
export function polishSummary(text) {
|
|
998
|
+
const f = facts(text);
|
|
999
|
+
return `${f.fences.length} code block(s), ${f.inline.length} inline code span(s), ${f.links.length} link(s), ${f.urls.length} URL(s) and ${f.numbers.length} number(s) unchanged; frontmatter identical`;
|
|
1000
|
+
}
|