@helping-ai-workflow/md2doc 3.17.7 → 3.17.9

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.
@@ -17387,7 +17387,16 @@
17387
17387
  // after a space (`**a **`) is not a closing delimiter in markdown.
17388
17388
  const r = document.createRange();
17389
17389
  r.selectNodeContents(cell);
17390
- if (trimRangeToText(r)) wrapRangeIn(r, tag, cell);
17390
+ if (trimRangeToText(r)) {
17391
+ // v3.17.8 D2 (final review I2): a cell whose text is one code span
17392
+ // (a signal-name column) trims to the code's own text node, and the
17393
+ // wrap landed inside the <code>, which the save drops. The mark goes
17394
+ // around the code instead (`**`b`**`), as it goes around every other
17395
+ // cell's text.
17396
+ const code = tag !== 'CODE' ? wholeSelectionMark(r, 'CODE', cell) : null;
17397
+ if (code) r.selectNode(code);
17398
+ wrapRangeIn(r, tag, cell);
17399
+ }
17391
17400
  }
17392
17401
  });
17393
17402
  snapBurstIfActive(burst.editEl, 'range-mark');
@@ -21616,6 +21625,15 @@
21616
21625
 
21617
21626
  // Applies the toggle policy for a plain mark type (STRONG/EM/CODE/DEL/U —
21618
21627
  // link has its own entry point below because it also needs a URL).
21628
+ // v3.17.8 D2: Markdown cannot hold a mark inside a code span (serializeCode()
21629
+ // writes the code's textContent), so a WRAP of anything but the code itself is
21630
+ // refused when the selection lies wholly inside one <code>. One helper, read
21631
+ // by selectionMarkStates() (the buttons' 'inert') and by the actions (the
21632
+ // keyboard reaches them without a button), so the two cannot drift. Unwrapping
21633
+ // a mark that wraps the code from outside stays allowed: that is spellable.
21634
+ function wrapRefusedInCode(range, tag, root) {
21635
+ return tag !== 'CODE' && !!wholeSelectionMark(range, 'CODE', root);
21636
+ }
21619
21637
  function applyMarkToggle(tag) {
21620
21638
  if (cellRangeBurst()) { applyMarkToCellRange(tag); return; }
21621
21639
  const root = selToolbarEditEl;
@@ -21646,6 +21664,11 @@
21646
21664
  return;
21647
21665
  }
21648
21666
  if (!trimRangeToText(range)) return; // 全空白/collapsed 選取:no-op
21667
+ // v3.17.8 D2: judged on the TRIMMED range, as the wrap sees it. A double
21668
+ // click on a code span's last word also selects the space after it; the
21669
+ // untrimmed range then ends outside the <code>, the trim pulls it back in,
21670
+ // and the wrap landed inside the code (final review I1).
21671
+ if (wrapRefusedInCode(range, tag, root)) return;
21649
21672
  snapBurstIfActive(root, 'mark-pre');
21650
21673
  reselectAndReposition(wrapRangeIn(range, tag, root));
21651
21674
  snapBurstIfActive(root, 'mark');
@@ -21990,6 +22013,7 @@
21990
22013
  // An all-whitespace range trims to nothing: bail before the field opens,
21991
22014
  // or accepting it would wrap a bare space in an <a>.
21992
22015
  if (!trimRangeToText(range)) return;
22016
+ if (wrapRefusedInCode(range, 'A', root)) return; // v3.17.8 D2
21993
22017
  openLinkField({ root, range, existing: null, prefill: '' });
21994
22018
  }
21995
22019
 
@@ -23382,7 +23406,9 @@
23382
23406
  // <strong> was gone, the paragraph's text was unchanged, and the file
23383
23407
  // saved back with no `**` in it. So drawing every button inert there
23384
23408
  // would grey out a button that acts.
23385
- // * 'A' never reaches it. The link button does not go through
23409
+ // * (v3.17.8 D2) A selection wholly inside one <code> makes every other
23410
+ // tag's would-wrap state 'inert' too, 'A' included: wrapRefusedInCode().
23411
+ // * Otherwise 'A' never reaches it. The link button does not go through
23386
23412
  // applyMarkToggle() at all — applyLinkToggle() has the same first two
23387
23413
  // branches, and since v3.4.0 its third refuses an all-whitespace
23388
23414
  // selection through trimRangeToText() before the URL field opens. The
@@ -23405,11 +23431,13 @@
23405
23431
  if (!hasFormattableSelection(sel)) return null;
23406
23432
  const root = selToolbarEditEl;
23407
23433
  const range = sel.getRangeAt(0);
23408
- const wrappable = trimRangeToText(range.cloneRange());
23434
+ const trimmed = range.cloneRange();
23435
+ const wrappable = trimRangeToText(trimmed);
23409
23436
  const out = {};
23410
23437
  for (const tag of MARK_TAGS) {
23411
23438
  if (wholeSelectionMark(range, tag, root)) out[tag] = 'whole';
23412
23439
  else if (overlappingMarks(range, tag, root).length > 0) out[tag] = 'partial';
23440
+ else if (wrapRefusedInCode(wrappable ? trimmed : range, tag, root)) out[tag] = 'inert'; // v3.17.8 D2, trimmed as the wrap sees it
23413
23441
  else if (wrappable || tag === 'A') out[tag] = 'none';
23414
23442
  else out[tag] = 'inert';
23415
23443
  }
@@ -26109,6 +26137,15 @@
26109
26137
  // file kept the plain word. This listener is registered ahead of the
26110
26138
  // `native-format-pre` one below (both capture phase on document, so they
26111
26139
  // run in that order), so the burst exists when that one snaps it.
26140
+ // v3.17.8 D2: the browser's own Ctrl+B / I / U must not wrap inside a code
26141
+ // span either (it would draw <b> in a <code> that the save then drops).
26142
+ document.addEventListener('beforeinput', (e) => {
26143
+ if (!NATIVE_FORMAT_INPUT_TYPES.has(e.inputType) || e.inputType === 'formatRemove') return;
26144
+ const surface = e.target && e.target.closest ? e.target.closest('.ed-wys-armed, .ed-wys-cell') : null;
26145
+ const sel = window.getSelection();
26146
+ if (!surface || !sel || !sel.rangeCount || sel.isCollapsed) return;
26147
+ if (wholeSelectionMark(sel.getRangeAt(0), 'CODE', surface)) e.preventDefault();
26148
+ }, true);
26112
26149
  document.addEventListener('beforeinput', (e) => {
26113
26150
  if (e.defaultPrevented) return;
26114
26151
  const t = e.inputType;
@@ -25,6 +25,7 @@
25
25
  return ch !== undefined && /\w/.test(ch);
26
26
  }
27
27
 
28
+ const REFERENCE_SHAPE = /^&#?\w+;/;
28
29
  function escapeText(s) {
29
30
  let out = '';
30
31
  for (let i = 0; i < s.length; i++) {
@@ -36,6 +37,13 @@
36
37
  if (c === ']') { out += '\\]'; continue; }
37
38
  if (c === '<') { out += '&lt;'; continue; }
38
39
  if (c === '~') { out += '\\~'; continue; }
40
+ // v3.17.8 D3: an `&` that starts something shaped like a character
41
+ // reference (`&amp;`, `&#38;`, `&x_1;`) is written `\&`, or the reader
42
+ // decodes what the person typed (`a &amp; b` showed `a & b`). The shape
43
+ // is marked's own, `&#?\w+;` (its escape() leaves exactly that alone),
44
+ // so it covers every reference a browser could decode. Any other `&`
45
+ // (`AT&T`, `&copy` with no `;`) is shown as typed and stays bare.
46
+ if (c === '&' && REFERENCE_SHAPE.test(s.slice(i, i + 40))) { out += '\\&'; continue; }
39
47
  if (c === '_') {
40
48
  const prev = s[i - 1];
41
49
  const next = s[i + 1];
@@ -381,6 +381,20 @@ function htmlToMarkdown(html) {
381
381
  codeBlockStyle: 'fenced',
382
382
  emDelimiter: '*'
383
383
  });
384
+ // v3.17.8 D3: turndown's escape() covers Markdown punctuation only, so
385
+ // pasted TEXT that spells HTML came back as markup: `<b>x</b>` turned bold,
386
+ // `<!-- c -->` vanished, `&amp;` showed as `&`. A `<` that could open a tag,
387
+ // a comment or an autolink is written `&lt;`, and an `&` shaped like a
388
+ // character reference is written `\&` -- the spellings inline-md.js's
389
+ // escapeText() gives typed text. A bare `<` (`1 < 2`) or `&` (`AT&T`) is
390
+ // already shown as typed and stays bare. Code is not escaped by turndown
391
+ // at all, and needs none: a code span is literal.
392
+ var baseEscape = service.escape;
393
+ service.escape = function (s) {
394
+ return baseEscape.call(this, s)
395
+ .replace(/&(?=#?\w+;)/g, '\\&')
396
+ .replace(/<(?=[A-Za-z\/!?])/g, '&lt;');
397
+ };
384
398
  addStrikethroughRule(service);
385
399
  addFlankingEmphasisRules(service);
386
400
  addTableRules(service);
@@ -29,14 +29,14 @@
29
29
  // nothing to do with text alignment. Reading them as alignment would be a
30
30
  // bug; they are deliberately never consulted below.
31
31
  //
32
- // ── Emission form (spec §4 / task-5 brief, amended by v3.0.1) ──────────
33
- // There are TWO emission forms, and exactly one gate selects between them:
34
- // whether serializeTable() was handed an `originalMd` it could turn into a
35
- // usable width source (columnWidthsOf() parsed it, AND the greedy
36
- // header-TEXT walk in serializeTable() matched EVERY original column, in
37
- // order — the all-or-nothing gate). No second argument, an unparseable or
38
- // ragged original, a renamed header, a dropped column: all of them land on
39
- // form A.
32
+ // ── Emission form (spec §4 / task-5 brief, amended by v3.0.1, v3.17.8) ─
33
+ // There are TWO emission forms for the padding. Form B applies when
34
+ // serializeTable() was handed an `originalMd` it could turn into a width
35
+ // source: columnWidthsOf() parsed it (not ragged) and the forward header
36
+ // scan in serializeTable() found no MOVED column. Since v3.0.2 a renamed
37
+ // or dropped column no longer voids it; only that column loses its width.
38
+ // No second argument, an unparseable or ragged original, a moved column:
39
+ // all of them land on form A.
40
40
  //
41
41
  // A. MINIMAL form (pre-v3.0.1, unchanged, and still what
42
42
  // `serializeTable(tableEl)` with no second argument emits — byte for
@@ -60,18 +60,30 @@
60
60
  // separators Prettier and VS Code emit ('| ----- | --- |') survive a
61
61
  // no-op instead of being rebuilt flush ('|-------|-----|').
62
62
  //
63
- // Independent of the form: a literal '|' inside
64
- // a cell -> '&#124;'; a cell newline is the literal '<br>' inline-md.js's
63
+ // Cell bytes (v3.17.8 F1, both forms): a cell whose original field reads
64
+ // the same as its current content (see readsSame()) is written with the
65
+ // original field's bytes, trimmed; every other cell gets the canonical
66
+ // spelling below. A header row or body row whose cells were ALL reused, in
67
+ // the original columns, with no column widened past that line's own
68
+ // fields, is the original line itself (trimmed), odd padding like '|a|b|'
69
+ // included. Body rows are paired with original rows by pairBodyRows(), so a
70
+ // row inserted, deleted or moved leaves the others as they were. With no
71
+ // parser nothing is reused, and the output is what it was before v3.17.8.
72
+ //
73
+ // Canonical spelling: a literal '|' inside a cell's text -> '&#124;'; a
74
+ // cell newline is the literal '<br>' inline-md.js's
65
75
  // serializeInline() already emits for both a real <br> node and a
66
76
  // contenteditable <div> line-split boundary — AND (final-review Finding 1
67
77
  // defense-in-depth) any raw '\n'/'\r' that still reaches a text node is
68
78
  // collapsed to '<br>' here too, via escapeNewlines() below, so a cell can
69
79
  // never split serializeTable()'s output into more physical lines than
70
- // it has rows. No trailing whitespace on any emitted line (every row/
71
- // separator line is built as '|'-delimited fields, so it always ends in
72
- // '|', never in whitespace, by construction). A '|' inside a <code> span
73
- // is written as '\|' instead (v3.17.6) — see codePipesOf() below; the one
74
- // code text that has no table spelling reports 'CODE' unsupported.
80
+ // it has rows. A '|' inside a <code> span is written as '\|' instead
81
+ // (v3.17.6) — see codePipesOf() below; the one code text that has no
82
+ // table spelling reports 'CODE' unsupported. A reused field never holds a
83
+ // newline or an unescaped '|' (it is one field of one source line). No
84
+ // trailing whitespace on any emitted line: a built row or separator is
85
+ // '|'-delimited fields, and an original line is trimmed and, to be read
86
+ // at all, ends in an unescaped '|' (splitFields()).
75
87
  //
76
88
  // Constrained (same as inline-md.js, and for the same reason: the node
77
89
  // test drives this with the hand-rolled element stub from
@@ -190,22 +202,35 @@
190
202
  // width of 3 is indistinguishable from this serializer's own minimal
191
203
  // form, which is exactly 3 on every row by construction).
192
204
  //
193
- // Source THIS serializer produced holds '\|' only inside code spans (plain
194
- // text gets '&#124;'), and a hand-written table may use '\|' anywhere, so
195
- // the split must not break on an escaped delimiter.
205
+ // A hand-written table may use '\|' anywhere, and since v3.17.8 so may
206
+ // this serializer's output (a reused field keeps the author's '\|'), so
207
+ // the split must not break on an escaped delimiter. Escaped means what
208
+ // marked's splitCells() means (v3.17.8 F3): an ODD run of backslashes
209
+ // before the '|'. A backslash and the character after it are taken as one
210
+ // pair, so '\\|' is an escaped backslash and then a delimiter; before this
211
+ // the split read that '|' as escaped, the row came out one field short and
212
+ // the whole table lost its width memory. A line whose last '|' is escaped
213
+ // has no closing delimiter and is not a row this can read (see the end).
196
214
  function splitFields(line) {
197
215
  const t = line.trim();
198
- if (t.charAt(0) !== '|' || t.charAt(t.length - 1) !== '|' || t.length < 2) return null;
199
- const inner = t.slice(1, -1);
216
+ if (t.charAt(0) !== '|' || t.length < 2) return null;
200
217
  const out = [];
201
218
  let cur = '';
202
- for (let i = 0; i < inner.length; i++) {
203
- const c = inner.charAt(i);
204
- if (c === '\\' && inner.charAt(i + 1) === '|') { cur += '\\|'; i += 1; continue; }
219
+ for (let i = 1; i < t.length; i++) {
220
+ const c = t.charAt(i);
221
+ if (c === '\\' && i + 1 < t.length) { cur += c + t.charAt(i + 1); i += 1; continue; }
205
222
  if (c === '|') { out.push(cur); cur = ''; continue; }
206
223
  cur += c;
207
224
  }
208
- out.push(cur);
225
+ // A line whose last '|' is escaped (`| p | q \|`) has no closing
226
+ // delimiter: not a row this can read, so it is never reused and the table
227
+ // has no width memory. v3.17.8's final-review round 1 split it the old way
228
+ // instead (last '|' closes the row) to keep the width memory, and the
229
+ // re-review found that this loses an edit: the field ` q \` read alone is
230
+ // `q \`, but in its line it reads `q |`, so an edit from `q |` to `q \`
231
+ // read "the same" and the original line came back (N1). Width memory
232
+ // for such a rare table is not worth that.
233
+ if (cur !== '') return null;
209
234
  return out;
210
235
  }
211
236
 
@@ -254,6 +279,9 @@
254
279
  const uniform = widths.map(() => true);
255
280
  let sepFields = null;
256
281
  for (let r = 1; r < rows.length; r++) {
282
+ // A lazy body line (no '|' at all, v3.17.9 D7) has no fields and so no
283
+ // widths: it neither voids nor sets the table's width memory.
284
+ if (r >= 2 && rows[r].indexOf('|') === -1) continue;
257
285
  const fields = splitFields(rows[r]);
258
286
  // A ragged original is not a width source — bail rather than guess.
259
287
  if (!fields || fields.length !== widths.length) return null;
@@ -268,6 +296,304 @@
268
296
  return { widths, headerFields: first.map((f) => f.trim()), sepFields };
269
297
  }
270
298
 
299
+ // ── v3.17.8 F1: an untouched cell keeps its bytes ─────────────────────
300
+ // Every cell is serialized from the DOM, so a cell nobody edited comes
301
+ // back in this file's canonical spelling: a plain '[' as '\[', a
302
+ // plain-text '\|' as '&#124;'. The meaning is the same; the git diff is
303
+ // not. So a current cell reuses its ORIGINAL field (trimmed) when the two
304
+ // spellings READ the same, and that is decided by parsing, not by rules:
305
+ // a private `new Marked({ gfm: true, breaks: false })` (the same family as
306
+ // inline-md.js's getInlineParser(); server.js injects marked before the
307
+ // editor modules) renders each spelling as the one cell of a one-column
308
+ // table, and the two cell HTMLs are compared after character references
309
+ // in their text are decoded (marked passes '&#124;' through, the browser
310
+ // shows '|'). A cell is parsed once per string (readCache).
311
+ //
312
+ // The plain parser has none of md2doc's sub / sup / KaTeX extensions, so
313
+ // an original holding an unescaped '~', '^' or '$' is reused only when it
314
+ // is byte-equal to the canonical spelling (R5). No parser (a page without
315
+ // marked, or setCellParser(null)): nothing is reused and every byte is
316
+ // what the serializer wrote before v3.17.8.
317
+ let cellParser; // undefined: not looked up yet; null: none available
318
+ const readCache = new Map();
319
+ function getCellParser() {
320
+ if (cellParser !== undefined) return cellParser;
321
+ cellParser = null;
322
+ try {
323
+ let lib = null;
324
+ if (typeof module === 'object' && module.exports && typeof require === 'function') lib = require('marked');
325
+ else if (typeof self !== 'undefined' && self.marked) lib = self.marked;
326
+ if (lib && typeof lib.Marked === 'function') {
327
+ const m = new lib.Marked({ gfm: true, breaks: false });
328
+ cellParser = (s) => m.parse(s);
329
+ }
330
+ } catch (e) {
331
+ cellParser = null;
332
+ }
333
+ return cellParser;
334
+ }
335
+ // Tests swap the parser: a function (markdown -> HTML), null to switch
336
+ // reuse off, or nothing to go back to the lookup above.
337
+ function setCellParser(fn) {
338
+ cellParser = typeof fn === 'function' ? fn : (fn === null ? null : undefined);
339
+ readCache.clear();
340
+ }
341
+
342
+ const CELL_HEAD = '<table>\n<thead>\n<tr>\n<th>h</th>\n</tr>\n</thead>\n<tbody><tr>\n<td>';
343
+ const CELL_TAIL = '</td>\n</tr>\n</tbody></table>\n';
344
+ const NAMED_REF = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
345
+ // A named reference outside NAMED_REF cannot be decoded here, so a cell
346
+ // holding one has no reading at all (final review I3): left as it was, the
347
+ // `&` was re-escaped and `&copy;` (shows ©) read the same as the typed
348
+ // text `\&copy;` (shows &copy;), so an edit to that text was overwritten
349
+ // by the original bytes. Such a cell is now reused only when byte-equal.
350
+ const UNKNOWN_REF = {};
351
+ function decodeRef(m, dec, hex, name) {
352
+ if (name !== undefined) {
353
+ if (NAMED_REF[name] !== undefined) return NAMED_REF[name];
354
+ throw UNKNOWN_REF;
355
+ }
356
+ const n = dec !== undefined ? parseInt(dec, 10) : parseInt(hex, 16);
357
+ return n === 0 || n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff) ? '�' : String.fromCodePoint(n);
358
+ }
359
+ // Text between tags: references decoded, then & < > " re-escaped, so two
360
+ // spellings of one character give one string. Tags are left as they are.
361
+ // HTML comments are dropped first: nothing shows them, the editor's DOM
362
+ // walk skips them (so the canonical spelling never has one), and
363
+ // inline-md.js's own read-back (htmlMarks()) skips them too. That keeps
364
+ // an untouched cell's comments, e.g. the `<!-- [[source, Cl.x]] -->`
365
+ // citation anchors in paperwork's hwe1 tables, which the canonical
366
+ // spelling would drop.
367
+ //
368
+ // v3.17.9 D7: a STRAY end tag (no start tag of that name earlier in the
369
+ // cell) is dropped too, for the same reason: the browser's parser ignores
370
+ // it, so the DOM never has it (`</content>` under a table). Kept, so the
371
+ // cell reads by its bytes as before: `</p>` and `</br>`, which the parser
372
+ // turns into an empty paragraph and a line break, and the table-structure
373
+ // names (some close the cell, some are ignored; keeping a tag only means
374
+ // the cell is reused when byte-equal, so the list errs on the safe side).
375
+ const STRAY_END_KEPT = /^(?:p|br|table|caption|colgroup|col|tbody|thead|tfoot|tr|td|th|template)$/;
376
+ function dropStrayEndTags(parts) {
377
+ const open = Object.create(null);
378
+ for (let i = 1; i < parts.length; i += 2) {
379
+ const m = /^<(\/?)([A-Za-z][A-Za-z0-9-]*)/.exec(parts[i]);
380
+ if (!m) continue;
381
+ const name = m[2].toLowerCase();
382
+ if (!m[1]) { open[name] = (open[name] || 0) + 1; continue; }
383
+ if (open[name]) { open[name] -= 1; continue; }
384
+ if (!STRAY_END_KEPT.test(name)) parts[i] = '';
385
+ }
386
+ return parts;
387
+ }
388
+ function normalizeCellHtml(html) {
389
+ return dropStrayEndTags(html.replace(/<!--[\s\S]*?-->/g, '').split(/(<[^>]*>)/)).map((part, i) => (i % 2 ? part : part
390
+ .replace(/&(?:#([0-9]+)|#[xX]([0-9a-fA-F]+)|([A-Za-z][A-Za-z0-9]*));/g, decodeRef)
391
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'))).join('');
392
+ }
393
+ // The normalized HTML of `field` as the one cell of a one-column table, or
394
+ // null when it is not read as exactly that (then it never reads the same
395
+ // as anything).
396
+ function cellReading(parse, field) {
397
+ if (readCache.has(field)) return readCache.get(field);
398
+ let v = null;
399
+ try {
400
+ const html = parse('| h |\n| - |\n| ' + field + ' |');
401
+ if (typeof html === 'string' && html.startsWith(CELL_HEAD) && html.endsWith(CELL_TAIL)) {
402
+ const inner = html.slice(CELL_HEAD.length, html.length - CELL_TAIL.length);
403
+ if (inner.indexOf('</td>') === -1) v = normalizeCellHtml(inner);
404
+ }
405
+ } catch (e) {
406
+ v = null;
407
+ }
408
+ // FIFO, not a clear: a table larger than the cap read itself cold on every
409
+ // commit when the whole cache was dropped (final review M1).
410
+ if (readCache.size >= READ_CACHE_MAX) readCache.delete(readCache.keys().next().value);
411
+ readCache.set(field, v);
412
+ return v;
413
+ }
414
+ const READ_CACHE_MAX = 100000;
415
+ // One string per cell such that readsSame(a, b) === (readingKey(a) ===
416
+ // readingKey(b)): a cell with a reading is keyed by it, one without by its
417
+ // bytes, so it only ever pairs with itself. pairBodyRows() hashes rows on it.
418
+ function readingKey(parse, x) {
419
+ if (!parse) return 'B' + x;
420
+ const r = cellReading(parse, x);
421
+ return r === null ? 'B' + x : 'R' + r;
422
+ }
423
+ function readsSame(parse, a, b) {
424
+ if (a === b) return true;
425
+ const ra = cellReading(parse, a);
426
+ return ra !== null && ra === cellReading(parse, b);
427
+ }
428
+ // '~' (sub), '^' (sup) or '$' (KaTeX) after an even backslash run (R5).
429
+ const EXTENSION_CHAR = /(?:^|[^\\])(?:\\\\)*[~^$]/;
430
+
431
+ // The original table's rows for reuse: { header, body } where `header` is
432
+ // { fields, line } and `body` holds one { fields, line } per body row, or
433
+ // null for a line splitFields() cannot read (it is then never paired).
434
+ // `line` is the source line trimmed. Unlike columnWidthsOf(), a ragged row
435
+ // does not void the table: reuse is decided per cell. null when there is
436
+ // no original or its header line is not a row.
437
+ //
438
+ // v3.17.9 D7: a body line with no '|' at all is still a row to marked (GFM:
439
+ // any non-blank line continues the table), e.g. a stray `</content>` right
440
+ // under a table. marked's splitCells() reads it as the whole line in the
441
+ // first cell and the rest empty, and so does this (`lazy: true`). Before
442
+ // this it was never paired, and an edit anywhere in the table rewrote it
443
+ // as `| | |`: the browser drops a stray end tag, so its cells are empty
444
+ // in the DOM. A line holding a '|' but not starting with one stays
445
+ // unreadable here, as before.
446
+ function originalRowsOf(originalMd) {
447
+ if (typeof originalMd !== 'string' || originalMd === '') return null;
448
+ const rows = originalMd.split('\n').filter((l) => l.trim() !== '');
449
+ if (rows.length < 2) return null;
450
+ const row = (l) => { const f = splitFields(l); return f ? { fields: f, line: l.trim() } : null; };
451
+ const header = row(rows[0]);
452
+ if (!header) return null;
453
+ const lazy = (l) => {
454
+ if (l.indexOf('|') !== -1) return null;
455
+ const fields = header.fields.map(() => '');
456
+ fields[0] = l.trim();
457
+ return { fields, line: l.trim(), lazy: true };
458
+ };
459
+ return { header, body: rows.slice(2).map((l) => row(l) || lazy(l)) };
460
+ }
461
+
462
+ // Pairs each current body row with an original body row (an index into
463
+ // `origBody`, or -1). A row "matches" when every mapped cell reads the
464
+ // same. (1) a forward greedy scan pairs matching rows in order — the
465
+ // anchors; (2) a row left over that matches some unused original anywhere
466
+ // is paired with it (a moved row); (3) the rows still left between two
467
+ // anchors are paired in order with the unused originals between the same
468
+ // anchors — that is an edited row, whose other cells are then reused one
469
+ // by one. A wrong pair only costs preserved bytes: every reuse is gated by
470
+ // readsSame() again, cell by cell.
471
+ // Linear in rows (final review M1: the scans were quadratic, 3000 rows
472
+ // that all differ took 3 s): each row is hashed on its mapped cells'
473
+ // readingKey(), so "matches" is one Map lookup.
474
+ // A lazy original (v3.17.9 D7) has no key through the column map: its
475
+ // cells are positions, not named columns, so it is matched by position
476
+ // (see `pos` below), in the file's order like every other row.
477
+ function pairBodyRows(curRows, origBody, colMap, keyOf) {
478
+ const mapped = [];
479
+ colMap.forEach((k, i) => { if (k !== undefined) mapped.push(i); });
480
+ const pair = new Array(curRows.length).fill(-1);
481
+ const SEP = '\u0000';
482
+ const origKey = origBody.map((o) => {
483
+ if (!o || o.lazy || mapped.length === 0) return null;
484
+ const parts = [];
485
+ for (let x = 0; x < mapped.length; x++) {
486
+ const f = o.fields[colMap[mapped[x]]];
487
+ if (f === undefined) return null;
488
+ parts.push(keyOf(f.trim()));
489
+ }
490
+ return parts.join(SEP);
491
+ });
492
+ const curKey = curRows.map((row) => mapped.map((i) => keyOf(row[i])).join(SEP));
493
+ // key -> { list: ascending original indices, p1: step-1 cursor, p2: step-2 cursor }
494
+ const byKey = new Map();
495
+ origKey.forEach((k, q) => {
496
+ if (k === null) return;
497
+ let e = byKey.get(k);
498
+ if (!e) { e = { list: [], p1: 0, p2: 0 }; byKey.set(k, e); }
499
+ e.list.push(q);
500
+ });
501
+ const used = new Array(origBody.length).fill(false);
502
+ const anchor = new Array(curRows.length).fill(false);
503
+ // A table with a lazy original (v3.17.9 D7) is first matched position by
504
+ // position over every cell: `pos` holds each original's positional key,
505
+ // lazy or not, and a row takes the first unused original at or after j
506
+ // whose every cell reads the same, before any match through the column
507
+ // map. Through the map a renamed header leaves few cells (or none) to
508
+ // match on, so an ordinary original whose mapped cells happen to match
509
+ // (all empty, say) could take the row a lazy line is in. Kept in the
510
+ // file's order (re-review round 3 C1): paired before everything else, a
511
+ // lazy line took the first row reading like it, and an ordinary row that
512
+ // read the same as a lazy line below it swapped lines with it on a no-op.
513
+ let pos = null;
514
+ let curPos = null;
515
+ const lazyAt = origBody.findIndex((o) => o && o.lazy);
516
+ if (lazyAt !== -1) {
517
+ const n = origBody[lazyAt].fields.length;
518
+ const posKey = (get) => Array.from({ length: n }, (_, i) => keyOf(get(i))).join(SEP);
519
+ pos = new Map();
520
+ origBody.forEach((o, q) => {
521
+ if (!o) return;
522
+ const k = posKey((i) => (o.fields[i] === undefined ? '' : o.fields[i].trim()));
523
+ let e = pos.get(k);
524
+ if (!e) { e = { list: [], p1: 0, p2: 0 }; pos.set(k, e); }
525
+ e.list.push(q);
526
+ });
527
+ curPos = curRows.map((row) => posKey((i) => (row[i] === undefined ? '' : row[i])));
528
+ }
529
+ // (1) forward greedy: the first unused match at or after j. Every original
530
+ // used in this step lies before j, so a cursor that skips indices < j
531
+ // finds it, and it only moves forward.
532
+ const firstFrom = (e) => {
533
+ if (!e) return -1;
534
+ while (e.p1 < e.list.length && e.list[e.p1] < j) e.p1++;
535
+ return e.p1 < e.list.length ? e.p1 : -1;
536
+ };
537
+ let j = 0;
538
+ for (let c = 0; c < curRows.length; c++) {
539
+ let e = pos ? pos.get(curPos[c]) : undefined;
540
+ let at = firstFrom(e);
541
+ if (at === -1) { e = byKey.get(curKey[c]); at = firstFrom(e); }
542
+ if (at === -1) continue;
543
+ const q = e.list[at];
544
+ e.p1 = at + 1;
545
+ pair[c] = q; used[q] = true; anchor[c] = true; j = q + 1;
546
+ }
547
+ // (2) a leftover row that matches an unused original anywhere (moved).
548
+ const firstUnused = (e) => {
549
+ if (!e) return -1;
550
+ while (e.p2 < e.list.length && used[e.list[e.p2]]) e.p2++;
551
+ return e.p2 < e.list.length ? e.list[e.p2++] : -1;
552
+ };
553
+ for (let c = 0; c < curRows.length; c++) {
554
+ if (pair[c] !== -1) continue;
555
+ let q = pos ? firstUnused(pos.get(curPos[c])) : -1;
556
+ if (q === -1) q = firstUnused(byKey.get(curKey[c]));
557
+ if (q !== -1) { pair[c] = q; used[q] = true; }
558
+ }
559
+ // (3) the rows still left between two anchors take the unused originals
560
+ // between the same anchors, in order.
561
+ const nextAnchorQ = new Array(curRows.length + 1);
562
+ nextAnchorQ[curRows.length] = origBody.length;
563
+ for (let c = curRows.length - 1; c >= 0; c--) nextAnchorQ[c] = anchor[c] ? pair[c] : nextAnchorQ[c + 1];
564
+ let q = 0;
565
+ for (let c = 0; c < curRows.length; c++) {
566
+ if (anchor[c]) { q = pair[c] + 1; continue; }
567
+ if (pair[c] !== -1) continue;
568
+ const nextQ = nextAnchorQ[c + 1];
569
+ while (q < nextQ && (used[q] || !origBody[q])) q++;
570
+ if (q < nextQ) { pair[c] = q; used[q] = true; q++; }
571
+ }
572
+ return pair;
573
+ }
574
+
575
+ // The contents a row is emitted with: each cell's original field where it
576
+ // may be reused, else its canonical content. `whole` is true when every
577
+ // cell was reused and the columns are the original's, in order — the row
578
+ // may then be the original line itself (decided at emission, once widths
579
+ // are known).
580
+ function reuseRow(parse, canon, cellBad, orig, colMap, nOrigCols) {
581
+ if (!orig) return { contents: canon, whole: false };
582
+ let whole = canon.length === orig.fields.length && canon.length === nOrigCols;
583
+ const contents = canon.map((c, i) => {
584
+ const k = colMap[i];
585
+ if (k !== i) whole = false;
586
+ const field = k === undefined ? undefined : orig.fields[k];
587
+ if (field === undefined || cellBad[i]) { whole = false; return c; }
588
+ const f = field.trim();
589
+ if (f === c) return c;
590
+ if (!EXTENSION_CHAR.test(f) && readsSame(parse, f, c)) return f;
591
+ whole = false;
592
+ return c;
593
+ });
594
+ return { contents, whole };
595
+ }
596
+
271
597
  // ' content ' right-padded out to `width`. NEVER truncates: a cell whose
272
598
  // content grew past the original column simply widens that column, and a
273
599
  // deliberately blank hand-aligned cell (e.g. `| |` kept wide on
@@ -380,13 +706,22 @@
380
706
  // Render every cell's content up front (same order the pre-v3.0.1 code
381
707
  // walked: header first, then each body row left-to-right), so the width
382
708
  // pass below and the emission pass after it share one rendering.
383
- const headerContents = headerCells.map((c) => cellContentOf(c, unsupported));
384
- const bodyContentRows = bodyRows.map((tr) =>
385
- elementChildren(tr).filter(isCell).map((c) => cellContentOf(c, unsupported)));
709
+ // `bad` marks a cell that reported unsupported: it is never reused, so a
710
+ // reuse can never stand in for (or hide) such a cell.
711
+ const cellOf = (c) => {
712
+ const before = unsupported.length;
713
+ const md = cellContentOf(c, unsupported);
714
+ return { md, bad: unsupported.length !== before };
715
+ };
716
+ const headerCellMd = headerCells.map(cellOf);
717
+ const bodyCellMd = bodyRows.map((tr) => elementChildren(tr).filter(isCell).map(cellOf));
718
+ // The canonical contents; replaced below by the reused ones (F1).
719
+ let headerContents = headerCellMd.map((x) => x.md);
720
+ let bodyContentRows = bodyCellMd.map((row) => row.map((x) => x.md));
386
721
 
387
722
  // Read the ORIGINAL widths first, then line each CURRENT header column
388
- // up with the original column it corresponds to BY TEXT, not by raw
389
- // index. A column inserted in the MIDDLE (insertColumn() in client.js)
723
+ // up with the original column it corresponds to BY TEXT (since v3.17.8,
724
+ // by how the text reads: `same` below), not by raw index. A column inserted in the MIDDLE (insertColumn() in client.js)
390
725
  // shifts every later column's index by one without changing what that
391
726
  // column actually is — matching by index alone would hand the shifted
392
727
  // column a stale neighbour's width, and the freshly-inserted empty
@@ -411,6 +746,17 @@
411
746
  // patch release carries. See test/table-width.test.js cases 14, 15, 20,
412
747
  // 21, 23 and 24, which pin every branch of the above.
413
748
  const original = columnWidthsOf(originalMd);
749
+ // F1: with a parser, names are compared by how they READ (a header
750
+ // spelled 'a\_b' in the file is the DOM's 'a_b'); without one, by bytes,
751
+ // as before. `src` is the original's rows for reuse (null: no reuse).
752
+ const parse = getCellParser();
753
+ const same = parse ? (a, b) => readsSame(parse, a, b) : (a, b) => a === b;
754
+ const src = parse ? originalRowsOf(originalMd) : null;
755
+ const origNames = original ? original.headerFields
756
+ : (src ? src.header.fields.map((f) => f.trim()) : null);
757
+ // colIdx[i]: the original column current column i was matched to.
758
+ const colIdx = new Array(headerContents.length);
759
+ let moved = false;
414
760
  let widths = null;
415
761
  // Set for a column that falls off the END of the original header list
416
762
  // (every original column has already been matched by the time this one
@@ -445,8 +791,7 @@
445
791
  // separator did not actually change (F1, final review) — see the
446
792
  // verbatim gate at the emission site.
447
793
  const matchedSepFields = new Array(headerContents.length);
448
- if (original) {
449
- const matched = new Array(headerContents.length);
794
+ if (origNames) {
450
795
  // v3.0.2: a FORWARD SCAN, not the pre-v3.0.2 lockstep compare. The
451
796
  // lockstep version left `j` parked on the first original column that
452
797
  // did not match, so it could never recover from one mismatch — and
@@ -460,15 +805,14 @@
460
805
  for (let i = 0; i < headerContents.length; i++) {
461
806
  const name = headerContents[i].trim();
462
807
  let k = -1;
463
- for (let q = j; q < original.headerFields.length; q++) {
464
- if (original.headerFields[q].trim() === name) { k = q; break; }
808
+ for (let q = j; q < origNames.length; q++) {
809
+ if (same(origNames[q].trim(), name)) { k = q; break; }
465
810
  }
466
811
  if (k >= 0) {
467
- matched[i] = original.widths[k];
468
- matchedSepFields[i] = original.sepFields[k];
812
+ colIdx[i] = k;
813
+ if (original) matchedSepFields[i] = original.sepFields[k];
469
814
  j = k + 1;
470
815
  } else {
471
- matched[i] = undefined;
472
816
  // The EMPTY name is exempt from the reorder test below, and the
473
817
  // exemption is load-bearing, not defensive: insertColumn()
474
818
  // (lib/editor/client.js:6448-6465) creates a th (or td) with no
@@ -479,7 +823,7 @@
479
823
  // whole-table refusal — a straight regression against v3.0.1 on
480
824
  // the ordinary insert-a-column-twice flow.
481
825
  if (name !== '') unmatchedNames.push(name);
482
- if (j >= original.headerFields.length) sepFallbackNatural[i] = true;
826
+ if (original && j >= origNames.length) sepFallbackNatural[i] = true;
483
827
  }
484
828
  }
485
829
  // A column that could not find itself in what remains of the original,
@@ -489,10 +833,56 @@
489
833
  // so it still discards the width source for the WHOLE table. A name
490
834
  // that is absent from the original entirely is an insert or a rename:
491
835
  // nothing stale to carry, so its neighbours keep their widths.
492
- const moved = unmatchedNames.some((n) =>
493
- original.headerFields.some((f) => f.trim() === n));
836
+ moved = unmatchedNames.some((n) =>
837
+ origNames.some((f) => same(f.trim(), n)));
838
+ // F1: the cells of a column the scan did not match (a moved one) are
839
+ // still reused when exactly one unmatched original column has its name.
840
+ // This feeds reuse only; the width refusal above stands.
841
+ if (src) {
842
+ const taken = new Set(colIdx.filter((k) => k !== undefined));
843
+ for (let i = 0; i < headerContents.length; i++) {
844
+ const name = headerContents[i].trim();
845
+ if (colIdx[i] !== undefined || name === '') continue;
846
+ const hits = [];
847
+ for (let q = 0; q < origNames.length; q++) {
848
+ if (!taken.has(q) && same(origNames[q].trim(), name)) hits.push(q);
849
+ }
850
+ if (hits.length === 1) { colIdx[i] = hits[0]; taken.add(hits[0]); }
851
+ }
852
+ }
853
+ }
854
+
855
+ // F1: reuse each untouched cell's original bytes (see reuseRow()), before
856
+ // the widths are settled, so the reused contents are what they measure.
857
+ let headerWhole = false;
858
+ const bodyWhole = bodyContentRows.map(() => false);
859
+ const bodyOrig = bodyContentRows.map(() => null);
860
+ if (src) {
861
+ const nOrigCols = src.header.fields.length;
862
+ const h = reuseRow(parse, headerContents, headerCellMd.map((x) => x.bad), src.header, colIdx, nOrigCols);
863
+ headerContents = h.contents;
864
+ headerWhole = h.whole;
865
+ const pair = pairBodyRows(bodyContentRows, src.body, colIdx, (x) => readingKey(parse, x));
866
+ // A lazy line's cells are positions in marked's split, not columns with
867
+ // a name, and its text is often in the file only (D7): a renamed header
868
+ // must not unmatch it. So it is mapped by position; every cell is still
869
+ // gated by readsSame(), so an edited cell is never overwritten.
870
+ const byPosition = headerContents.map((_, i) => (i < nOrigCols ? i : undefined));
871
+ bodyContentRows = bodyContentRows.map((canon, r) => {
872
+ const orig = pair[r] === -1 ? null : src.body[pair[r]];
873
+ const map = orig && orig.lazy ? byPosition : colIdx;
874
+ const b = reuseRow(parse, canon, bodyCellMd[r].map((x) => x.bad), orig, map, nOrigCols);
875
+ bodyWhole[r] = b.whole;
876
+ bodyOrig[r] = orig;
877
+ return b.contents;
878
+ });
879
+ }
880
+
881
+ if (original) {
494
882
  if (!moved) {
495
- widths = matched;
883
+ // Not moved means the name rescue above found nothing (an unmatched
884
+ // name is absent from the original), so colIdx is the scan's alone.
885
+ widths = colIdx.map((k) => (k === undefined ? undefined : original.widths[k]));
496
886
  // A matched column is then widened, for every row of that column
497
887
  // at once (header + separator + every body row), to whatever the
498
888
  // grown content now needs — the padding floor rises but never
@@ -502,7 +892,9 @@
502
892
  // comparing a number against `undefined` is always false, so this
503
893
  // loop leaves it alone and padField()'s `|| 0` fallback below
504
894
  // gives it independent per-row minimal form instead.
505
- const allRows = [headerContents].concat(bodyContentRows);
895
+ // A lazy line that is written as itself (D7) is no column's content.
896
+ const allRows = [headerContents].concat(bodyContentRows.filter((_, r) =>
897
+ !(bodyWhole[r] && bodyOrig[r] && bodyOrig[r].lazy)));
506
898
  for (let i = 0; i < widths.length; i++) {
507
899
  let needed = widths[i];
508
900
  allRows.forEach((row) => {
@@ -515,8 +907,15 @@
515
907
  }
516
908
  }
517
909
 
910
+ // F1: a row whose cells were all reused, in the original columns, is the
911
+ // original line itself (trimmed), padding and all, unless a column it
912
+ // holds was widened past that line's own field. A lazy line (D7) has no
913
+ // fields to pad, so it is always its own line.
914
+ const asWritten = (whole, orig) => whole && (orig.lazy || orig.fields.every((f, i) =>
915
+ !widths || widths[i] === undefined || widths[i] <= f.length));
518
916
  const lines = [];
519
- lines.push(serializeRow(headerContents, widths));
917
+ lines.push(asWritten(headerWhole, src && src.header) ? src.header.line
918
+ : serializeRow(headerContents, widths));
520
919
  lines.push('|' + headerCells.map((c, i) => {
521
920
  // Preference order for a column's separator width:
522
921
  // 1. widths[i] — this column is a full hand-aligned width source
@@ -592,10 +991,11 @@
592
991
  return sepCellFor(align, w);
593
992
  }).join('|') + '|');
594
993
 
595
- bodyContentRows.forEach((contents) => lines.push(serializeRow(contents, widths)));
994
+ bodyContentRows.forEach((contents, r) => lines.push(asWritten(bodyWhole[r], bodyOrig[r])
995
+ ? bodyOrig[r].line : serializeRow(contents, widths)));
596
996
 
597
997
  return { md: lines.join('\n'), unsupported };
598
998
  }
599
999
 
600
- return { serializeTable };
1000
+ return { serializeTable, setCellParser };
601
1001
  });
package/lib/md2doc.js CHANGED
@@ -233,6 +233,117 @@ try {
233
233
  ],
234
234
  });
235
235
 
236
+ // v3.17.9 D8: `[[ref-id, §sub]]` → a clickable citation, as an inline
237
+ // extension for the same reason as sub / sup above. It was a raw-text
238
+ // pre-pass over the whole file (mdPre), which rewrote it inside code spans,
239
+ // fenced code, HTML comments and tag attributes too: the code then showed
240
+ // the anchor's HTML as text, and the editor wrote that HTML back into the
241
+ // file on the first edit of the block. `text` is the anchor's visible text,
242
+ // escaped as the old html + text tokens were, so heading ids and the
243
+ // search index read the same strings as before. The body is literal: no
244
+ // bold / code / escapes inside a citation any more (the pre-pass left them
245
+ // to marked), which is what the editor writes back (isCitationEligible).
246
+ const escCiteAttr = (s) => String(s)
247
+ .replace(/&/g, '&amp;')
248
+ .replace(/</g, '&lt;')
249
+ .replace(/>/g, '&gt;')
250
+ .replace(/"/g, '&quot;');
251
+ marked.use({
252
+ extensions: [{
253
+ name: 'citation',
254
+ level: 'inline',
255
+ // No start(): marked's inline text rule already stops before every
256
+ // `[`, and this tokenizer is tried there. A start() that searched the
257
+ // rest of the paragraph for `[[` ran once per text token (re-review
258
+ // round 3 C3: quadratic, a 32000-character paragraph of `[a] ` took 0.2 s).
259
+ tokenizer(src) {
260
+ const m = /^\[\[([^\]\n]+)\]\]/.exec(src);
261
+ if (!m) return undefined;
262
+ const body = m[1].trim();
263
+ return { type: 'citation', raw: m[0], body, slug: body.split(',', 1)[0].trim(),
264
+ text: '[' + escCiteAttr(body) + ']' };
265
+ },
266
+ renderer(token) { return `<a href="#${escCiteAttr(token.slug)}">${token.text}</a>`; },
267
+ }],
268
+ });
269
+ // A citation inside a link label or an image alt: marked's label allows one
270
+ // nested bracket level and `[[...]]` is two, so `[see [[ref]]](#sec)` fell
271
+ // apart into text (the old pre-pass had removed one level first). Each
272
+ // citation in the label is masked as `\u0000[body]\u0000` (same length, one
273
+ // level), marked's own link tokenizer reads the masked text, and the link's
274
+ // raw / label come back from the real source: a link's label is re-lexed, so
275
+ // its citations render; an image's alt is the citations' visible text.
276
+ // A `[[` after an odd backslash run is escaped text, not a citation (the
277
+ // inline lexer reads `\[` as a literal bracket), so it is left unmasked.
278
+ // The mask is the regex /(\\*)\[\[([^\]\n]+)\]\]/g scanned by hand, in
279
+ // one pass (re-review round 3 C3): the tokenizer runs at every `[` of a
280
+ // paragraph, and the regex let each unclosed `[[` scan to the end of its
281
+ // line, so one 8000-character line of `[[a ` took 10 s. `stop` is the
282
+ // first `]` or newline at or after a body's start; it only moves forward.
283
+ const maskCitations = (s) => {
284
+ let out = '';
285
+ let from = 0;
286
+ let stop = -1;
287
+ for (let p = s.indexOf('[['); p !== -1; p = s.indexOf('[[', p)) {
288
+ if (stop < p + 2) {
289
+ stop = p + 2;
290
+ while (stop < s.length && s[stop] !== ']' && s[stop] !== '\n') stop++;
291
+ }
292
+ if (stop === p + 2 || s[stop] !== ']' || s[stop + 1] !== ']') { p++; continue; }
293
+ let bs = 0;
294
+ while (p - bs - 1 >= from && s[p - bs - 1] === '\\') bs++;
295
+ out += bs % 2 ? s.slice(from, stop + 2) : s.slice(from, p) + '\u0000[' + s.slice(p + 2, stop) + ']\u0000';
296
+ from = p = stop + 2;
297
+ }
298
+ return from ? out + s.slice(from) : s;
299
+ };
300
+ // Where the label of a link (or alt of an image) starting at `src` ends:
301
+ // the index of its closing `]`, or -1. marked's label allows escapes, code
302
+ // spans (at its own level only: inside a nested bracket a backtick is a
303
+ // plain character, re-review round 4) and one nested bracket level, and a
304
+ // masked citation is one level,
305
+ // so deeper than three raw levels is no label of marked's. Bounded so that
306
+ // a call costs about the distance to the next brackets: the tokenizer runs
307
+ // at the start of every token of a paragraph (re-review round 3 C3).
308
+ const labelEnd = (src) => {
309
+ const start = src[0] === '!' ? 1 : 0;
310
+ if (src[start] !== '[') return -1;
311
+ let depth = 0;
312
+ for (let i = start; i < src.length; i++) {
313
+ const ch = src[i];
314
+ if (ch === '\\') i++;
315
+ else if (ch === '`' && depth === 1) { const k = src.indexOf('`', i + 1); if (k !== -1) i = k; }
316
+ else if (ch === '[') { if (++depth > 3) return -1; }
317
+ else if (ch === ']') { if (--depth === 0) return i; }
318
+ }
319
+ return -1;
320
+ };
321
+ const defaultLink = marked.Tokenizer.prototype.link;
322
+ marked.use({ tokenizer: { link(src) {
323
+ // only an inline link or image whose label holds a `[[` is read here; a
324
+ // citation in its href or title stays literal (unmasked), as it is in a
325
+ // link whose label cites nothing (re-review round 3 C4)
326
+ const e = labelEnd(src);
327
+ if (e === -1 || src[e + 1] !== '(' || src.lastIndexOf('[[', e) === -1) return false;
328
+ const masked = maskCitations(src.slice(0, e + 1)) + src.slice(e + 1);
329
+ const tok = defaultLink.call(this, masked);
330
+ if (!tok || tok.raw.indexOf('\u0000') === -1) return false;
331
+ // a mask byte past the label means the scan disagreed with marked's
332
+ // label; leave the link to marked rather than leak the byte
333
+ if ((tok.href && tok.href.indexOf('\u0000') !== -1) || (tok.title && tok.title.indexOf('\u0000') !== -1)) return false;
334
+ tok.raw = src.slice(0, tok.raw.length);
335
+ if (tok.type === 'image') { tok.text = tok.text.replace(/\u0000/g, ''); return tok; }
336
+ // The label is lexed with its escapes in place (marked unescapes `\[`
337
+ // first, which would turn an escaped `\[[x]]` back into a citation).
338
+ const cap = this.rules.inline.link.exec(masked);
339
+ const label = src.substr(1, cap[1].length);
340
+ this.lexer.state.inLink = true;
341
+ tok.text = label.replace(/\\([\[\]])/g, '$1');
342
+ tok.tokens = this.lexer.inlineTokens(label);
343
+ this.lexer.state.inLink = false;
344
+ return tok;
345
+ } } });
346
+
236
347
  // $$…$$ (display) and $…$ (inline) math via KaTeX. marked tokenizes code
237
348
  // spans/fences first, so $ inside code stays literal; the extension's
238
349
  // default no-space-adjacency rules keep prose currency ($5 to $10) unrendered.
@@ -887,6 +998,14 @@ ${itemsHtml}
887
998
  if (item.type === 'codespan') {
888
999
  return unescapeHtml(item.text || '');
889
1000
  }
1001
+ // v3.17.9 D8: what the old [[...]] text pre-pass left here, an html
1002
+ // token's markup plus the text, so classifyColumns() (it measures the
1003
+ // markup: a citation column often reads as prose) and heading ids
1004
+ // (stripHtmlTags() removes it) see the strings they always did. A
1005
+ // patch release does not re-lay-out every table that cites.
1006
+ if (item.type === 'citation') {
1007
+ return '<a href="#' + item.slug + '">[' + item.body + ']</a>';
1008
+ }
890
1009
  if (item.tokens) {
891
1010
  return flattenTokenText(item.tokens);
892
1011
  }
@@ -1361,35 +1480,26 @@ ${itemsHtml}
1361
1480
  // concurrently, unlike the one-shot CLI).
1362
1481
  marked.setOptions({ gfm: true, breaks: false, renderer });
1363
1482
 
1364
- // Pre-process the remaining non-standard inline syntax before marked parses
1365
- const escAttr = (s) => String(s)
1366
- .replace(/&/g, '&amp;')
1367
- .replace(/</g, '&lt;')
1368
- .replace(/>/g, '&gt;')
1369
- .replace(/"/g, '&quot;');
1370
- const mdPre = md
1371
- .replace(/\[\[([^\]\n]+)\]\]/g, (_, inner) => { // [[ref-id, §sub]] → clickable citation
1372
- const body = inner.trim();
1373
- const slug = body.split(',', 1)[0].trim();
1374
- return `<a href="#${escAttr(slug)}">[${escAttr(body)}]</a>`;
1375
- });
1483
+ // `[[ref-id, §sub]]` citations are the `citation` inline extension
1484
+ // installed at require time (v3.17.9 D8); there is no text pre-pass, so
1485
+ // marked reads the file's own bytes and the block map's lines are the
1486
+ // file's lines.
1376
1487
 
1377
1488
  // A $$ block's TeX reaches the index through the blockKatex hook at the
1378
1489
  // top of the file, set only while this render's synchronous parse runs.
1379
1490
  if (opts.editMode) {
1380
- // Ranges are computed on mdPre. The [[...]] preprocessing above only
1381
- // replaces text within single lines and never adds/removes newlines,
1382
- // so line ranges stay valid against the ORIGINAL md the client holds
1383
- // (guarded by the '[[...]] must not shift lines' test).
1491
+ // Ranges are computed on the file's own text (no pre-pass since
1492
+ // v3.17.9), so they are the lines the client holds (still guarded by
1493
+ // the '[[...]] must not shift lines' test).
1384
1494
  const { buildBlockMap } = require('./editor/blockmap.js');
1385
- blocks = buildBlockMap(mdPre).blocks;
1495
+ blocks = buildBlockMap(md).blocks;
1386
1496
  // marked.lexer() inline-lexes as part of block lexing (verified against
1387
1497
  // marked 14.1.4: a paragraph token's `.tokens` already holds resolved
1388
1498
  // inline tokens such as `strong`/`link`, not raw text) — so
1389
1499
  // marked.parser([t]) on one already-lexed top-level token reproduces
1390
1500
  // exactly what the whole-document parse would emit for that token,
1391
1501
  // using the same renderer/options set above.
1392
- const tokens = marked.lexer(mdPre);
1502
+ const tokens = marked.lexer(md);
1393
1503
  parts = [];
1394
1504
  // `v` is the block cursor; `prevLi`/`types` are the §3.8 run state the
1395
1505
  // flat list renderer above threads through its own recursion (see
@@ -1465,7 +1575,7 @@ ${itemsHtml}
1465
1575
  } else {
1466
1576
  try {
1467
1577
  blockMathText = appendSectionText;
1468
- bodyHtml = marked.parse(mdPre);
1578
+ bodyHtml = marked.parse(md);
1469
1579
  } finally {
1470
1580
  blockMathText = null;
1471
1581
  }
@@ -6790,7 +6900,7 @@ ${usesDrawioMultiPage ? ` // v3.4.0 batch2 Task 6: wired through a NAMED, reusa
6790
6900
  // v3.4.0 §4: same short-circuit story for drawio. editMode already baked
6791
6901
  // every part above, so both calls below are no-ops there (no browser is
6792
6902
  // opened) — this is only load-bearing for the non-editMode
6793
- // `bodyHtml = marked.parse(mdPre)` path, which never went through the
6903
+ // `bodyHtml = marked.parse(md)` path, which never went through the
6794
6904
  // per-part loop. `html` and `bodyHtml` share the same placeholders (html
6795
6905
  // embeds bodyHtml), so both bakes share one lazily-opened browser instead
6796
6906
  // of paying for two.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "3.17.7",
3
+ "version": "3.17.9",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, Graphviz, and draw.io support",
5
5
  "keywords": [
6
6
  "markdown",