@cube-drone/marquee-parser 0.9.3 → 0.10.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/dist/blocks.js +22 -5
- package/dist/inlines.d.ts +1 -0
- package/dist/inlines.js +119 -52
- package/package.json +1 -1
- package/src/blocks.ts +24 -5
- package/src/inlines.ts +136 -49
package/dist/blocks.js
CHANGED
|
@@ -257,7 +257,7 @@ function parseContainer(cur, ctx, openDir, T) {
|
|
|
257
257
|
}, li, cur.pos - 1));
|
|
258
258
|
continue;
|
|
259
259
|
}
|
|
260
|
-
if (line
|
|
260
|
+
if (trimEndSpaceTab(line) === "---") {
|
|
261
261
|
const li = cur.pos;
|
|
262
262
|
cur.pos += 1;
|
|
263
263
|
out.push(mark(T, cur, { type: "thematic_break" }, li, li));
|
|
@@ -313,8 +313,25 @@ function indentOf(line) {
|
|
|
313
313
|
}
|
|
314
314
|
return i;
|
|
315
315
|
}
|
|
316
|
+
// Manual scans, not /[ \t]+$/: V8 retries that regex from every start
|
|
317
|
+
// position in a space run, so `a` + ` `×n + `b` went quadratic. These are
|
|
318
|
+
// Rust's trim_matches / trim_end_matches([' ', '\t']).
|
|
319
|
+
function isSpaceTab(c) {
|
|
320
|
+
return c === " " || c === "\t";
|
|
321
|
+
}
|
|
322
|
+
function trimEndSpaceTab(s) {
|
|
323
|
+
let end = s.length;
|
|
324
|
+
while (end > 0 && isSpaceTab(s[end - 1])) {
|
|
325
|
+
end -= 1;
|
|
326
|
+
}
|
|
327
|
+
return s.slice(0, end);
|
|
328
|
+
}
|
|
316
329
|
function trimSpaceTab(s) {
|
|
317
|
-
|
|
330
|
+
let start = 0;
|
|
331
|
+
while (start < s.length && isSpaceTab(s[start])) {
|
|
332
|
+
start += 1;
|
|
333
|
+
}
|
|
334
|
+
return trimEndSpaceTab(s.slice(start));
|
|
318
335
|
}
|
|
319
336
|
/** Would this line start a non-paragraph block in this context? (Used both
|
|
320
337
|
* by the dispatcher and to end paragraphs: any block construct interrupts.) */
|
|
@@ -324,7 +341,7 @@ function isBlockStart(line, ctx) {
|
|
|
324
341
|
headingLine(line) !== null ||
|
|
325
342
|
fenceOpen(line) !== null ||
|
|
326
343
|
(line.startsWith(">") && ctx.quoteDepth < MAX_QUOTE_DEPTH) ||
|
|
327
|
-
line
|
|
344
|
+
trimEndSpaceTab(line) === "---" ||
|
|
328
345
|
(ctx.listDepth < MAX_LIST_DEPTH && marker(line) !== null));
|
|
329
346
|
}
|
|
330
347
|
function headingLine(line) {
|
|
@@ -349,7 +366,7 @@ function fenceOpen(line) {
|
|
|
349
366
|
return { len: n, info: info === "" ? null : info };
|
|
350
367
|
}
|
|
351
368
|
function fenceClose(line, openLen) {
|
|
352
|
-
const t = line
|
|
369
|
+
const t = trimEndSpaceTab(line);
|
|
353
370
|
return t.length >= openLen && /^`+$/.test(t);
|
|
354
371
|
}
|
|
355
372
|
function marker(line) {
|
|
@@ -455,7 +472,7 @@ function finishItem(buf, bufStarts, ctx, T, itemStart) {
|
|
|
455
472
|
return item;
|
|
456
473
|
}
|
|
457
474
|
function classifyDirective(line) {
|
|
458
|
-
const rest = line.slice(3)
|
|
475
|
+
const rest = trimEndSpaceTab(line.slice(3));
|
|
459
476
|
if (rest === "") {
|
|
460
477
|
return { kind: "close", name: null };
|
|
461
478
|
}
|
package/dist/inlines.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Node } from "./ast.ts";
|
|
|
2
2
|
import { type Span } from "./blocks.ts";
|
|
3
3
|
export declare const MAX_INLINE_DEPTH = 16;
|
|
4
4
|
export declare const MAX_EMOJI_SLUG_BYTES = 64;
|
|
5
|
+
export declare const MAX_SPAN_OPENER_BYTES = 4096;
|
|
5
6
|
/** Positions context: map[cpIndex] = source UTF-16 offset of that code
|
|
6
7
|
* point in the logical text (one extra entry for the end). */
|
|
7
8
|
export interface PosCtx {
|
package/dist/inlines.js
CHANGED
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
// node gets a [start, end) span in the side-table - UTF-16 offsets into the
|
|
11
11
|
// normalized source, translated from code-point indices via `map`. The AST
|
|
12
12
|
// itself is untouched either way: spans live outside the wire contract.
|
|
13
|
-
import {
|
|
13
|
+
import { nameLen, parseAttrs, parseValue } from "./attrs.js";
|
|
14
14
|
import { MAX_TARGET_BYTES } from "./blocks.js";
|
|
15
15
|
export const MAX_INLINE_DEPTH = 16;
|
|
16
16
|
export const MAX_EMOJI_SLUG_BYTES = 64;
|
|
17
|
+
export const MAX_SPAN_OPENER_BYTES = 4096;
|
|
17
18
|
function rootFrame() {
|
|
18
19
|
return { openerRaw: "", name: "", attrs: {}, children: [], delims: [], openerCp: 0, openerCpEnd: 0 };
|
|
19
20
|
}
|
|
@@ -41,6 +42,7 @@ function mk(P, node, cpStart, cpEnd) {
|
|
|
41
42
|
* would let composition multiply past it). */
|
|
42
43
|
function parseInlinesAt(text, base, P) {
|
|
43
44
|
const chars = Array.from(text);
|
|
45
|
+
const closes = findCloses(chars);
|
|
44
46
|
const frames = [rootFrame()];
|
|
45
47
|
let i = 0;
|
|
46
48
|
while (i < chars.length) {
|
|
@@ -73,10 +75,10 @@ function parseInlinesAt(text, base, P) {
|
|
|
73
75
|
}
|
|
74
76
|
}
|
|
75
77
|
else if (c === "[") {
|
|
76
|
-
i = bracket(chars, i, false, frames, base, P);
|
|
78
|
+
i = bracket(chars, closes, i, false, frames, base, P);
|
|
77
79
|
}
|
|
78
80
|
else if (c === "!" && chars[i + 1] === "[") {
|
|
79
|
-
i = bracket(chars, i + 1, true, frames, base, P);
|
|
81
|
+
i = bracket(chars, closes, i + 1, true, frames, base, P);
|
|
80
82
|
}
|
|
81
83
|
else if (c === ":") {
|
|
82
84
|
let k = i + 1;
|
|
@@ -207,37 +209,61 @@ function delimiter(chars, i, n, kind, raw, frames, base, P) {
|
|
|
207
209
|
}
|
|
208
210
|
return i + n;
|
|
209
211
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const fallback = () => {
|
|
215
|
-
pushStr(frames, chars[bang], P, bang, bang + 1);
|
|
216
|
-
return bang + 1;
|
|
212
|
+
function findCloses(chars) {
|
|
213
|
+
return {
|
|
214
|
+
brackets: matchCloses(chars, "[", "]", true),
|
|
215
|
+
parens: matchCloses(chars, "(", ")", false),
|
|
217
216
|
};
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
217
|
+
}
|
|
218
|
+
/** level[k] is the nesting depth after chars[k] (an escaped char leaves it
|
|
219
|
+
* unchanged); the closer matching the opener at j is the first k > j whose
|
|
220
|
+
* level is level[j] - 1, so one right-to-left sweep that remembers the
|
|
221
|
+
* nearest position of each level answers every opener. */
|
|
222
|
+
function matchCloses(chars, open, close, escapes) {
|
|
223
|
+
const n = chars.length;
|
|
224
|
+
const level = new Int32Array(n);
|
|
225
|
+
let depth = 0;
|
|
226
|
+
for (let k = 0; k < n; k += 1) {
|
|
222
227
|
const c = chars[k];
|
|
223
|
-
if (c === "\\") {
|
|
228
|
+
if (escapes && c === "\\" && k + 1 < n) {
|
|
229
|
+
level[k] = depth;
|
|
224
230
|
k += 1;
|
|
225
231
|
}
|
|
226
|
-
else if (c ===
|
|
232
|
+
else if (c === open) {
|
|
227
233
|
depth += 1;
|
|
228
234
|
}
|
|
229
|
-
else if (c ===
|
|
235
|
+
else if (c === close) {
|
|
230
236
|
depth -= 1;
|
|
231
|
-
if (depth === 0) {
|
|
232
|
-
break;
|
|
233
|
-
}
|
|
234
237
|
}
|
|
235
|
-
k
|
|
238
|
+
level[k] = depth;
|
|
236
239
|
}
|
|
237
|
-
|
|
240
|
+
// Levels span [-n, n]: nearest[level + n + 1], so level - 1 indexes >= 0.
|
|
241
|
+
const nearest = new Int32Array(2 * n + 2).fill(-1);
|
|
242
|
+
const closes = new Int32Array(n).fill(-1);
|
|
243
|
+
for (let j = n - 1; j >= 0; j -= 1) {
|
|
244
|
+
if (chars[j] === open) {
|
|
245
|
+
closes[j] = nearest[level[j] + n];
|
|
246
|
+
}
|
|
247
|
+
nearest[level[j] + n + 1] = j;
|
|
248
|
+
}
|
|
249
|
+
return closes;
|
|
250
|
+
}
|
|
251
|
+
/** Handle a bracket construct starting at `chars[open]` (which is `[`).
|
|
252
|
+
* `embed` means a `!` sits just before it. Returns the new position. */
|
|
253
|
+
function bracket(chars, closes, open, embed, frames, base, P) {
|
|
254
|
+
const bang = embed ? open - 1 : open;
|
|
255
|
+
const fallback = () => {
|
|
256
|
+
pushStr(frames, chars[bang], P, bang, bang + 1);
|
|
257
|
+
return bang + 1;
|
|
258
|
+
};
|
|
259
|
+
// The matching `]` (balanced, escape-aware).
|
|
260
|
+
const k = closes.brackets[open];
|
|
261
|
+
if (k < 0) {
|
|
238
262
|
return fallback();
|
|
239
263
|
}
|
|
240
|
-
|
|
264
|
+
// Copied only once a construct is sure to want it: copying up front made
|
|
265
|
+
// nested brackets (`[`×n `]`×n) quadratic.
|
|
266
|
+
const interior = () => chars.slice(open + 1, k).join("");
|
|
241
267
|
// Link / embed: `](` with a lexable target.
|
|
242
268
|
if (chars[k + 1] === "(") {
|
|
243
269
|
// A link's text is one nesting level (embeds don't recurse: alt is a
|
|
@@ -245,23 +271,23 @@ function bracket(chars, open, embed, frames, base, P) {
|
|
|
245
271
|
if (!embed && base + totalDepth(frames) >= MAX_INLINE_DEPTH) {
|
|
246
272
|
return fallback();
|
|
247
273
|
}
|
|
248
|
-
const lexed = lexTarget(chars, k + 2);
|
|
274
|
+
const lexed = lexTarget(chars, closes.parens[k + 1], k + 2);
|
|
249
275
|
if (lexed !== null) {
|
|
250
276
|
const depth = base + totalDepth(frames) + 1;
|
|
251
277
|
// The link text's own positions: the interior is a contiguous slice
|
|
252
278
|
// of this logical text, so its map is a slice of ours.
|
|
253
279
|
const subP = P === null ? null : { map: P.map.slice(open + 1, k + 1), spans: P.spans };
|
|
254
280
|
top(frames).children.push(mk(P, embed
|
|
255
|
-
? { type: "embed", target: lexed.target, alt: resolveEscapes(interior) }
|
|
256
|
-
: { type: "link", target: lexed.target, children: parseInlinesAt(interior, depth, subP) }, bang, lexed.end));
|
|
281
|
+
? { type: "embed", target: lexed.target, alt: resolveEscapes(interior()) }
|
|
282
|
+
: { type: "link", target: lexed.target, children: parseInlinesAt(interior(), depth, subP) }, bang, lexed.end));
|
|
257
283
|
return lexed.end;
|
|
258
284
|
}
|
|
259
285
|
return fallback();
|
|
260
286
|
}
|
|
261
287
|
// Span closer: `[/name]` must name the innermost open span.
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
|
|
288
|
+
if (chars[open + 1] === "/") {
|
|
289
|
+
if (isNameAt(chars, open + 2, k)) {
|
|
290
|
+
const name = chars.slice(open + 2, k).join("");
|
|
265
291
|
if (embed) {
|
|
266
292
|
pushStr(frames, "!", P, bang, bang + 1); // the ! belongs to links/embeds, not spans
|
|
267
293
|
}
|
|
@@ -280,7 +306,7 @@ function bracket(chars, open, embed, frames, base, P) {
|
|
|
280
306
|
}
|
|
281
307
|
// Span opener: `[name ...]`, with the BBCode default-parameter idiom
|
|
282
308
|
// (`[color=red]` puts `color=red` in the span's own attrs).
|
|
283
|
-
const opener = parseSpanOpener(interior);
|
|
309
|
+
const opener = maybeSpanOpener(chars, open + 1, k) ? parseSpanOpener(interior()) : null;
|
|
284
310
|
if (opener !== null) {
|
|
285
311
|
if (embed) {
|
|
286
312
|
pushStr(frames, "!", P, bang, bang + 1); // the ! belongs to links/embeds, not spans
|
|
@@ -303,6 +329,48 @@ function bracket(chars, open, embed, frames, base, P) {
|
|
|
303
329
|
}
|
|
304
330
|
return fallback();
|
|
305
331
|
}
|
|
332
|
+
function isNameStart(c) {
|
|
333
|
+
return c !== undefined && c >= "a" && c <= "z";
|
|
334
|
+
}
|
|
335
|
+
function isNameChar(c) {
|
|
336
|
+
return (c >= "a" && c <= "z") || (c >= "0" && c <= "9") || c === "_" || c === "-";
|
|
337
|
+
}
|
|
338
|
+
/** isName over chars[from, to) in place: it stops at the first non-name
|
|
339
|
+
* char, so a failed closer (`[/[/[/`...) costs no copy of its interior. */
|
|
340
|
+
function isNameAt(chars, from, to) {
|
|
341
|
+
if (from >= to || !isNameStart(chars[from])) {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
for (let k = from + 1; k < to; k += 1) {
|
|
345
|
+
if (!isNameChar(chars[k])) {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
/** parseSpanOpener's early rejections, checked in place: a name, then `=`,
|
|
352
|
+
* a space or tab, or the end. Spares `[a[a[a`... a copy of each interior
|
|
353
|
+
* only to fail on its second char. And the opener cap: nested openers each
|
|
354
|
+
* re-parse the attribute lists inside them, so an uncapped interior made
|
|
355
|
+
* `[b a=`... quadratic. */
|
|
356
|
+
function maybeSpanOpener(chars, from, to) {
|
|
357
|
+
if (from >= to || !isNameStart(chars[from])) {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
let bytes = 0;
|
|
361
|
+
for (let j = from; j < to; j++) {
|
|
362
|
+
const cp = chars[j].codePointAt(0);
|
|
363
|
+
bytes += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
|
|
364
|
+
if (bytes > MAX_SPAN_OPENER_BYTES) {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
let k = from + 1;
|
|
369
|
+
while (k < to && isNameChar(chars[k])) {
|
|
370
|
+
k += 1;
|
|
371
|
+
}
|
|
372
|
+
return k === to || chars[k] === "=" || chars[k] === " " || chars[k] === "\t";
|
|
373
|
+
}
|
|
306
374
|
function parseSpanOpener(interior) {
|
|
307
375
|
const nlen = nameLen(interior);
|
|
308
376
|
if (nlen === 0) {
|
|
@@ -337,31 +405,30 @@ function parseSpanOpener(interior) {
|
|
|
337
405
|
return { name, attrs: Object.fromEntries(pairs) };
|
|
338
406
|
}
|
|
339
407
|
/** Lex a link/embed target from `chars[from]`: no whitespace, balanced
|
|
340
|
-
* parens, an unbalanced `)` ends it.
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
return { target, end: k + 1 };
|
|
352
|
-
}
|
|
353
|
-
if (c === ")") {
|
|
354
|
-
depth -= 1;
|
|
355
|
-
}
|
|
356
|
-
else if (c === "(") {
|
|
357
|
-
depth += 1;
|
|
358
|
-
}
|
|
359
|
-
else if (isWs(c)) {
|
|
408
|
+
* parens, an unbalanced `)` ends it. `close` is that `)` (the match of the
|
|
409
|
+
* `(` before `from`), or -1. Returns the position after `)`. */
|
|
410
|
+
function lexTarget(chars, close, from) {
|
|
411
|
+
// Every char is at least a byte: a target longer than the cap in chars
|
|
412
|
+
// fails without a look, so no failed target costs more than the cap.
|
|
413
|
+
if (close < 0 || close - from > MAX_TARGET_BYTES) {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
let bytes = 0;
|
|
417
|
+
for (let k = from; k < close; k += 1) {
|
|
418
|
+
if (isWs(chars[k])) {
|
|
360
419
|
return null;
|
|
361
420
|
}
|
|
362
|
-
|
|
421
|
+
bytes += cpUtf8Len(chars[k]);
|
|
363
422
|
}
|
|
364
|
-
|
|
423
|
+
if (bytes > MAX_TARGET_BYTES) {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
return { target: chars.slice(from, close).join(""), end: close + 1 };
|
|
427
|
+
}
|
|
428
|
+
/** utf8Len of one code point (a lone surrogate encodes as U+FFFD: 3). */
|
|
429
|
+
function cpUtf8Len(c) {
|
|
430
|
+
const cp = c.codePointAt(0);
|
|
431
|
+
return cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
|
|
365
432
|
}
|
|
366
433
|
function resolveEscapes(s) {
|
|
367
434
|
let out = "";
|
package/package.json
CHANGED
package/src/blocks.ts
CHANGED
|
@@ -325,7 +325,7 @@ function parseContainer(cur: Cursor, ctx: Ctx, openDir: string | null, T: PosTra
|
|
|
325
325
|
continue;
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
-
if (line
|
|
328
|
+
if (trimEndSpaceTab(line) === "---") {
|
|
329
329
|
const li = cur.pos;
|
|
330
330
|
cur.pos += 1;
|
|
331
331
|
out.push(mark(T, cur, { type: "thematic_break" }, li, li));
|
|
@@ -387,8 +387,27 @@ function indentOf(line: string): number {
|
|
|
387
387
|
return i;
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
+
// Manual scans, not /[ \t]+$/: V8 retries that regex from every start
|
|
391
|
+
// position in a space run, so `a` + ` `×n + `b` went quadratic. These are
|
|
392
|
+
// Rust's trim_matches / trim_end_matches([' ', '\t']).
|
|
393
|
+
function isSpaceTab(c: string): boolean {
|
|
394
|
+
return c === " " || c === "\t";
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function trimEndSpaceTab(s: string): string {
|
|
398
|
+
let end = s.length;
|
|
399
|
+
while (end > 0 && isSpaceTab(s[end - 1]!)) {
|
|
400
|
+
end -= 1;
|
|
401
|
+
}
|
|
402
|
+
return s.slice(0, end);
|
|
403
|
+
}
|
|
404
|
+
|
|
390
405
|
function trimSpaceTab(s: string): string {
|
|
391
|
-
|
|
406
|
+
let start = 0;
|
|
407
|
+
while (start < s.length && isSpaceTab(s[start]!)) {
|
|
408
|
+
start += 1;
|
|
409
|
+
}
|
|
410
|
+
return trimEndSpaceTab(s.slice(start));
|
|
392
411
|
}
|
|
393
412
|
|
|
394
413
|
/** Would this line start a non-paragraph block in this context? (Used both
|
|
@@ -400,7 +419,7 @@ function isBlockStart(line: string, ctx: Ctx): boolean {
|
|
|
400
419
|
headingLine(line) !== null ||
|
|
401
420
|
fenceOpen(line) !== null ||
|
|
402
421
|
(line.startsWith(">") && ctx.quoteDepth < MAX_QUOTE_DEPTH) ||
|
|
403
|
-
line
|
|
422
|
+
trimEndSpaceTab(line) === "---" ||
|
|
404
423
|
(ctx.listDepth < MAX_LIST_DEPTH && marker(line) !== null)
|
|
405
424
|
);
|
|
406
425
|
}
|
|
@@ -429,7 +448,7 @@ function fenceOpen(line: string): { len: number; info: string | null } | null {
|
|
|
429
448
|
}
|
|
430
449
|
|
|
431
450
|
function fenceClose(line: string, openLen: number): boolean {
|
|
432
|
-
const t = line
|
|
451
|
+
const t = trimEndSpaceTab(line);
|
|
433
452
|
return t.length >= openLen && /^`+$/.test(t);
|
|
434
453
|
}
|
|
435
454
|
|
|
@@ -557,7 +576,7 @@ type DirLine =
|
|
|
557
576
|
| { kind: "bad"; reason: Reason };
|
|
558
577
|
|
|
559
578
|
function classifyDirective(line: string): DirLine {
|
|
560
|
-
const rest = line.slice(3)
|
|
579
|
+
const rest = trimEndSpaceTab(line.slice(3));
|
|
561
580
|
if (rest === "") {
|
|
562
581
|
return { kind: "close", name: null };
|
|
563
582
|
}
|
package/src/inlines.ts
CHANGED
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
// itself is untouched either way: spans live outside the wire contract.
|
|
13
13
|
|
|
14
14
|
import type { Attrs, Node } from "./ast.ts";
|
|
15
|
-
import {
|
|
15
|
+
import { nameLen, parseAttrs, parseValue } from "./attrs.ts";
|
|
16
16
|
import { MAX_TARGET_BYTES, type Span } from "./blocks.ts";
|
|
17
17
|
|
|
18
18
|
export const MAX_INLINE_DEPTH = 16;
|
|
19
19
|
export const MAX_EMOJI_SLUG_BYTES = 64;
|
|
20
|
+
export const MAX_SPAN_OPENER_BYTES = 4096;
|
|
20
21
|
|
|
21
22
|
/** Positions context: map[cpIndex] = source UTF-16 offset of that code
|
|
22
23
|
* point in the logical text (one extra entry for the end). */
|
|
@@ -80,6 +81,7 @@ function mk(P: PosCtx | null, node: Node, cpStart: number, cpEnd: number): Node
|
|
|
80
81
|
* would let composition multiply past it). */
|
|
81
82
|
function parseInlinesAt(text: string, base: number, P: PosCtx | null): Node[] {
|
|
82
83
|
const chars = Array.from(text);
|
|
84
|
+
const closes = findCloses(chars);
|
|
83
85
|
const frames: Frame[] = [rootFrame()];
|
|
84
86
|
let i = 0;
|
|
85
87
|
|
|
@@ -110,9 +112,9 @@ function parseInlinesAt(text: string, base: number, P: PosCtx | null): Node[] {
|
|
|
110
112
|
i += n;
|
|
111
113
|
}
|
|
112
114
|
} else if (c === "[") {
|
|
113
|
-
i = bracket(chars, i, false, frames, base, P);
|
|
115
|
+
i = bracket(chars, closes, i, false, frames, base, P);
|
|
114
116
|
} else if (c === "!" && chars[i + 1] === "[") {
|
|
115
|
-
i = bracket(chars, i + 1, true, frames, base, P);
|
|
117
|
+
i = bracket(chars, closes, i + 1, true, frames, base, P);
|
|
116
118
|
} else if (c === ":") {
|
|
117
119
|
let k = i + 1;
|
|
118
120
|
while (k < chars.length && isSlugChar(chars[k]!)) {
|
|
@@ -258,10 +260,60 @@ function delimiter(
|
|
|
258
260
|
return i + n;
|
|
259
261
|
}
|
|
260
262
|
|
|
263
|
+
/** Where each `[` and `(` in a container closes (-1 if never), found all
|
|
264
|
+
* at once: scanning forward from each opener in turn made `[`×n and
|
|
265
|
+
* `[a](`×n quadratic. */
|
|
266
|
+
interface Closes {
|
|
267
|
+
/** Balanced and escape-aware, as a bracket's interior is. */
|
|
268
|
+
brackets: Int32Array;
|
|
269
|
+
/** Balanced, no escapes, as a link target is. */
|
|
270
|
+
parens: Int32Array;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function findCloses(chars: string[]): Closes {
|
|
274
|
+
return {
|
|
275
|
+
brackets: matchCloses(chars, "[", "]", true),
|
|
276
|
+
parens: matchCloses(chars, "(", ")", false),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** level[k] is the nesting depth after chars[k] (an escaped char leaves it
|
|
281
|
+
* unchanged); the closer matching the opener at j is the first k > j whose
|
|
282
|
+
* level is level[j] - 1, so one right-to-left sweep that remembers the
|
|
283
|
+
* nearest position of each level answers every opener. */
|
|
284
|
+
function matchCloses(chars: string[], open: string, close: string, escapes: boolean): Int32Array {
|
|
285
|
+
const n = chars.length;
|
|
286
|
+
const level = new Int32Array(n);
|
|
287
|
+
let depth = 0;
|
|
288
|
+
for (let k = 0; k < n; k += 1) {
|
|
289
|
+
const c = chars[k]!;
|
|
290
|
+
if (escapes && c === "\\" && k + 1 < n) {
|
|
291
|
+
level[k] = depth;
|
|
292
|
+
k += 1;
|
|
293
|
+
} else if (c === open) {
|
|
294
|
+
depth += 1;
|
|
295
|
+
} else if (c === close) {
|
|
296
|
+
depth -= 1;
|
|
297
|
+
}
|
|
298
|
+
level[k] = depth;
|
|
299
|
+
}
|
|
300
|
+
// Levels span [-n, n]: nearest[level + n + 1], so level - 1 indexes >= 0.
|
|
301
|
+
const nearest = new Int32Array(2 * n + 2).fill(-1);
|
|
302
|
+
const closes = new Int32Array(n).fill(-1);
|
|
303
|
+
for (let j = n - 1; j >= 0; j -= 1) {
|
|
304
|
+
if (chars[j] === open) {
|
|
305
|
+
closes[j] = nearest[level[j]! + n]!;
|
|
306
|
+
}
|
|
307
|
+
nearest[level[j]! + n + 1] = j;
|
|
308
|
+
}
|
|
309
|
+
return closes;
|
|
310
|
+
}
|
|
311
|
+
|
|
261
312
|
/** Handle a bracket construct starting at `chars[open]` (which is `[`).
|
|
262
313
|
* `embed` means a `!` sits just before it. Returns the new position. */
|
|
263
314
|
function bracket(
|
|
264
315
|
chars: string[],
|
|
316
|
+
closes: Closes,
|
|
265
317
|
open: number,
|
|
266
318
|
embed: boolean,
|
|
267
319
|
frames: Frame[],
|
|
@@ -274,27 +326,14 @@ function bracket(
|
|
|
274
326
|
return bang + 1;
|
|
275
327
|
};
|
|
276
328
|
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
while (k < chars.length) {
|
|
281
|
-
const c = chars[k]!;
|
|
282
|
-
if (c === "\\") {
|
|
283
|
-
k += 1;
|
|
284
|
-
} else if (c === "[") {
|
|
285
|
-
depth += 1;
|
|
286
|
-
} else if (c === "]") {
|
|
287
|
-
depth -= 1;
|
|
288
|
-
if (depth === 0) {
|
|
289
|
-
break;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
k += 1;
|
|
293
|
-
}
|
|
294
|
-
if (k >= chars.length) {
|
|
329
|
+
// The matching `]` (balanced, escape-aware).
|
|
330
|
+
const k = closes.brackets[open]!;
|
|
331
|
+
if (k < 0) {
|
|
295
332
|
return fallback();
|
|
296
333
|
}
|
|
297
|
-
|
|
334
|
+
// Copied only once a construct is sure to want it: copying up front made
|
|
335
|
+
// nested brackets (`[`×n `]`×n) quadratic.
|
|
336
|
+
const interior = (): string => chars.slice(open + 1, k).join("");
|
|
298
337
|
|
|
299
338
|
// Link / embed: `](` with a lexable target.
|
|
300
339
|
if (chars[k + 1] === "(") {
|
|
@@ -303,7 +342,7 @@ function bracket(
|
|
|
303
342
|
if (!embed && base + totalDepth(frames) >= MAX_INLINE_DEPTH) {
|
|
304
343
|
return fallback();
|
|
305
344
|
}
|
|
306
|
-
const lexed = lexTarget(chars, k + 2);
|
|
345
|
+
const lexed = lexTarget(chars, closes.parens[k + 1]!, k + 2);
|
|
307
346
|
if (lexed !== null) {
|
|
308
347
|
const depth = base + totalDepth(frames) + 1;
|
|
309
348
|
// The link text's own positions: the interior is a contiguous slice
|
|
@@ -314,8 +353,8 @@ function bracket(
|
|
|
314
353
|
mk(
|
|
315
354
|
P,
|
|
316
355
|
embed
|
|
317
|
-
? { type: "embed", target: lexed.target, alt: resolveEscapes(interior) }
|
|
318
|
-
: { type: "link", target: lexed.target, children: parseInlinesAt(interior, depth, subP) },
|
|
356
|
+
? { type: "embed", target: lexed.target, alt: resolveEscapes(interior()) }
|
|
357
|
+
: { type: "link", target: lexed.target, children: parseInlinesAt(interior(), depth, subP) },
|
|
319
358
|
bang,
|
|
320
359
|
lexed.end,
|
|
321
360
|
),
|
|
@@ -326,9 +365,9 @@ function bracket(
|
|
|
326
365
|
}
|
|
327
366
|
|
|
328
367
|
// Span closer: `[/name]` must name the innermost open span.
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
|
|
368
|
+
if (chars[open + 1] === "/") {
|
|
369
|
+
if (isNameAt(chars, open + 2, k)) {
|
|
370
|
+
const name = chars.slice(open + 2, k).join("");
|
|
332
371
|
if (embed) {
|
|
333
372
|
pushStr(frames, "!", P, bang, bang + 1); // the ! belongs to links/embeds, not spans
|
|
334
373
|
}
|
|
@@ -349,7 +388,7 @@ function bracket(
|
|
|
349
388
|
|
|
350
389
|
// Span opener: `[name ...]`, with the BBCode default-parameter idiom
|
|
351
390
|
// (`[color=red]` puts `color=red` in the span's own attrs).
|
|
352
|
-
const opener = parseSpanOpener(interior);
|
|
391
|
+
const opener = maybeSpanOpener(chars, open + 1, k) ? parseSpanOpener(interior()) : null;
|
|
353
392
|
if (opener !== null) {
|
|
354
393
|
if (embed) {
|
|
355
394
|
pushStr(frames, "!", P, bang, bang + 1); // the ! belongs to links/embeds, not spans
|
|
@@ -373,6 +412,52 @@ function bracket(
|
|
|
373
412
|
return fallback();
|
|
374
413
|
}
|
|
375
414
|
|
|
415
|
+
function isNameStart(c: string | undefined): boolean {
|
|
416
|
+
return c !== undefined && c >= "a" && c <= "z";
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function isNameChar(c: string): boolean {
|
|
420
|
+
return (c >= "a" && c <= "z") || (c >= "0" && c <= "9") || c === "_" || c === "-";
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** isName over chars[from, to) in place: it stops at the first non-name
|
|
424
|
+
* char, so a failed closer (`[/[/[/`...) costs no copy of its interior. */
|
|
425
|
+
function isNameAt(chars: string[], from: number, to: number): boolean {
|
|
426
|
+
if (from >= to || !isNameStart(chars[from])) {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
for (let k = from + 1; k < to; k += 1) {
|
|
430
|
+
if (!isNameChar(chars[k]!)) {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** parseSpanOpener's early rejections, checked in place: a name, then `=`,
|
|
438
|
+
* a space or tab, or the end. Spares `[a[a[a`... a copy of each interior
|
|
439
|
+
* only to fail on its second char. And the opener cap: nested openers each
|
|
440
|
+
* re-parse the attribute lists inside them, so an uncapped interior made
|
|
441
|
+
* `[b a=`... quadratic. */
|
|
442
|
+
function maybeSpanOpener(chars: string[], from: number, to: number): boolean {
|
|
443
|
+
if (from >= to || !isNameStart(chars[from])) {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
let bytes = 0;
|
|
447
|
+
for (let j = from; j < to; j++) {
|
|
448
|
+
const cp = chars[j]!.codePointAt(0)!;
|
|
449
|
+
bytes += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
|
|
450
|
+
if (bytes > MAX_SPAN_OPENER_BYTES) {
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
let k = from + 1;
|
|
455
|
+
while (k < to && isNameChar(chars[k]!)) {
|
|
456
|
+
k += 1;
|
|
457
|
+
}
|
|
458
|
+
return k === to || chars[k] === "=" || chars[k] === " " || chars[k] === "\t";
|
|
459
|
+
}
|
|
460
|
+
|
|
376
461
|
function parseSpanOpener(interior: string): { name: string; attrs: Attrs } | null {
|
|
377
462
|
const nlen = nameLen(interior);
|
|
378
463
|
if (nlen === 0) {
|
|
@@ -407,29 +492,31 @@ function parseSpanOpener(interior: string): { name: string; attrs: Attrs } | nul
|
|
|
407
492
|
}
|
|
408
493
|
|
|
409
494
|
/** Lex a link/embed target from `chars[from]`: no whitespace, balanced
|
|
410
|
-
* parens, an unbalanced `)` ends it.
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
return { target, end: k + 1 };
|
|
422
|
-
}
|
|
423
|
-
if (c === ")") {
|
|
424
|
-
depth -= 1;
|
|
425
|
-
} else if (c === "(") {
|
|
426
|
-
depth += 1;
|
|
427
|
-
} else if (isWs(c)) {
|
|
495
|
+
* parens, an unbalanced `)` ends it. `close` is that `)` (the match of the
|
|
496
|
+
* `(` before `from`), or -1. Returns the position after `)`. */
|
|
497
|
+
function lexTarget(chars: string[], close: number, from: number): { target: string; end: number } | null {
|
|
498
|
+
// Every char is at least a byte: a target longer than the cap in chars
|
|
499
|
+
// fails without a look, so no failed target costs more than the cap.
|
|
500
|
+
if (close < 0 || close - from > MAX_TARGET_BYTES) {
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
let bytes = 0;
|
|
504
|
+
for (let k = from; k < close; k += 1) {
|
|
505
|
+
if (isWs(chars[k]!)) {
|
|
428
506
|
return null;
|
|
429
507
|
}
|
|
430
|
-
|
|
508
|
+
bytes += cpUtf8Len(chars[k]!);
|
|
431
509
|
}
|
|
432
|
-
|
|
510
|
+
if (bytes > MAX_TARGET_BYTES) {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
return { target: chars.slice(from, close).join(""), end: close + 1 };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** utf8Len of one code point (a lone surrogate encodes as U+FFFD: 3). */
|
|
517
|
+
function cpUtf8Len(c: string): number {
|
|
518
|
+
const cp = c.codePointAt(0)!;
|
|
519
|
+
return cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
|
|
433
520
|
}
|
|
434
521
|
|
|
435
522
|
function resolveEscapes(s: string): string {
|