@geml/geml 1.7.8 → 1.8.2

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/inline.js CHANGED
@@ -72,41 +72,57 @@ function classifyDest(dest) {
72
72
  return { doc: d };
73
73
  return {};
74
74
  }
75
+ function pairsOf(s) {
76
+ const br = new Int32Array(s.length).fill(-1);
77
+ const pa = new Int32Array(s.length).fill(-1);
78
+ const bs = [], ps = [];
79
+ for (let i = 0; i < s.length; i++) {
80
+ const c = s[i];
81
+ if (c === "[")
82
+ bs.push(i);
83
+ else if (c === "]") {
84
+ const j = bs.pop();
85
+ if (j !== undefined)
86
+ br[j] = i;
87
+ }
88
+ else if (c === "(")
89
+ ps.push(i);
90
+ else if (c === ")") {
91
+ const j = ps.pop();
92
+ if (j !== undefined)
93
+ pa[j] = i;
94
+ }
95
+ }
96
+ return { br, pa, off: 0 };
97
+ }
98
+ // A link label is a bracket-balanced span, so the map restricted to it IS the
99
+ // map of the substring — the maps are built ONCE for the whole inline and read
100
+ // through an offset by every nesting level, rather than rebuilt per level (which
101
+ // at the 100-deep cap would have multiplied a 1 MB line's map memory by 100).
102
+ // A partner falling outside the window means "unbalanced here", which is what
103
+ // the substring-local scan reported.
104
+ function pairEnd(m, p, s, i) {
105
+ const j = m[p.off + i];
106
+ if (j === undefined || j < 0)
107
+ return -1;
108
+ const end = j - p.off;
109
+ return end < s.length ? end : -1;
110
+ }
75
111
  // Read a balanced `(...)` starting at s[i]==='('. Returns content and index
76
112
  // just past the closing ')', or null if unbalanced.
77
- function readParen(s, i) {
113
+ function readParen(s, i, p) {
78
114
  if (s[i] !== "(")
79
115
  return null;
80
- let depth = 0;
81
- for (let j = i; j < s.length; j++) {
82
- const c = s[j];
83
- if (c === "(")
84
- depth++;
85
- else if (c === ")") {
86
- depth--;
87
- if (depth === 0)
88
- return { content: s.slice(i + 1, j), end: j + 1 };
89
- }
90
- }
91
- return null;
116
+ const j = pairEnd(p.pa, p, s, i);
117
+ return j < 0 ? null : { content: s.slice(i + 1, j), end: j + 1 };
92
118
  }
93
119
  // Read a balanced `[...]` starting at s[i]==='['. Returns content and index
94
120
  // just past the closing ']', or null if unbalanced.
95
- function readBracket(s, i) {
121
+ function readBracket(s, i, p) {
96
122
  if (s[i] !== "[")
97
123
  return null;
98
- let depth = 0;
99
- for (let j = i; j < s.length; j++) {
100
- const c = s[j];
101
- if (c === "[")
102
- depth++;
103
- else if (c === "]") {
104
- depth--;
105
- if (depth === 0)
106
- return { content: s.slice(i + 1, j), end: j + 1 };
107
- }
108
- }
109
- return null;
124
+ const j = pairEnd(p.br, p, s, i);
125
+ return j < 0 ? null : { content: s.slice(i + 1, j), end: j + 1 };
110
126
  }
111
127
  // Optional `{…}` attribute object immediately following a construct.
112
128
  function readAttrs(s, i) {
@@ -120,13 +136,18 @@ function readAttrs(s, i) {
120
136
  // Phase A: pull out high-priority atoms (escapes, code, math, media, links,
121
137
  // auto-refs, footnotes, hard breaks). Everything else is left as text runs for
122
138
  // phase B (emphasis). Children of links are fully re-parsed.
123
- function scanAtoms(s, line, sink, depth = 0) {
139
+ function scanAtoms(s, line, sink, depth, p) {
124
140
  const out = [];
125
141
  let buf = "";
126
142
  const flush = () => { if (buf) {
127
143
  out.push(buf);
128
144
  buf = "";
129
145
  } };
146
+ // Emit `node` as the atom occupying source span [start, end).
147
+ const atom = (node, start, end) => {
148
+ flush();
149
+ out.push({ node, first: s[start], last: s[end - 1] });
150
+ };
130
151
  let i = 0;
131
152
  while (i < s.length) {
132
153
  const c = s[i];
@@ -134,16 +155,15 @@ function scanAtoms(s, line, sink, depth = 0) {
134
155
  if (c === "\\") {
135
156
  const next = s[i + 1];
136
157
  if (next === undefined || next === "\n") { // line-final backslash
137
- flush();
138
- out.push({ type: "break" });
139
- i += next === undefined ? 1 : 2;
158
+ const end = i + (next === undefined ? 1 : 2);
159
+ atom({ type: "break" }, i, end);
160
+ i = end;
140
161
  continue;
141
162
  }
142
163
  if (/[!-/:-@[-`{-~]/.test(next)) {
143
164
  // ASCII punctuation -> literal, emitted as its own text atom so phase B
144
165
  // (emphasis) cannot mistake an escaped `*`/`~` for a delimiter (§5.3(1)).
145
- flush();
146
- out.push({ type: "text", value: next });
166
+ atom({ type: "text", value: next }, i, i + 2);
147
167
  i += 2;
148
168
  continue;
149
169
  }
@@ -159,8 +179,7 @@ function scanAtoms(s, line, sink, depth = 0) {
159
179
  const fence = "`".repeat(n);
160
180
  const close = s.indexOf(fence, i + n);
161
181
  if (close >= 0) {
162
- flush();
163
- out.push({ type: "code", value: s.slice(i + n, close) });
182
+ atom({ type: "code", value: s.slice(i + n, close) }, i, close + n);
164
183
  i = close + n;
165
184
  continue;
166
185
  }
@@ -172,8 +191,7 @@ function scanAtoms(s, line, sink, depth = 0) {
172
191
  if (c === "$") {
173
192
  const close = s.indexOf("$", i + 1);
174
193
  if (close > i + 1) {
175
- flush();
176
- out.push({ type: "math", value: s.slice(i + 1, close) });
194
+ atom({ type: "math", value: s.slice(i + 1, close) }, i, close + 1);
177
195
  i = close + 1;
178
196
  continue;
179
197
  }
@@ -187,15 +205,14 @@ function scanAtoms(s, line, sink, depth = 0) {
187
205
  // `![[#x]](y)` would be claimed whole — the parenthesis run has to stay
188
206
  // literal text, which is what this ordering pins.
189
207
  if (c === "!" && s[i + 1] === "[" && s[i + 2] === "[") {
190
- const inner = readBracket(s, i + 2); // the inner [...] after `![`
208
+ const inner = readBracket(s, i + 2, p); // the inner [...] after `![`
191
209
  if (inner && s[inner.end] === "]") {
192
210
  const { doc, anchor } = classifyDest(inner.content.trim());
193
211
  if (anchor) {
194
- flush();
195
212
  const node = { type: "project", anchor };
196
213
  if (doc)
197
214
  node.doc = doc;
198
- out.push(node);
215
+ atom(node, i, inner.end + 1);
199
216
  // Validated by the same §8 resolver as any reference; the target's TYPE
200
217
  // is checked separately, since only inline content can be projected.
201
218
  sink.refs.push({ kind: doc ? "cross" : "autoref", doc, anchor, line });
@@ -206,8 +223,8 @@ function scanAtoms(s, line, sink, depth = 0) {
206
223
  }
207
224
  }
208
225
  if (c === "!" && s[i + 1] === "[") {
209
- const label = readBracket(s, i + 1);
210
- const paren = label ? readParen(s, label.end) : null;
226
+ const label = readBracket(s, i + 1, p);
227
+ const paren = label ? readParen(s, label.end, p) : null;
211
228
  if (label && paren) {
212
229
  const a = readAttrs(s, paren.end);
213
230
  const attrObj = a ? a.attrs : { classes: [], attrs: {} };
@@ -232,24 +249,22 @@ function scanAtoms(s, line, sink, depth = 0) {
232
249
  if (inf)
233
250
  node.as = inf;
234
251
  }
235
- flush();
236
- out.push(node);
252
+ atom(node, i, a ? a.end : paren.end);
237
253
  i = a ? a.end : paren.end;
238
254
  continue;
239
255
  }
240
256
  }
241
257
  // §5.3(2): auto-reference [[#id]].
242
258
  if (c === "[" && s[i + 1] === "[") {
243
- const inner = readBracket(s, i + 1); // inner [...] after the first [
259
+ const inner = readBracket(s, i + 1, p); // inner [...] after the first [
244
260
  if (inner && s[inner.end] === "]") {
245
261
  const target = inner.content.trim();
246
262
  const { doc, anchor } = classifyDest(target);
247
263
  if (anchor) {
248
- flush();
249
264
  const node = { type: "autoref", anchor };
250
265
  if (doc)
251
266
  node.doc = doc;
252
- out.push(node);
267
+ atom(node, i, inner.end + 1);
253
268
  sink.refs.push({ kind: doc ? "cross" : "autoref", doc, anchor, line });
254
269
  i = inner.end + 1;
255
270
  continue;
@@ -258,11 +273,10 @@ function scanAtoms(s, line, sink, depth = 0) {
258
273
  }
259
274
  // §5.3(2): footnote reference [^id].
260
275
  if (c === "[" && s[i + 1] === "^") {
261
- const br = readBracket(s, i);
276
+ const br = readBracket(s, i, p);
262
277
  if (br && br.content.startsWith("^")) {
263
278
  const ref = br.content.slice(1).trim();
264
- flush();
265
- out.push({ type: "footnote", ref });
279
+ atom({ type: "footnote", ref }, i, br.end);
266
280
  sink.refs.push({ kind: "footnote", anchor: ref, line });
267
281
  i = br.end;
268
282
  continue;
@@ -270,15 +284,17 @@ function scanAtoms(s, line, sink, depth = 0) {
270
284
  }
271
285
  // §5.3(2): link [text](dest){…}.
272
286
  if (c === "[") {
273
- const label = readBracket(s, i);
274
- const paren = label ? readParen(s, label.end) : null;
287
+ const label = readBracket(s, i, p);
288
+ const paren = label ? readParen(s, label.end, p) : null;
275
289
  if (label && paren) {
276
290
  const a = readAttrs(s, paren.end);
277
291
  const attrObj = a ? a.attrs : { classes: [], attrs: {} };
278
292
  const dest = classifyDest(paren.content);
279
293
  const node = {
280
294
  type: "link",
281
- children: parseInline(label.content, line, sink, depth + 1),
295
+ // The label window starts one character past this `[`, so the shared
296
+ // maps are read at that offset instead of being rebuilt for it.
297
+ children: parseInline(label.content, line, sink, depth + 1, { br: p.br, pa: p.pa, off: p.off + i + 1 }),
282
298
  attrs: attrObj.attrs,
283
299
  };
284
300
  if (dest.href)
@@ -290,8 +306,7 @@ function scanAtoms(s, line, sink, depth = 0) {
290
306
  if (dest.anchor || dest.doc) {
291
307
  sink.refs.push({ kind: dest.doc ? "cross" : "internal", doc: dest.doc, anchor: dest.anchor, line });
292
308
  }
293
- flush();
294
- out.push(node);
309
+ atom(node, i, a ? a.end : paren.end);
295
310
  i = a ? a.end : paren.end;
296
311
  continue;
297
312
  }
@@ -302,19 +317,23 @@ function scanAtoms(s, line, sink, depth = 0) {
302
317
  flush();
303
318
  return out;
304
319
  }
305
- // Phase B: emphasis / strong / strikethrough on a plain text run (§5.3).
320
+ // Phase B: emphasis / strong / strikethrough over the whole inline sequence
321
+ // (§5.3, GEP-0007).
306
322
  //
307
- // A maximal run of `*` is an emphasis delimiter (one `*` -> emphasis, two ->
308
- // strong, longer runs pair greedily); a maximal run of two or more `~` is a
309
- // strikethrough delimiter (a lone `~` is literal). Whether a run may *open*
310
- // and/or *close* is fixed by flanking: it must hug a non-space character, and on
311
- // the side facing a punctuation character it must also have whitespace or
312
- // punctuation on the far side (the CommonMark left/right-flanking rule). Runs are
313
- // then paired by a single left-to-right stack scan with the rule of three, so
314
- // nested and adjacent delimiters resolve to exactly one tree — no leftmost-regex
315
- // guesswork. Delimiters pair only *within* one text run: they never reach across
316
- // a code span, inline math, a link or image (atoms from phase A), or a block
317
- // boundary. Any delimiter left unpaired is literal text.
323
+ // A maximal run of `*` in literal text is an emphasis delimiter (one `*` ->
324
+ // emphasis, two -> strong, longer runs pair greedily); a maximal run of two or
325
+ // more `~` is a strikethrough delimiter (a lone `~` is literal). Whether a run
326
+ // may *open* and/or *close* is fixed by flanking: it must hug a non-space
327
+ // character, and on the side facing a punctuation character it must also have
328
+ // whitespace or punctuation on the far side (the CommonMark left/right-flanking
329
+ // rule). Runs are then paired by a single left-to-right stack scan with the
330
+ // rule of three, so nested and adjacent delimiters resolve to exactly one tree
331
+ // — no leftmost-regex guesswork. Delimiters pair across phase-A atoms a pair
332
+ // may wrap a code span, math, a link or image but the atoms themselves are
333
+ // opaque: characters inside one are never delimiters, and at an atom boundary
334
+ // the flanking test reads the atom's edge source characters (AtomPart). A
335
+ // delimiter run never pairs across a block boundary, and any run left unpaired
336
+ // is literal text.
318
337
  // Unicode punctuation, not just ASCII (§5.3). With an ASCII-only test, `“` and
319
338
  // `,` count as ordinary letters, and a run hugged by CJK punctuation on the
320
339
  // outside and ASCII punctuation on the inside stops flanking: `“*(foo)*”` loses
@@ -328,45 +347,68 @@ function flank(before, after) {
328
347
  const bWS = isWS(before), aWS = isWS(after), bP = isPunct(before), aP = isPunct(after);
329
348
  return { open: !aWS && (!aP || bWS || bP), close: !bWS && (!bP || aWS || aP) };
330
349
  }
331
- // Split a text run into a doubly-linked list of text and delimiter-run nodes.
332
- function tokenizeRuns(s) {
350
+ // Split the mixed phase-A sequence into a doubly-linked list of text,
351
+ // delimiter-run, and atom nodes. A delimiter run at the edge of a text part
352
+ // flanks against the neighboring part's edge character — an atom's recorded
353
+ // source edge, or the adjacent char of a neighboring text part — and against
354
+ // nothing (whitespace) at the ends of the sequence.
355
+ function tokenizeRuns(parts) {
333
356
  let head = null, tail = null;
334
357
  const push = (node) => { node.prev = tail; if (tail)
335
358
  tail.next = node;
336
359
  else
337
360
  head = node; tail = node; };
338
- let i = 0;
339
- while (i < s.length) {
340
- const c = s[i];
341
- if (c === "*" || c === "~") {
342
- let j = i;
343
- while (s[j] === c)
344
- j++;
345
- const n = j - i;
346
- if (c === "~" && n < 2)
347
- push({ t: "text", v: "~", prev: null, next: null });
361
+ // …and thread every delimiter onto the delimiter-only chain as it is pushed.
362
+ let dhead = null, dtail = null, dn = 0;
363
+ const pushDelim = (d) => {
364
+ d.idx = dn++;
365
+ d.dprev = dtail;
366
+ if (dtail)
367
+ dtail.dnext = d;
368
+ else
369
+ dhead = d;
370
+ dtail = d;
371
+ push(d);
372
+ };
373
+ for (let k = 0; k < parts.length; k++) {
374
+ const part = parts[k];
375
+ if (typeof part !== "string") {
376
+ push({ t: "atom", node: part.node, prev: null, next: null });
377
+ continue;
378
+ }
379
+ const s = part;
380
+ // A text part's neighbors are always atoms (or the sequence edge):
381
+ // scanAtoms flushes buffered text exactly when it emits an atom, so two
382
+ // text parts are never adjacent.
383
+ const before0 = k > 0 ? parts[k - 1].last : undefined;
384
+ const after0 = k + 1 < parts.length ? parts[k + 1].first : undefined;
385
+ let i = 0;
386
+ while (i < s.length) {
387
+ const c = s[i];
388
+ if (c === "*" || c === "~") {
389
+ let j = i;
390
+ while (s[j] === c)
391
+ j++;
392
+ const n = j - i;
393
+ if (c === "~" && n < 2)
394
+ push({ t: "text", v: "~", prev: null, next: null });
395
+ else {
396
+ const f = flank(i > 0 ? s[i - 1] : before0, j < s.length ? s[j] : after0);
397
+ pushDelim({ t: "delim", ch: c, n, open: f.open, close: f.close, idx: 0, dprev: null, dnext: null, prev: null, next: null });
398
+ }
399
+ i = j;
400
+ }
348
401
  else {
349
- const f = flank(i > 0 ? s[i - 1] : undefined, j < s.length ? s[j] : undefined);
350
- push({ t: "delim", ch: c, n, open: f.open, close: f.close, prev: null, next: null });
402
+ let j = i;
403
+ while (j < s.length && s[j] !== "*" && s[j] !== "~")
404
+ j++;
405
+ push({ t: "text", v: s.slice(i, j), prev: null, next: null });
406
+ i = j;
351
407
  }
352
- i = j;
353
- }
354
- else {
355
- let j = i;
356
- while (j < s.length && s[j] !== "*" && s[j] !== "~")
357
- j++;
358
- push({ t: "text", v: s.slice(i, j), prev: null, next: null });
359
- i = j;
360
408
  }
361
409
  }
362
- return head;
410
+ return { head, first: dhead };
363
411
  }
364
- const nextDelim = (n) => { for (; n; n = n.next)
365
- if (n.t === "delim")
366
- return n; return null; };
367
- const prevDelim = (n) => { for (; n; n = n.prev)
368
- if (n.t === "delim")
369
- return n; return null; };
370
412
  // Rule of three: when either side can also play the other role, a combined
371
413
  // length that is a multiple of three is only allowed if both lengths are.
372
414
  function rule3(o, c) {
@@ -374,6 +416,18 @@ function rule3(o, c) {
374
416
  return (o.n + c.n) % 3 !== 0 || (o.n % 3 === 0 && c.n % 3 === 0);
375
417
  return true;
376
418
  }
419
+ // Drop a delimiter from the delimiter-only chain. Called for a spent delimiter
420
+ // and for every delimiter that a new wrap swallows: the chain must hold exactly
421
+ // the delimiters still pairable AT THIS LEVEL, or an opener search would reach
422
+ // inside a finished span and splice its own list.
423
+ function unlinkDelim(d) {
424
+ if (d.dprev)
425
+ d.dprev.dnext = d.dnext;
426
+ if (d.dnext)
427
+ d.dnext.dprev = d.dprev;
428
+ d.dprev = null;
429
+ d.dnext = null;
430
+ }
377
431
  function unlink(node, head) {
378
432
  if (node.prev)
379
433
  node.prev.next = node.next;
@@ -381,38 +435,58 @@ function unlink(node, head) {
381
435
  head = node.next;
382
436
  if (node.next)
383
437
  node.next.prev = node.prev;
438
+ if (node.t === "delim")
439
+ unlinkDelim(node);
384
440
  return head;
385
441
  }
386
442
  // The CommonMark emphasis algorithm over the delimiter list: scan closers left
387
443
  // to right, pair each with the nearest eligible opener, wrap the span, and bound
388
444
  // future searches with `bottom` so the scan stays linear and deterministic.
389
- function processEmphasis(head) {
445
+ function processEmphasis(head, first) {
446
+ // The cut-off is a delimiter POSITION, not a node: the delimiter it names can
447
+ // be consumed and unlinked later on, and a stale node reference would then
448
+ // never be reached — the search would run to the head of the list every time,
449
+ // which is the O(n^2) blowup this map exists to prevent. -1 means "no bound
450
+ // yet" (the whole prefix is searchable), matching an unset entry.
390
451
  const bottom = new Map();
391
- let closer = nextDelim(head);
452
+ let closer = first;
392
453
  while (closer) {
393
- if (closer.t !== "delim" || !closer.close) {
394
- closer = nextDelim(closer.next);
454
+ // A `~` run pairs TWO characters at a time, so one leftover character is no
455
+ // longer a delimiter — exactly as a lone `~` was never one (tokenizeRuns).
456
+ // Skipping it here is what keeps `n` from going negative: pairing a spent run
457
+ // again drove `n` past 0, and since the loop only advances on `n === 0` the
458
+ // same closer was re-paired forever, allocating a wrap each time (a hang on
459
+ // `~~~a~~~`) or reaching finalize with n = -1 (`"~".repeat(-1)` threw a
460
+ // RangeError on `~~~~a~~~`).
461
+ if (!closer.close || (closer.ch === "~" && closer.n < 2)) {
462
+ closer = closer.dnext;
395
463
  continue;
396
464
  }
397
465
  const ch = closer.ch;
398
466
  const key = `${ch}${closer.open ? 1 : 0}${closer.n % 3}`;
399
- const stop = bottom.has(key) ? bottom.get(key) : null;
400
- let opener = prevDelim(closer.prev);
467
+ const stop = bottom.get(key) ?? -1;
468
+ let opener = closer.dprev;
401
469
  let found = null;
402
- while (opener && opener !== stop) {
403
- if (opener.t === "delim" && opener.open && opener.ch === ch && rule3(opener, closer)) {
470
+ while (opener && opener.idx > stop) {
471
+ // …and the same for the opener side: a `~` run down to one character can
472
+ // no longer open, so `use = 2` never takes more than a side has left.
473
+ if (opener.open && opener.ch === ch && (ch !== "~" || opener.n >= 2) && rule3(opener, closer)) {
404
474
  found = opener;
405
475
  break;
406
476
  }
407
- opener = prevDelim(opener.prev);
477
+ opener = opener.dprev;
408
478
  }
409
479
  if (found) {
410
480
  const use = ch === "~" ? 2 : (found.n >= 2 && closer.n >= 2 ? 2 : 1);
411
481
  const kind = ch === "~" ? "strike" : use === 2 ? "strong" : "emph";
412
- // Gather and detach the nodes strictly between opener and closer.
482
+ // Gather and detach the nodes strictly between opener and closer. Any
483
+ // delimiter among them is now inside the new span — unpaired and literal —
484
+ // so it leaves the delimiter chain with them.
413
485
  let kidsHead = null, kidsTail = null;
414
486
  for (let p = found.next; p && p !== closer;) {
415
487
  const q = p.next;
488
+ if (p.t === "delim")
489
+ unlinkDelim(p);
416
490
  p.prev = kidsTail;
417
491
  p.next = null;
418
492
  if (kidsTail)
@@ -430,21 +504,25 @@ function processEmphasis(head) {
430
504
  if (found.n === 0)
431
505
  head = unlink(found, head);
432
506
  if (closer.n === 0) {
433
- const after = closer.next;
507
+ const after = closer.dnext;
434
508
  head = unlink(closer, head);
435
- closer = nextDelim(after);
509
+ closer = after;
436
510
  }
437
511
  // else: keep the same closer (it still has delimiter characters left).
438
512
  }
439
513
  else {
440
- bottom.set(key, closer.prev);
441
- closer = nextDelim(closer.next);
514
+ // Nothing before this closer can open for this key, so no later closer
515
+ // with the same key need look past the delimiter just before it either.
516
+ bottom.set(key, closer.dprev ? closer.dprev.idx : -1);
517
+ closer = closer.dnext;
442
518
  }
443
519
  }
444
520
  return head;
445
521
  }
446
522
  // Linked list of (possibly nested) nodes -> Inline[]; unpaired delimiters and
447
- // empty text vanish into literal text, with adjacent text runs merged.
523
+ // empty text vanish into literal text, with adjacent text runs merged. An
524
+ // escaped-punctuation atom is a text Inline, so it folds into its neighbors —
525
+ // the emitted sequence is canonical with no adjacent text nodes at any level.
448
526
  function finalize(head) {
449
527
  const out = [];
450
528
  const pushText = (v) => {
@@ -459,29 +537,25 @@ function finalize(head) {
459
537
  pushText(n.v);
460
538
  else if (n.t === "delim")
461
539
  pushText(n.ch.repeat(n.n));
540
+ else if (n.t === "atom") {
541
+ if (n.node.type === "text")
542
+ pushText(n.node.value);
543
+ else
544
+ out.push(n.node);
545
+ }
462
546
  else
463
547
  out.push({ type: n.kind, children: finalize(n.kids) });
464
548
  }
465
549
  return out;
466
550
  }
467
- function emphasize(text) {
468
- const head = tokenizeRuns(text);
469
- return head ? finalize(processEmphasis(head)) : [];
470
- }
471
- // Coalesce adjacent literal text nodes (e.g. an escaped `*` atom sitting between
472
- // two text runs) so the inline sequence is canonical.
473
- function mergeText(ns) {
474
- const out = [];
475
- for (const n of ns) {
476
- const last = out[out.length - 1];
477
- if (n.type === "text" && last && last.type === "text")
478
- last.value += n.value;
479
- else
480
- out.push(n);
481
- }
482
- return out;
551
+ function emphasize(parts) {
552
+ const { head, first } = tokenizeRuns(parts);
553
+ return head ? finalize(processEmphasis(head, first)) : [];
483
554
  }
484
- export function parseInline(s, line, sink, depth = 0) {
555
+ // `pairs` is internal: the bracket/paren partner maps of the WHOLE inline, with
556
+ // the offset of this call's window into them. Only the recursive link-label call
557
+ // supplies it; every external caller omits it and gets the maps built here.
558
+ export function parseInline(s, line, sink, depth = 0, pairs) {
485
559
  if (depth > MAX_INLINE_NESTING) {
486
560
  // Pathological nesting (thousands of nested link labels) would overflow the
487
561
  // call stack (R2-7). Degrade the over-deep content to text — emphasis only,
@@ -489,15 +563,7 @@ export function parseInline(s, line, sink, depth = 0) {
489
563
  const diags = sink.diags;
490
564
  if (Array.isArray(diags) && !diags.some((d) => d.code === "inline-nesting-too-deep"))
491
565
  diags.push({ severity: "error", code: "inline-nesting-too-deep", message: `inline nesting too deep (max ${MAX_INLINE_NESTING})`, line });
492
- return mergeText(emphasize(s));
493
- }
494
- const atoms = scanAtoms(s, line, sink, depth);
495
- const out = [];
496
- for (const a of atoms) {
497
- if (typeof a === "string")
498
- out.push(...emphasize(a));
499
- else
500
- out.push(a);
566
+ return emphasize([s]);
501
567
  }
502
- return mergeText(out);
568
+ return emphasize(scanAtoms(s, line, sink, depth, pairs ?? pairsOf(s)));
503
569
  }