@descent-vtt/spec-graph 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +627 -0
- package/bin/spec-graph.js +24 -0
- package/dist/baseline.d.ts +93 -0
- package/dist/baseline.js +187 -0
- package/dist/baseline.js.map +1 -0
- package/dist/cli.d.ts +66 -0
- package/dist/cli.js +565 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +66 -0
- package/dist/config.js +183 -0
- package/dist/config.js.map +1 -0
- package/dist/directives.d.ts +56 -0
- package/dist/directives.js +125 -0
- package/dist/directives.js.map +1 -0
- package/dist/extract.d.ts +116 -0
- package/dist/extract.js +1043 -0
- package/dist/extract.js.map +1 -0
- package/dist/glob.d.ts +73 -0
- package/dist/glob.js +284 -0
- package/dist/glob.js.map +1 -0
- package/dist/graph.d.ts +55 -0
- package/dist/graph.js +201 -0
- package/dist/graph.js.map +1 -0
- package/dist/identity.d.ts +87 -0
- package/dist/identity.js +294 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +45 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle.d.ts +57 -0
- package/dist/lifecycle.js +258 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/markdown.d.ts +152 -0
- package/dist/markdown.js +820 -0
- package/dist/markdown.js.map +1 -0
- package/dist/paths.d.ts +22 -0
- package/dist/paths.js +58 -0
- package/dist/paths.js.map +1 -0
- package/dist/report.d.ts +89 -0
- package/dist/report.js +326 -0
- package/dist/report.js.map +1 -0
- package/dist/resolve.d.ts +61 -0
- package/dist/resolve.js +340 -0
- package/dist/resolve.js.map +1 -0
- package/dist/rules.d.ts +61 -0
- package/dist/rules.js +557 -0
- package/dist/rules.js.map +1 -0
- package/dist/runner.d.ts +108 -0
- package/dist/runner.js +186 -0
- package/dist/runner.js.map +1 -0
- package/dist/sections.d.ts +94 -0
- package/dist/sections.js +372 -0
- package/dist/sections.js.map +1 -0
- package/dist/select.d.ts +96 -0
- package/dist/select.js +413 -0
- package/dist/select.js.map +1 -0
- package/dist/source.d.ts +43 -0
- package/dist/source.js +109 -0
- package/dist/source.js.map +1 -0
- package/dist/state.d.ts +45 -0
- package/dist/state.js +330 -0
- package/dist/state.js.map +1 -0
- package/dist/types.d.ts +294 -0
- package/dist/types.js +52 -0
- package/dist/types.js.map +1 -0
- package/dist/yaml.d.ts +37 -0
- package/dist/yaml.js +240 -0
- package/dist/yaml.js.map +1 -0
- package/package.json +71 -0
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A structural Markdown scanner.
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately **not** a CommonMark implementation. spec-graph needs six
|
|
5
|
+
* things from a document - front matter, headings, list items with their
|
|
6
|
+
* continuations, HTML comments, links, and a reliable map of what is code - and
|
|
7
|
+
* a full parser would cost a dependency tree, an AST walk, and a position
|
|
8
|
+
* mapping layer to get back the offsets we want in the first place.
|
|
9
|
+
*
|
|
10
|
+
* What the scanner does guarantee is the part that makes regex-based spec
|
|
11
|
+
* linters lie:
|
|
12
|
+
*
|
|
13
|
+
* - **Code never counts.** Fenced blocks, indented blocks and inline spans are
|
|
14
|
+
* masked before a single link is extracted, so an example in a code sample
|
|
15
|
+
* never becomes a graph edge.
|
|
16
|
+
* - **Items are blocks, not lines.** A list item owns its wrapped continuation
|
|
17
|
+
* lines and its nested content, so a resolution written on the second line of
|
|
18
|
+
* a bullet is found instead of missed.
|
|
19
|
+
* - **Everything carries absolute offsets.** No re-scanning, no drift between
|
|
20
|
+
* the text a rule matched and the position it reports.
|
|
21
|
+
*/
|
|
22
|
+
import { createLineIndex } from './source.js';
|
|
23
|
+
/* -------------------------------------------------------------------------- */
|
|
24
|
+
/* Entry point */
|
|
25
|
+
/* -------------------------------------------------------------------------- */
|
|
26
|
+
export function scanMarkdown(source) {
|
|
27
|
+
const text = source.charCodeAt(0) === 0xfeff ? source.slice(1) : source;
|
|
28
|
+
const index = createLineIndex(text);
|
|
29
|
+
const frontMatter = readFrontMatter(text, index);
|
|
30
|
+
const bodyStart = frontMatter ? frontMatter.bodyStart : 0;
|
|
31
|
+
const lines = scanLines(text, index, bodyStart);
|
|
32
|
+
const codeRanges = collectCodeRanges(lines, bodyStart);
|
|
33
|
+
const comments = scanComments(text, index, bodyStart, codeRanges);
|
|
34
|
+
const inlineCode = scanInlineCode(text, bodyStart, codeRanges, comments);
|
|
35
|
+
const maskRanges = [
|
|
36
|
+
...(frontMatter ? [{ start: 0, end: frontMatter.bodyStart }] : []),
|
|
37
|
+
...codeRanges,
|
|
38
|
+
...inlineCode,
|
|
39
|
+
...comments.map((c) => ({ start: c.start, end: c.end })),
|
|
40
|
+
];
|
|
41
|
+
const masked = applyMask(text, maskRanges);
|
|
42
|
+
const sortedMask = mergeRanges(maskRanges);
|
|
43
|
+
const headings = scanHeadings(lines);
|
|
44
|
+
const listItems = scanListItems(lines, text, masked);
|
|
45
|
+
const links = scanLinks(masked, text, index);
|
|
46
|
+
const tables = scanTables(lines, text, masked);
|
|
47
|
+
return {
|
|
48
|
+
text,
|
|
49
|
+
index,
|
|
50
|
+
frontMatter,
|
|
51
|
+
bodyStart,
|
|
52
|
+
lines,
|
|
53
|
+
headings,
|
|
54
|
+
listItems,
|
|
55
|
+
comments,
|
|
56
|
+
links,
|
|
57
|
+
tables,
|
|
58
|
+
masked,
|
|
59
|
+
isMasked: (offset) => containsOffset(sortedMask, offset),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/* -------------------------------------------------------------------------- */
|
|
63
|
+
/* Front matter */
|
|
64
|
+
/* -------------------------------------------------------------------------- */
|
|
65
|
+
const FENCE_FM = /^(-{3,}|\+{3,})[ \t]*$/;
|
|
66
|
+
function readFrontMatter(text, index) {
|
|
67
|
+
if (index.lineCount === 0)
|
|
68
|
+
return null;
|
|
69
|
+
const first = index.lineText(1);
|
|
70
|
+
const open = FENCE_FM.exec(first);
|
|
71
|
+
if (!open)
|
|
72
|
+
return null;
|
|
73
|
+
const closer = open[1].startsWith('-') ? /^(-{3,}|\.{3,})[ \t]*$/ : /^\+{3,}[ \t]*$/;
|
|
74
|
+
for (let line = 2; line <= index.lineCount; line += 1) {
|
|
75
|
+
if (closer.test(index.lineText(line))) {
|
|
76
|
+
const start = index.lineStart(2);
|
|
77
|
+
const end = index.lineStart(line);
|
|
78
|
+
const bodyStart = line < index.lineCount ? index.lineStart(line + 1) : text.length;
|
|
79
|
+
return { raw: text.slice(start, end), start, end, bodyStart };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// An unterminated opener is a horizontal rule, not front matter.
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
/* -------------------------------------------------------------------------- */
|
|
86
|
+
/* Line classification */
|
|
87
|
+
/* -------------------------------------------------------------------------- */
|
|
88
|
+
const LIST_MARKER = /^([ \t]*)([-*+]|\d{1,9}[.)])([ \t]+|$)/;
|
|
89
|
+
const ATX_HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*(?:#+[ \t]*)?$/;
|
|
90
|
+
const SETEXT_UNDERLINE = /^ {0,3}(=+|-+)[ \t]*$/;
|
|
91
|
+
const THEMATIC_BREAK = /^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/;
|
|
92
|
+
const FENCE_OPEN = /^([ \t]*)(`{3,}|~{3,})(.*)$/;
|
|
93
|
+
/** Counts indentation in columns, expanding tabs to the next multiple of four. */
|
|
94
|
+
function measureIndent(line) {
|
|
95
|
+
let width = 0;
|
|
96
|
+
for (const ch of line) {
|
|
97
|
+
if (ch === ' ')
|
|
98
|
+
width += 1;
|
|
99
|
+
else if (ch === '\t')
|
|
100
|
+
width += 4 - (width % 4);
|
|
101
|
+
else
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
return width;
|
|
105
|
+
}
|
|
106
|
+
/** Strips `>` block-quote markers, returning the offset and text of the content. */
|
|
107
|
+
function stripQuotes(raw, lineStart) {
|
|
108
|
+
let i = 0;
|
|
109
|
+
let depth = 0;
|
|
110
|
+
for (;;) {
|
|
111
|
+
let j = i;
|
|
112
|
+
let spaces = 0;
|
|
113
|
+
while (j < raw.length && (raw[j] === ' ' || raw[j] === '\t') && spaces < 3) {
|
|
114
|
+
spaces += 1;
|
|
115
|
+
j += 1;
|
|
116
|
+
}
|
|
117
|
+
if (raw[j] !== '>')
|
|
118
|
+
break;
|
|
119
|
+
j += 1;
|
|
120
|
+
if (raw[j] === ' ')
|
|
121
|
+
j += 1;
|
|
122
|
+
depth += 1;
|
|
123
|
+
i = j;
|
|
124
|
+
}
|
|
125
|
+
return { contentStart: lineStart + i, content: raw.slice(i), depth };
|
|
126
|
+
}
|
|
127
|
+
function scanLines(text, index, bodyStart) {
|
|
128
|
+
const out = [];
|
|
129
|
+
const firstLine = index.positionAt(bodyStart).line;
|
|
130
|
+
let fence = null;
|
|
131
|
+
let inList = false;
|
|
132
|
+
let listIndent = 0;
|
|
133
|
+
let previousBlank = true;
|
|
134
|
+
let indentedCode = false;
|
|
135
|
+
for (let line = firstLine; line <= index.lineCount; line += 1) {
|
|
136
|
+
const start = index.lineStart(line);
|
|
137
|
+
const end = index.lineEnd(line);
|
|
138
|
+
if (end < bodyStart)
|
|
139
|
+
continue;
|
|
140
|
+
const raw = text.slice(start, end);
|
|
141
|
+
const { contentStart, content, depth } = stripQuotes(raw, start);
|
|
142
|
+
const indent = measureIndent(content);
|
|
143
|
+
const blank = content.trim().length === 0;
|
|
144
|
+
let code = false;
|
|
145
|
+
if (fence) {
|
|
146
|
+
code = true;
|
|
147
|
+
const close = FENCE_OPEN.exec(content);
|
|
148
|
+
if (close &&
|
|
149
|
+
close[2][0] === fence.char &&
|
|
150
|
+
close[2].length >= fence.length &&
|
|
151
|
+
close[3].trim().length === 0 &&
|
|
152
|
+
measureIndent(close[1]) <= fence.indent + 3) {
|
|
153
|
+
fence = null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
const open = FENCE_OPEN.exec(content);
|
|
158
|
+
if (open && open[3].indexOf('`') === -1) {
|
|
159
|
+
fence = {
|
|
160
|
+
char: open[2][0],
|
|
161
|
+
length: open[2].length,
|
|
162
|
+
indent: measureIndent(open[1]),
|
|
163
|
+
};
|
|
164
|
+
code = true;
|
|
165
|
+
indentedCode = false;
|
|
166
|
+
}
|
|
167
|
+
else if (blank) {
|
|
168
|
+
code = indentedCode;
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
// Indented code is only recognised outside list containers. Inside a
|
|
172
|
+
// list, four-space indentation is ordinary continuation far more often
|
|
173
|
+
// than it is a code block, and masking a continuation would silently
|
|
174
|
+
// drop the links and resolutions written there.
|
|
175
|
+
if (!inList && indent >= 4 && (previousBlank || indentedCode)) {
|
|
176
|
+
code = true;
|
|
177
|
+
indentedCode = true;
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
indentedCode = false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (!code && !blank) {
|
|
185
|
+
const marker = LIST_MARKER.exec(content);
|
|
186
|
+
if (marker) {
|
|
187
|
+
inList = true;
|
|
188
|
+
listIndent = indent;
|
|
189
|
+
}
|
|
190
|
+
else if (inList && indent === 0 && previousBlank) {
|
|
191
|
+
inList = false;
|
|
192
|
+
listIndent = 0;
|
|
193
|
+
}
|
|
194
|
+
else if (inList && indent <= listIndent && previousBlank && ATX_HEADING.test(content)) {
|
|
195
|
+
inList = false;
|
|
196
|
+
listIndent = 0;
|
|
197
|
+
}
|
|
198
|
+
if (ATX_HEADING.test(content) && indent < 4) {
|
|
199
|
+
inList = false;
|
|
200
|
+
listIndent = 0;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
out.push({ line, start, end, contentStart, content, indent, blank, quoteDepth: depth, code });
|
|
204
|
+
previousBlank = blank;
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
function collectCodeRanges(lines, bodyStart) {
|
|
209
|
+
const ranges = [];
|
|
210
|
+
let open = null;
|
|
211
|
+
for (const line of lines) {
|
|
212
|
+
if (line.code) {
|
|
213
|
+
const start = Math.max(line.start, bodyStart);
|
|
214
|
+
if (open && open.end >= line.start - 2)
|
|
215
|
+
open.end = line.end;
|
|
216
|
+
else {
|
|
217
|
+
if (open)
|
|
218
|
+
ranges.push(open);
|
|
219
|
+
open = { start, end: line.end };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else if (open) {
|
|
223
|
+
ranges.push(open);
|
|
224
|
+
open = null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (open)
|
|
228
|
+
ranges.push(open);
|
|
229
|
+
return ranges;
|
|
230
|
+
}
|
|
231
|
+
/* -------------------------------------------------------------------------- */
|
|
232
|
+
/* Headings */
|
|
233
|
+
/* -------------------------------------------------------------------------- */
|
|
234
|
+
export function slugify(text) {
|
|
235
|
+
return text
|
|
236
|
+
.trim()
|
|
237
|
+
.toLowerCase()
|
|
238
|
+
.replace(/[`*_~[\]()<>#!|]/g, '')
|
|
239
|
+
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
|
240
|
+
.trim()
|
|
241
|
+
// GitHub hyphenates every whitespace character, so a run of two spaces left
|
|
242
|
+
// behind by stripped punctuation becomes two hyphens, not one.
|
|
243
|
+
.replace(/\s/g, '-');
|
|
244
|
+
}
|
|
245
|
+
function scanHeadings(lines) {
|
|
246
|
+
const out = [];
|
|
247
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
248
|
+
const line = lines[i];
|
|
249
|
+
if (line.code || line.blank)
|
|
250
|
+
continue;
|
|
251
|
+
const atx = ATX_HEADING.exec(line.content);
|
|
252
|
+
if (atx && line.indent < 4) {
|
|
253
|
+
const body = (atx[2] ?? '').trim();
|
|
254
|
+
out.push({
|
|
255
|
+
level: atx[1].length,
|
|
256
|
+
text: body,
|
|
257
|
+
slug: slugify(body),
|
|
258
|
+
start: line.contentStart,
|
|
259
|
+
end: line.end,
|
|
260
|
+
line: line.line,
|
|
261
|
+
});
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
// Setext: an underline of `=` or `-` directly below a paragraph line. The
|
|
265
|
+
// preceding line must not itself be a heading, a list item or a fence.
|
|
266
|
+
const next = lines[i + 1];
|
|
267
|
+
if (!next || next.code || next.blank)
|
|
268
|
+
continue;
|
|
269
|
+
const under = SETEXT_UNDERLINE.exec(next.content);
|
|
270
|
+
if (!under)
|
|
271
|
+
continue;
|
|
272
|
+
if (LIST_MARKER.test(line.content) || THEMATIC_BREAK.test(line.content))
|
|
273
|
+
continue;
|
|
274
|
+
if (line.indent >= 4)
|
|
275
|
+
continue;
|
|
276
|
+
const body = line.content.trim();
|
|
277
|
+
if (body.length === 0)
|
|
278
|
+
continue;
|
|
279
|
+
out.push({
|
|
280
|
+
level: under[1][0] === '=' ? 1 : 2,
|
|
281
|
+
text: body,
|
|
282
|
+
slug: slugify(body),
|
|
283
|
+
start: line.contentStart,
|
|
284
|
+
end: next.end,
|
|
285
|
+
line: line.line,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
/* -------------------------------------------------------------------------- */
|
|
291
|
+
/* List items */
|
|
292
|
+
/* -------------------------------------------------------------------------- */
|
|
293
|
+
const CHECKBOX = /^\[([ xX~\-?!*/+])\](?=[ \t]|$)/;
|
|
294
|
+
function scanListItems(lines, text, masked) {
|
|
295
|
+
const out = [];
|
|
296
|
+
// Indents of the list items currently enclosing the cursor, outermost first.
|
|
297
|
+
const stack = [];
|
|
298
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
299
|
+
const line = lines[i];
|
|
300
|
+
if (line.code || line.blank)
|
|
301
|
+
continue;
|
|
302
|
+
const marker = LIST_MARKER.exec(line.content);
|
|
303
|
+
if (!marker)
|
|
304
|
+
continue;
|
|
305
|
+
const indent = measureIndent(marker[1]);
|
|
306
|
+
while (stack.length > 0 && indent <= stack[stack.length - 1])
|
|
307
|
+
stack.pop();
|
|
308
|
+
const depth = stack.length;
|
|
309
|
+
stack.push(indent);
|
|
310
|
+
const markerStart = line.contentStart + marker[1].length;
|
|
311
|
+
let textStart = markerStart + marker[2].length + marker[3].length;
|
|
312
|
+
const rest = text.slice(textStart, line.end);
|
|
313
|
+
const box = CHECKBOX.exec(rest);
|
|
314
|
+
let checkbox = null;
|
|
315
|
+
let checkboxStart = null;
|
|
316
|
+
if (box) {
|
|
317
|
+
checkbox = box[1];
|
|
318
|
+
checkboxStart = textStart;
|
|
319
|
+
textStart += box[0].length;
|
|
320
|
+
while (text[textStart] === ' ' || text[textStart] === '\t')
|
|
321
|
+
textStart += 1;
|
|
322
|
+
}
|
|
323
|
+
const end = findItemEnd(lines, i, indent);
|
|
324
|
+
const firstLine = text.slice(textStart, line.end).trim();
|
|
325
|
+
const body = text.slice(textStart, end);
|
|
326
|
+
out.push({
|
|
327
|
+
start: line.contentStart,
|
|
328
|
+
end,
|
|
329
|
+
markerStart,
|
|
330
|
+
marker: marker[2],
|
|
331
|
+
textStart,
|
|
332
|
+
checkbox,
|
|
333
|
+
checkboxStart,
|
|
334
|
+
firstLine,
|
|
335
|
+
body,
|
|
336
|
+
maskedBody: masked.slice(textStart, end),
|
|
337
|
+
line: line.line,
|
|
338
|
+
depth,
|
|
339
|
+
indent,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Finds where a list item block ends.
|
|
346
|
+
*
|
|
347
|
+
* The item owns its wrapped continuation lines and everything nested under it.
|
|
348
|
+
* It gives up ownership at the next sibling or shallower marker, at any
|
|
349
|
+
* heading, and at the first non-indented line after a blank one.
|
|
350
|
+
*/
|
|
351
|
+
function findItemEnd(lines, startIndex, indent) {
|
|
352
|
+
let end = lines[startIndex].end;
|
|
353
|
+
let sawBlank = false;
|
|
354
|
+
for (let i = startIndex + 1; i < lines.length; i += 1) {
|
|
355
|
+
const line = lines[i];
|
|
356
|
+
if (line.blank) {
|
|
357
|
+
sawBlank = true;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (!line.code) {
|
|
361
|
+
if (line.indent < 4 && ATX_HEADING.test(line.content))
|
|
362
|
+
break;
|
|
363
|
+
if (line.indent <= indent) {
|
|
364
|
+
if (sawBlank)
|
|
365
|
+
break;
|
|
366
|
+
if (LIST_MARKER.test(line.content))
|
|
367
|
+
break;
|
|
368
|
+
if (THEMATIC_BREAK.test(line.content))
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (line.indent <= indent && sawBlank)
|
|
373
|
+
break;
|
|
374
|
+
end = line.end;
|
|
375
|
+
sawBlank = false;
|
|
376
|
+
}
|
|
377
|
+
return end;
|
|
378
|
+
}
|
|
379
|
+
/* -------------------------------------------------------------------------- */
|
|
380
|
+
/* Tables */
|
|
381
|
+
/* -------------------------------------------------------------------------- */
|
|
382
|
+
/** `| :--- | ---: | :-: |` - the row that makes the line above it a header. */
|
|
383
|
+
const TABLE_DELIMITER = /^\|?(?:\s*:?-+:?\s*\|)+\s*:?-*:?\s*\|?$/;
|
|
384
|
+
/**
|
|
385
|
+
* Finds pipe tables, with an offset for every cell.
|
|
386
|
+
*
|
|
387
|
+
* Cell offsets are the point: a register kept as a table needs findings that
|
|
388
|
+
* name the cell declaring the relation, not the row and not the file.
|
|
389
|
+
*
|
|
390
|
+
* Splitting happens on the masked copy so a pipe inside inline code cannot
|
|
391
|
+
* invent a column, while the text comes from the original.
|
|
392
|
+
*/
|
|
393
|
+
function scanTables(lines, text, masked) {
|
|
394
|
+
const out = [];
|
|
395
|
+
for (let i = 0; i < lines.length - 1; i += 1) {
|
|
396
|
+
const header = lines[i];
|
|
397
|
+
const delimiter = lines[i + 1];
|
|
398
|
+
if (header.code || header.blank || delimiter.code)
|
|
399
|
+
continue;
|
|
400
|
+
if (!header.content.includes('|'))
|
|
401
|
+
continue;
|
|
402
|
+
if (!TABLE_DELIMITER.test(delimiter.content.trim()))
|
|
403
|
+
continue;
|
|
404
|
+
const headers = splitRow(header, text, masked);
|
|
405
|
+
if (headers.length === 0)
|
|
406
|
+
continue;
|
|
407
|
+
const rows = [];
|
|
408
|
+
let end = delimiter.end;
|
|
409
|
+
for (let j = i + 2; j < lines.length; j += 1) {
|
|
410
|
+
const line = lines[j];
|
|
411
|
+
if (line.blank || line.code || !line.content.includes('|'))
|
|
412
|
+
break;
|
|
413
|
+
const cells = splitRow(line, text, masked);
|
|
414
|
+
if (cells.length === 0)
|
|
415
|
+
break;
|
|
416
|
+
rows.push({ cells, start: line.contentStart, end: line.end, line: line.line });
|
|
417
|
+
end = line.end;
|
|
418
|
+
}
|
|
419
|
+
out.push({ start: header.contentStart, end, headers, rows });
|
|
420
|
+
i += rows.length + 1;
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
/** Splits one row into cells, keeping each cell's offset in the source. */
|
|
425
|
+
function splitRow(line, text, masked) {
|
|
426
|
+
const from = line.contentStart;
|
|
427
|
+
const to = line.end;
|
|
428
|
+
const bounds = [];
|
|
429
|
+
for (let i = from; i < to; i += 1) {
|
|
430
|
+
if (masked[i] !== '|')
|
|
431
|
+
continue;
|
|
432
|
+
// A pipe escaped with a backslash is content, not a column edge.
|
|
433
|
+
if (i > from && text[i - 1] === '\\')
|
|
434
|
+
continue;
|
|
435
|
+
bounds.push(i);
|
|
436
|
+
}
|
|
437
|
+
if (bounds.length === 0)
|
|
438
|
+
return [];
|
|
439
|
+
const cells = [];
|
|
440
|
+
// A leading pipe opens the first cell; without one the row starts at `from`.
|
|
441
|
+
let cursor = bounds[0] === from ? from + 1 : from;
|
|
442
|
+
for (const bound of bounds) {
|
|
443
|
+
if (bound < cursor)
|
|
444
|
+
continue;
|
|
445
|
+
cells.push(makeCell(text, cursor, bound));
|
|
446
|
+
cursor = bound + 1;
|
|
447
|
+
}
|
|
448
|
+
// Trailing content after the last pipe is a final cell unless the row ended
|
|
449
|
+
// with a border pipe.
|
|
450
|
+
if (cursor < to && text.slice(cursor, to).trim().length > 0)
|
|
451
|
+
cells.push(makeCell(text, cursor, to));
|
|
452
|
+
return cells;
|
|
453
|
+
}
|
|
454
|
+
function makeCell(text, start, end) {
|
|
455
|
+
const raw = text.slice(start, end);
|
|
456
|
+
const leading = raw.length - raw.trimStart().length;
|
|
457
|
+
const trimmed = raw.trim();
|
|
458
|
+
return { text: trimmed, start: start + leading, end: start + leading + trimmed.length };
|
|
459
|
+
}
|
|
460
|
+
/* -------------------------------------------------------------------------- */
|
|
461
|
+
/* HTML comments */
|
|
462
|
+
/* -------------------------------------------------------------------------- */
|
|
463
|
+
function scanComments(text, index, bodyStart, code) {
|
|
464
|
+
const out = [];
|
|
465
|
+
const merged = mergeRanges(code);
|
|
466
|
+
let from = bodyStart;
|
|
467
|
+
for (;;) {
|
|
468
|
+
const open = text.indexOf('<!--', from);
|
|
469
|
+
if (open === -1)
|
|
470
|
+
break;
|
|
471
|
+
if (containsOffset(merged, open)) {
|
|
472
|
+
from = open + 4;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
const close = text.indexOf('-->', open + 4);
|
|
476
|
+
if (close === -1)
|
|
477
|
+
break;
|
|
478
|
+
out.push({
|
|
479
|
+
start: open,
|
|
480
|
+
end: close + 3,
|
|
481
|
+
inner: text.slice(open + 4, close),
|
|
482
|
+
innerStart: open + 4,
|
|
483
|
+
line: index.positionAt(open).line,
|
|
484
|
+
});
|
|
485
|
+
from = close + 3;
|
|
486
|
+
}
|
|
487
|
+
return out;
|
|
488
|
+
}
|
|
489
|
+
/* -------------------------------------------------------------------------- */
|
|
490
|
+
/* Inline code */
|
|
491
|
+
/* -------------------------------------------------------------------------- */
|
|
492
|
+
function scanInlineCode(text, bodyStart, code, comments) {
|
|
493
|
+
const blocked = mergeRanges([...code, ...comments.map((c) => ({ start: c.start, end: c.end }))]);
|
|
494
|
+
const out = [];
|
|
495
|
+
let i = bodyStart;
|
|
496
|
+
while (i < text.length) {
|
|
497
|
+
if (text[i] !== '`') {
|
|
498
|
+
i += 1;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (containsOffset(blocked, i)) {
|
|
502
|
+
i += 1;
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
let runStart = i;
|
|
506
|
+
while (text[i] === '`')
|
|
507
|
+
i += 1;
|
|
508
|
+
const runLength = i - runStart;
|
|
509
|
+
// An escaped backtick does not open a span.
|
|
510
|
+
if (runStart > 0 && text[runStart - 1] === '\\')
|
|
511
|
+
continue;
|
|
512
|
+
let j = i;
|
|
513
|
+
let closed = -1;
|
|
514
|
+
while (j < text.length) {
|
|
515
|
+
if (text[j] !== '`') {
|
|
516
|
+
j += 1;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
const closeStart = j;
|
|
520
|
+
while (text[j] === '`')
|
|
521
|
+
j += 1;
|
|
522
|
+
if (j - closeStart === runLength && !containsOffset(blocked, closeStart)) {
|
|
523
|
+
closed = j;
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (closed === -1)
|
|
528
|
+
continue;
|
|
529
|
+
out.push({ start: runStart, end: closed });
|
|
530
|
+
i = closed;
|
|
531
|
+
}
|
|
532
|
+
return out;
|
|
533
|
+
}
|
|
534
|
+
/* -------------------------------------------------------------------------- */
|
|
535
|
+
/* Masking */
|
|
536
|
+
/* -------------------------------------------------------------------------- */
|
|
537
|
+
function mergeRanges(ranges) {
|
|
538
|
+
if (ranges.length === 0)
|
|
539
|
+
return [];
|
|
540
|
+
const sorted = [...ranges].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
541
|
+
const out = [{ ...sorted[0] }];
|
|
542
|
+
for (let i = 1; i < sorted.length; i += 1) {
|
|
543
|
+
const next = sorted[i];
|
|
544
|
+
const last = out[out.length - 1];
|
|
545
|
+
if (next.start <= last.end)
|
|
546
|
+
last.end = Math.max(last.end, next.end);
|
|
547
|
+
else
|
|
548
|
+
out.push({ ...next });
|
|
549
|
+
}
|
|
550
|
+
return out;
|
|
551
|
+
}
|
|
552
|
+
function containsOffset(sorted, offset) {
|
|
553
|
+
let lo = 0;
|
|
554
|
+
let hi = sorted.length - 1;
|
|
555
|
+
while (lo <= hi) {
|
|
556
|
+
const mid = (lo + hi) >> 1;
|
|
557
|
+
const range = sorted[mid];
|
|
558
|
+
if (offset < range.start)
|
|
559
|
+
hi = mid - 1;
|
|
560
|
+
else if (offset >= range.end)
|
|
561
|
+
lo = mid + 1;
|
|
562
|
+
else
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Blanks out the given ranges.
|
|
569
|
+
*
|
|
570
|
+
* Line terminators survive so every offset, line and column in the masked copy
|
|
571
|
+
* still matches the original. Callers slice the original text for content and
|
|
572
|
+
* search the masked copy for structure.
|
|
573
|
+
*/
|
|
574
|
+
function applyMask(text, ranges) {
|
|
575
|
+
const merged = mergeRanges(ranges);
|
|
576
|
+
if (merged.length === 0)
|
|
577
|
+
return text;
|
|
578
|
+
// Built from slices rather than a per-character array. Masking runs over every
|
|
579
|
+
// byte of every document, and a `split('')`/`join('')` round trip allocates an
|
|
580
|
+
// array entry per character - on a large corpus that was the single most
|
|
581
|
+
// expensive thing the scanner did.
|
|
582
|
+
let out = '';
|
|
583
|
+
let cursor = 0;
|
|
584
|
+
for (const range of merged) {
|
|
585
|
+
const start = Math.max(range.start, cursor);
|
|
586
|
+
const end = Math.min(range.end, text.length);
|
|
587
|
+
if (end <= start)
|
|
588
|
+
continue;
|
|
589
|
+
out += text.slice(cursor, start);
|
|
590
|
+
// The character class is deliberately not Unicode-aware: a surrogate pair
|
|
591
|
+
// must become two spaces so that every later offset still lines up.
|
|
592
|
+
out += text.slice(start, end).replace(NON_TERMINATOR, ' ');
|
|
593
|
+
cursor = end;
|
|
594
|
+
}
|
|
595
|
+
return out + text.slice(cursor);
|
|
596
|
+
}
|
|
597
|
+
const NON_TERMINATOR = /[^\n\r]/g;
|
|
598
|
+
/* -------------------------------------------------------------------------- */
|
|
599
|
+
/* Links */
|
|
600
|
+
/* -------------------------------------------------------------------------- */
|
|
601
|
+
const DEFINITION = /^ {0,3}\[([^\]\n]+)\]:[ \t]*(<[^>\n]*>|\S+)/;
|
|
602
|
+
const AUTOLINK = /<((?:https?|ftp|mailto):[^>\s]+)>/g;
|
|
603
|
+
/** How far a bracket construct may run before we call it unbalanced prose. */
|
|
604
|
+
const MAX_LINK_SPAN = 4096;
|
|
605
|
+
function scanLinks(masked, text, index) {
|
|
606
|
+
const out = [];
|
|
607
|
+
const definitions = new Set();
|
|
608
|
+
const definitionRanges = [];
|
|
609
|
+
// Pass 1: reference definitions, which also make shortcut links resolvable.
|
|
610
|
+
for (let line = 1; line <= index.lineCount; line += 1) {
|
|
611
|
+
const start = index.lineStart(line);
|
|
612
|
+
const content = masked.slice(start, index.lineEnd(line));
|
|
613
|
+
const match = DEFINITION.exec(content);
|
|
614
|
+
if (!match)
|
|
615
|
+
continue;
|
|
616
|
+
const label = match[1].trim().toLowerCase();
|
|
617
|
+
definitions.add(label);
|
|
618
|
+
const rawTarget = match[2];
|
|
619
|
+
const targetStart = start + content.indexOf(rawTarget, match[1].length);
|
|
620
|
+
definitionRanges.push({ start, end: start + match[0].length });
|
|
621
|
+
out.push({
|
|
622
|
+
text: match[1].trim(),
|
|
623
|
+
target: unwrapAngle(rawTarget),
|
|
624
|
+
start,
|
|
625
|
+
end: start + match[0].length,
|
|
626
|
+
targetStart,
|
|
627
|
+
form: 'definition',
|
|
628
|
+
line,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
// Pass 2: autolinks.
|
|
632
|
+
AUTOLINK.lastIndex = 0;
|
|
633
|
+
for (let m = AUTOLINK.exec(masked); m !== null; m = AUTOLINK.exec(masked)) {
|
|
634
|
+
const start = m.index;
|
|
635
|
+
out.push({
|
|
636
|
+
text: '',
|
|
637
|
+
target: m[1],
|
|
638
|
+
start,
|
|
639
|
+
end: start + m[0].length,
|
|
640
|
+
targetStart: start + 1,
|
|
641
|
+
form: 'autolink',
|
|
642
|
+
line: index.positionAt(start).line,
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
// Pass 3: bracket constructs, left to right.
|
|
646
|
+
const definedAt = mergeRanges(definitionRanges);
|
|
647
|
+
let i = 0;
|
|
648
|
+
while (i < masked.length) {
|
|
649
|
+
const ch = masked[i];
|
|
650
|
+
if (ch !== '[') {
|
|
651
|
+
i += 1;
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
// The label of a definition is not itself a shortcut link to anything.
|
|
655
|
+
if (containsOffset(definedAt, i)) {
|
|
656
|
+
i += 1;
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
const isImage = i > 0 && masked[i - 1] === '!';
|
|
660
|
+
if (masked[i + 1] === '[') {
|
|
661
|
+
const close = masked.indexOf(']]', i + 2);
|
|
662
|
+
if (close !== -1 && close - i <= MAX_LINK_SPAN && masked.slice(i + 2, close).indexOf('\n') === -1) {
|
|
663
|
+
const target = text.slice(i + 2, close).trim();
|
|
664
|
+
if (target.length > 0) {
|
|
665
|
+
out.push({
|
|
666
|
+
text: target,
|
|
667
|
+
target: wikiTarget(target),
|
|
668
|
+
start: i,
|
|
669
|
+
end: close + 2,
|
|
670
|
+
targetStart: i + 2,
|
|
671
|
+
form: 'wiki',
|
|
672
|
+
line: index.positionAt(i).line,
|
|
673
|
+
});
|
|
674
|
+
i = close + 2;
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
const labelEnd = matchBracket(masked, i);
|
|
680
|
+
if (labelEnd === -1) {
|
|
681
|
+
i += 1;
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
const label = text.slice(i + 1, labelEnd);
|
|
685
|
+
const after = masked[labelEnd + 1];
|
|
686
|
+
if (after === '(') {
|
|
687
|
+
const dest = readDestination(masked, labelEnd + 2);
|
|
688
|
+
if (dest) {
|
|
689
|
+
if (!isImage) {
|
|
690
|
+
out.push({
|
|
691
|
+
text: label.trim(),
|
|
692
|
+
target: unwrapAngle(text.slice(dest.start, dest.end).trim()),
|
|
693
|
+
start: isImage ? i - 1 : i,
|
|
694
|
+
end: dest.close + 1,
|
|
695
|
+
targetStart: dest.start,
|
|
696
|
+
form: 'inline',
|
|
697
|
+
line: index.positionAt(i).line,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
i = dest.close + 1;
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
else if (after === '[') {
|
|
705
|
+
const refEnd = matchBracket(masked, labelEnd + 1);
|
|
706
|
+
if (refEnd !== -1) {
|
|
707
|
+
const ref = text.slice(labelEnd + 2, refEnd).trim();
|
|
708
|
+
const resolved = ref.length > 0 ? ref : label.trim();
|
|
709
|
+
if (!isImage) {
|
|
710
|
+
out.push({
|
|
711
|
+
text: label.trim(),
|
|
712
|
+
target: resolved,
|
|
713
|
+
start: isImage ? i - 1 : i,
|
|
714
|
+
end: refEnd + 1,
|
|
715
|
+
targetStart: ref.length > 0 ? labelEnd + 2 : i + 1,
|
|
716
|
+
form: 'reference',
|
|
717
|
+
line: index.positionAt(i).line,
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
i = refEnd + 1;
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
else if (!isImage && definitions.has(label.trim().toLowerCase())) {
|
|
725
|
+
out.push({
|
|
726
|
+
text: label.trim(),
|
|
727
|
+
target: label.trim(),
|
|
728
|
+
start: i,
|
|
729
|
+
end: labelEnd + 1,
|
|
730
|
+
targetStart: i + 1,
|
|
731
|
+
form: 'shortcut',
|
|
732
|
+
line: index.positionAt(i).line,
|
|
733
|
+
});
|
|
734
|
+
i = labelEnd + 1;
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
i = labelEnd + 1;
|
|
738
|
+
}
|
|
739
|
+
out.sort((a, b) => a.start - b.start);
|
|
740
|
+
return out;
|
|
741
|
+
}
|
|
742
|
+
/** Finds the `]` matching the `[` at `open`, honouring nesting and escapes. */
|
|
743
|
+
function matchBracket(text, open) {
|
|
744
|
+
let depth = 0;
|
|
745
|
+
const limit = Math.min(text.length, open + MAX_LINK_SPAN);
|
|
746
|
+
for (let i = open; i < limit; i += 1) {
|
|
747
|
+
const ch = text[i];
|
|
748
|
+
if (ch === '\\') {
|
|
749
|
+
i += 1;
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
if (ch === '\n' && text[i + 1] === '\n')
|
|
753
|
+
return -1;
|
|
754
|
+
if (ch === '[')
|
|
755
|
+
depth += 1;
|
|
756
|
+
else if (ch === ']') {
|
|
757
|
+
depth -= 1;
|
|
758
|
+
if (depth === 0)
|
|
759
|
+
return i;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return -1;
|
|
763
|
+
}
|
|
764
|
+
/** Reads a link destination starting just after `(`. */
|
|
765
|
+
function readDestination(text, from) {
|
|
766
|
+
let i = from;
|
|
767
|
+
const limit = Math.min(text.length, from + MAX_LINK_SPAN);
|
|
768
|
+
while (i < limit && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n'))
|
|
769
|
+
i += 1;
|
|
770
|
+
const start = i;
|
|
771
|
+
if (text[i] === '<') {
|
|
772
|
+
const close = text.indexOf('>', i + 1);
|
|
773
|
+
if (close === -1 || close > limit)
|
|
774
|
+
return null;
|
|
775
|
+
const paren = text.indexOf(')', close);
|
|
776
|
+
if (paren === -1 || paren > limit)
|
|
777
|
+
return null;
|
|
778
|
+
return { start, end: close + 1, close: paren };
|
|
779
|
+
}
|
|
780
|
+
let depth = 0;
|
|
781
|
+
while (i < limit) {
|
|
782
|
+
const ch = text[i];
|
|
783
|
+
if (ch === '\\') {
|
|
784
|
+
i += 2;
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
if (ch === '(')
|
|
788
|
+
depth += 1;
|
|
789
|
+
else if (ch === ')') {
|
|
790
|
+
if (depth === 0)
|
|
791
|
+
break;
|
|
792
|
+
depth -= 1;
|
|
793
|
+
}
|
|
794
|
+
else if (ch === ' ' || ch === '\t' || ch === '\n')
|
|
795
|
+
break;
|
|
796
|
+
i += 1;
|
|
797
|
+
}
|
|
798
|
+
const end = i;
|
|
799
|
+
// Skip an optional title, then require the closing paren.
|
|
800
|
+
let j = i;
|
|
801
|
+
while (j < limit && text[j] !== ')') {
|
|
802
|
+
if (text[j] === '\n' && text[j + 1] === '\n')
|
|
803
|
+
return null;
|
|
804
|
+
j += 1;
|
|
805
|
+
}
|
|
806
|
+
if (j >= limit || text[j] !== ')')
|
|
807
|
+
return null;
|
|
808
|
+
if (end === start)
|
|
809
|
+
return null;
|
|
810
|
+
return { start, end, close: j };
|
|
811
|
+
}
|
|
812
|
+
function unwrapAngle(value) {
|
|
813
|
+
return value.startsWith('<') && value.endsWith('>') ? value.slice(1, -1) : value;
|
|
814
|
+
}
|
|
815
|
+
/** `[[0007-thing|Display]]` and `[[0007-thing]]` both target `0007-thing`. */
|
|
816
|
+
function wikiTarget(raw) {
|
|
817
|
+
const pipe = raw.indexOf('|');
|
|
818
|
+
return (pipe === -1 ? raw : raw.slice(0, pipe)).trim();
|
|
819
|
+
}
|
|
820
|
+
//# sourceMappingURL=markdown.js.map
|