@cube-drone/marquee-parser 0.9.2 → 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 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.replace(/[ \t]+$/, "") === "---") {
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
- return s.replace(/^[ \t]+|[ \t]+$/g, "");
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.replace(/[ \t]+$/, "") === "---" ||
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.replace(/[ \t]+$/, "");
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).replace(/[ \t]+$/, "");
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 { isName, nameLen, parseAttrs, parseValue, utf8Len } from "./attrs.js";
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
- /** Handle a bracket construct starting at `chars[open]` (which is `[`).
211
- * `embed` means a `!` sits just before it. Returns the new position. */
212
- function bracket(chars, open, embed, frames, base, P) {
213
- const bang = embed ? open - 1 : open;
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
- // Find the matching `]` (balanced, escape-aware).
219
- let depth = 1;
220
- let k = open + 1;
221
- while (k < chars.length) {
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 += 1;
238
+ level[k] = depth;
236
239
  }
237
- if (k >= chars.length) {
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
- const interior = chars.slice(open + 1, k).join("");
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 (interior.startsWith("/")) {
263
- const name = interior.slice(1);
264
- if (isName(name)) {
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. Returns the position after `)`. */
341
- function lexTarget(chars, from) {
342
- let depth = 0;
343
- let k = from;
344
- while (k < chars.length) {
345
- const c = chars[k];
346
- if (c === ")" && depth === 0) {
347
- const target = chars.slice(from, k).join("");
348
- if (utf8Len(target) > MAX_TARGET_BYTES) {
349
- return null;
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
- k += 1;
421
+ bytes += cpUtf8Len(chars[k]);
363
422
  }
364
- return null;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cube-drone/marquee-parser",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "Reference parser for the Marquee markup language",
5
5
  "license": "MPL-2.0",
6
6
  "repository": {
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.replace(/[ \t]+$/, "") === "---") {
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
- return s.replace(/^[ \t]+|[ \t]+$/g, "");
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.replace(/[ \t]+$/, "") === "---" ||
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.replace(/[ \t]+$/, "");
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).replace(/[ \t]+$/, "");
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 { isName, nameLen, parseAttrs, parseValue, utf8Len } from "./attrs.ts";
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
- // Find the matching `]` (balanced, escape-aware).
278
- let depth = 1;
279
- let k = open + 1;
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
- const interior = chars.slice(open + 1, k).join("");
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 (interior.startsWith("/")) {
330
- const name = interior.slice(1);
331
- if (isName(name)) {
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. Returns the position after `)`. */
411
- function lexTarget(chars: string[], from: number): { target: string; end: number } | null {
412
- let depth = 0;
413
- let k = from;
414
- while (k < chars.length) {
415
- const c = chars[k]!;
416
- if (c === ")" && depth === 0) {
417
- const target = chars.slice(from, k).join("");
418
- if (utf8Len(target) > MAX_TARGET_BYTES) {
419
- return null;
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
- k += 1;
508
+ bytes += cpUtf8Len(chars[k]!);
431
509
  }
432
- return null;
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 {