@helping-ai-workflow/md2doc 3.17.5 → 3.17.7

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.
@@ -126,13 +126,19 @@
126
126
  // (verified round-trip-identical either way), so we always pad once a
127
127
  // fence is needed — simpler, and preserves the original single-backtick
128
128
  // test's exact padded form.
129
- function serializeCode(node) {
129
+ //
130
+ // `opts.codePipe` (v3.17.6, table cells only): what a '|' in the code text
131
+ // is written as. table-md.js passes a placeholder here and turns it into
132
+ // `\|` after its own whole-cell '|' pass, which must not see it. The
133
+ // fence is measured on the real text; the placeholder holds no backtick.
134
+ function serializeCode(node, opts) {
130
135
  const raw = node.textContent;
131
136
  const runs = raw.match(/`+/g);
132
137
  const longestRun = runs ? Math.max.apply(null, runs.map((r) => r.length)) : 0;
133
- if (longestRun === 0) return '`' + raw + '`';
138
+ const body = opts && opts.codePipe !== undefined ? raw.split('|').join(opts.codePipe) : raw;
139
+ if (longestRun === 0) return '`' + body + '`';
134
140
  const fence = new Array(longestRun + 2).join('`');
135
- return fence + ' ' + raw + ' ' + fence;
141
+ return fence + ' ' + body + ' ' + fence;
136
142
  }
137
143
 
138
144
  // IMPORTANT 3 (degrade-never-lose): the citation form only round-trips
@@ -148,7 +154,14 @@
148
154
  return inner.indexOf(']') === -1;
149
155
  }
150
156
 
151
- function serializeAnchor(node, unsupported) {
157
+ // The test serializeAnchor() uses to write `[[body]]` (v3.17.7: shared
158
+ // with serializeInline()'s read-back check).
159
+ function isCitationNode(node) {
160
+ const href = typeof node.getAttribute === 'function' ? node.getAttribute('href') : null;
161
+ const text = node.textContent;
162
+ return !!href && href.charAt(0) === '#' && /^\[.*\]$/.test(text) && isCitationEligible(node, text);
163
+ }
164
+ function serializeAnchor(node, unsupported, opts) {
152
165
  const href = node.getAttribute('href');
153
166
  // A raw HTML anchor (`<a id="ref-x"></a>`, NO href attribute) is not a
154
167
  // markdown link: emitting '[label]()' for it erased the anchor on the
@@ -167,7 +180,7 @@
167
180
  unsupported.push('A');
168
181
  return '';
169
182
  }
170
- const label = walkChildren(node.childNodes, unsupported).md;
183
+ const label = walkChildren(node.childNodes, unsupported, opts).md;
171
184
  return '[' + label + '](' + (href || '') + ')';
172
185
  }
173
186
 
@@ -385,10 +398,16 @@
385
398
  if (typeof node.getAttribute !== 'function') return false;
386
399
  return (' ' + (node.getAttribute('class') || '') + ' ').indexOf(' search-hit ') !== -1;
387
400
  }
388
- function walkChildren(nodes, unsupported) {
401
+ // `ctx` (v3.17.7) says how a STRONG / EM nested inside another STRONG / EM
402
+ // is spelled; see serializeInline(). Absent means the plain `*` spelling.
403
+ // `touching` (v3.17.7) is the mark tag that ends right before these
404
+ // nodes, for a transparent wrapper whose first child may need the
405
+ // same-tag guard against it.
406
+ function walkChildren(nodes, unsupported, opts, ctx, touching) {
407
+ const c = ctx || PLAIN;
389
408
  let out = '';
390
409
  let firstSegment = true;
391
- let lastMarkTag = null;
410
+ let lastMarkTag = touching || null;
392
411
  for (let i = 0; i < nodes.length; i++) {
393
412
  const node = nodes[i];
394
413
  if (node.nodeType === 3) {
@@ -403,7 +422,7 @@
403
422
  if (name === 'DIV') {
404
423
  let added = '';
405
424
  if (!firstSegment) added += '<br>';
406
- const inner = walkChildren(node.childNodes, unsupported);
425
+ const inner = walkChildren(node.childNodes, unsupported, opts, c);
407
426
  added += inner.md;
408
427
  out = appendPiece(out, added);
409
428
  if (added.length) lastMarkTag = inner.md.length ? inner.lastMarkTag : null;
@@ -413,7 +432,7 @@
413
432
  if (name === 'MARK' && markIsSearchHit(node)) {
414
433
  // v3.14.0 T9: the reader's search paints its hits as this element and
415
434
  // it is transient, so it serializes as what it wraps.
416
- const inner = walkChildren(node.childNodes, unsupported);
435
+ const inner = walkChildren(node.childNodes, unsupported, opts, c, lastMarkTag);
417
436
  out = appendPiece(out, inner.md);
418
437
  if (inner.md.length) lastMarkTag = inner.lastMarkTag;
419
438
  firstSegment = false;
@@ -423,7 +442,7 @@
423
442
  if (spanHasAttributes(node)) {
424
443
  unsupported.push(name);
425
444
  } else {
426
- const inner = walkChildren(node.childNodes, unsupported);
445
+ const inner = walkChildren(node.childNodes, unsupported, opts, c, lastMarkTag);
427
446
  out = appendPiece(out, inner.md);
428
447
  if (inner.md.length) lastMarkTag = inner.lastMarkTag;
429
448
  }
@@ -432,12 +451,12 @@
432
451
  }
433
452
  if (name === 'STRONG' || name === 'B' || name === 'EM' || name === 'I') {
434
453
  const tag = (name === 'STRONG' || name === 'B') ? 'STRONG' : 'EM';
435
- const delim = tag === 'STRONG' ? '**' : '*';
436
- const e = splitEdges(walkChildren(node.childNodes, unsupported).md);
454
+ const e = splitEdges(walkChildren(node.childNodes, unsupported, opts, c.inner).md);
437
455
  if (e.core) {
438
456
  // a hoisted leading space already separates two same-tag runs
439
457
  const guard = !e.lead && lastMarkTag === tag ? '<!-- -->' : '';
440
- out = appendPiece(out, e.lead + guard + delim + e.core + delim + e.trail);
458
+ const delim = c.delims[tag];
459
+ out = appendPiece(out, e.lead + guard + c.before + delim + e.core + delim + c.after + e.trail);
441
460
  lastMarkTag = e.trail ? null : tag;
442
461
  } else if (e.lead || e.trail) {
443
462
  out = appendPiece(out, e.lead + e.trail);
@@ -452,9 +471,15 @@
452
471
  // accepted on input (some contenteditable/paste paths produce it)
453
472
  // but the toolbar/serializer always speak DEL, matching what marked
454
473
  // itself emits.
455
- const e = splitEdges(walkChildren(node.childNodes, unsupported).md);
456
- out = appendPiece(out, e.core ? e.lead + '~~' + e.core + '~~' + e.trail : e.lead + e.trail);
457
- if (e.core || e.lead || e.trail) lastMarkTag = null;
474
+ //
475
+ // v3.17.7 N2: two strikethroughs side by side get the same guard as
476
+ // two bolds. `~~a~~~~b~~` reads as ONE del holding `a~~~~b` (measured
477
+ // on 71fedaa: strike `two` right after `~~one~~` wrote exactly that).
478
+ const e = splitEdges(walkChildren(node.childNodes, unsupported, opts, c).md);
479
+ const guard = e.core && !e.lead && lastMarkTag === 'DEL' ? '<!-- -->' : '';
480
+ out = appendPiece(out, e.core ? e.lead + guard + '~~' + e.core + '~~' + e.trail : e.lead + e.trail);
481
+ if (e.core) lastMarkTag = e.trail ? null : 'DEL';
482
+ else if (e.lead || e.trail) lastMarkTag = null;
458
483
  firstSegment = false;
459
484
  continue;
460
485
  }
@@ -469,19 +494,23 @@
469
494
  // backtick, literal `<u>` typed as plain text is already escaped by
470
495
  // the `<` -> `&lt;` rule above, so it can never collide with a real
471
496
  // toolbar-made underline.
472
- out = appendPiece(out, '<u>' + walkChildren(node.childNodes, unsupported).md + '</u>');
497
+ out = appendPiece(out, '<u>' + walkChildren(node.childNodes, unsupported, opts, c).md + '</u>');
473
498
  lastMarkTag = null;
474
499
  firstSegment = false;
475
500
  continue;
476
501
  }
477
502
  if (name === 'CODE') {
478
- out = appendPiece(out, serializeCode(node));
479
- lastMarkTag = null;
503
+ // v3.17.7 N3: two code spans side by side are one span to a reader
504
+ // (`` `one``two` `` is the code "one``two"), so they get a guard too.
505
+ // serializeCode() never writes an empty span, so this always fires
506
+ // between two real ones.
507
+ out = appendPiece(out, (lastMarkTag === 'CODE' ? '<!-- -->' : '') + serializeCode(node, opts));
508
+ lastMarkTag = 'CODE';
480
509
  firstSegment = false;
481
510
  continue;
482
511
  }
483
512
  if (name === 'A') {
484
- out = appendPiece(out, serializeAnchor(node, unsupported));
513
+ out = appendPiece(out, serializeAnchor(node, unsupported, opts));
485
514
  lastMarkTag = null;
486
515
  firstSegment = false;
487
516
  continue;
@@ -530,11 +559,227 @@
530
559
  return { md: out, lastMarkTag };
531
560
  }
532
561
 
533
- function serializeInline(rootEl) {
562
+ // ── v3.17.7 N1: check the bytes with the reader's own parser ─────────────
563
+ //
564
+ // A STRONG / EM nested inside another STRONG / EM is written `*` / `**`
565
+ // like any other, and most of the time marked reads that back. Not always:
566
+ // marked's emphasis scan (emStrong in marked 14) walks from an opening run
567
+ // to its closer counting the `*` runs in between, and a run with word
568
+ // characters on both sides is a "mid" run it may skip under the rule of 3.
569
+ // Whether a nested mark survives therefore depends on the characters
570
+ // around its runs. Measured on 71fedaa, each from a toolbar gesture:
571
+ // `*`c`**i**tal*` -> the em is lost, its stars show
572
+ // `*~~a**a**~~*` -> `~~` is invisible to that scan
573
+ // `前面「引號<!-- -->*」**粗**後面,中文*。`
574
+ // while `*a**i**tal*` and `***ital* bold**` read fine. The rules are
575
+ // marked's, so marked decides: serializeInline() writes the bytes as
576
+ // before, parses them, and compares the marks on every character with the
577
+ // DOM it was given. Only when they differ does it try other spellings for
578
+ // the nested marks, in this order, keeping the first that reads back:
579
+ // 1. `__` / `_` for every STRONG / EM inside another (those runs are
580
+ // not `*`, so the outer scan does not count them);
581
+ // 2. the same with `<!-- -->` on both sides of each such mark (a run
582
+ // next to `<` is no longer flanked by a word character), then each of
583
+ // those guards dropped again if the bytes still read back without it.
584
+ // If neither reads back the original bytes are kept: what this release
585
+ // wrote before. Bytes that already read back are never touched, so no
586
+ // file written by an earlier release changes by being opened and saved.
587
+ // A fuzz over random strong / em / del / code trees (inline-md.test.js;
588
+ // 7725 of 20000 read back wrong on 71fedaa) needed nothing beyond step 2.
589
+ //
590
+ // A repaired spelling never keeps `<!--` at the start of a line: marked
591
+ // reads a line-start `<!--` as an HTML block and ends the paragraph there
592
+ // (v3.17.5 C1), and parseInline() cannot see that. It does not have to:
593
+ // the only guards step 2 adds sit before an opening run or after a
594
+ // closing one, and after a newline the character before an opening run
595
+ // is whitespace, which already does what the guard's `>` does (no
596
+ // right-flanking, opening allowed). So a step-2 guard at a line start is
597
+ // dropped before anything is parsed, whether or not the drop pass below
598
+ // runs (re-review: with the pass skipped past 16 guards, one survived
599
+ // after a hard break and the reader cut the paragraph there). The walk
600
+ // itself never writes one (needsFlankSeparator() sees `\n` as a non-word
601
+ // character).
602
+ //
603
+ // The parser is a private `new Marked()` with gfm on and nothing else, in
604
+ // node and in the editor page alike (server.js injects marked's UMD build
605
+ // before this file). The reader's own instance carries the md2doc
606
+ // renderer, whose text hook writes into the render's search index, and
607
+ // its sub / sup / KaTeX extensions only see `~` / `^` / `$`, which this
608
+ // comparison is not about. No parser (a page without marked) means no
609
+ // check: the bytes are written as before.
610
+ const GUARD = '<!-- -->';
611
+ const STAR = { STRONG: '**', EM: '*' };
612
+ const UNDERSCORE = { STRONG: '__', EM: '_' };
613
+ // PLAIN.inner is PLAIN: the spelling every release before this one used.
614
+ const PLAIN = { delims: STAR, before: '', after: '', inner: null };
615
+ PLAIN.inner = PLAIN;
616
+ function spelling(before) {
617
+ const nested = { delims: UNDERSCORE, before, after: before, inner: null };
618
+ nested.inner = nested;
619
+ return { delims: STAR, before: '', after: '', inner: nested };
620
+ }
621
+ const NESTED_UNDERSCORE = spelling('');
622
+ // One character stands in for each step-2 guard until it is decided, so
623
+ // only those guards are ever dropped, never one the walk wrote itself. It
624
+ // must be punctuation to needsFlankSeparator(), as the `<` it stands for
625
+ // is: with a word-like stand-in the walk skips a guard the real bytes need
626
+ // (measured: `ital**<!-- -->_…` lost the `<!-- -->` before its `**`).
627
+ // U+2E3B THREE-EM DASH (\p{Pd}); a block whose text holds one skips step 2.
628
+ const PENDING = '\u2E3B';
629
+ const NESTED_GUARDED = spelling(PENDING);
630
+
631
+ let inlineParser; // undefined: not looked up yet; null: none available
632
+ function getInlineParser() {
633
+ if (inlineParser !== undefined) return inlineParser;
634
+ inlineParser = null;
635
+ try {
636
+ let lib = null;
637
+ if (typeof module === 'object' && module.exports && typeof require === 'function') lib = require('marked');
638
+ else if (typeof self !== 'undefined' && self.marked) lib = self.marked;
639
+ if (lib && typeof lib.Marked === 'function') {
640
+ const m = new lib.Marked({ gfm: true, breaks: false });
641
+ inlineParser = (s) => m.parseInline(s);
642
+ }
643
+ } catch (e) {
644
+ inlineParser = null;
645
+ }
646
+ return inlineParser;
647
+ }
648
+ // Tests swap the parser: a function, null to switch the check off, or
649
+ // nothing to go back to the lookup above.
650
+ function setInlineParser(fn) {
651
+ inlineParser = typeof fn === 'function' ? fn : (fn === null ? null : undefined);
652
+ }
653
+
654
+ // One entry per non-space character: the character and the sorted set of
655
+ // S(trong) / E(m) / D(el) around it. A code span is one entry holding its
656
+ // text without whitespace (a reader strips one space at each end, and
657
+ // spacing is not what this check is about). Whitespace is left out
658
+ // (splitEdges() moves it across a delimiter on purpose), and so are line
659
+ // breaks, link targets and <u>. A citation is written `[[body]]`, which
660
+ // this private parser (no md2doc extension) reads as that literal text,
661
+ // so its DOM side counts the outer brackets too: without them the two
662
+ // sides never agreed and any block holding a citation was never repaired
663
+ // (final review).
664
+ const MARK_LETTER = { STRONG: 'S', B: 'S', EM: 'E', I: 'E', DEL: 'D', S: 'D' };
665
+ const SPACE = /\s/;
666
+ function domMarks(nodes, marks, out) {
667
+ for (let i = 0; i < nodes.length; i++) {
668
+ const node = nodes[i];
669
+ if (node.nodeType === 3) {
670
+ for (const ch of node.textContent) if (!SPACE.test(ch)) out.push(ch + '[' + marks + ']');
671
+ continue;
672
+ }
673
+ if (node.nodeType !== 1) continue;
674
+ if (node.nodeName === 'CODE') { out.push('`' + node.textContent.replace(/\s+/g, '') + '[' + marks + ']'); continue; }
675
+ if (node.nodeName === 'A' && isCitationNode(node)) {
676
+ for (const ch of '[' + node.textContent + ']') if (!SPACE.test(ch)) out.push(ch + '[' + marks + ']');
677
+ continue;
678
+ }
679
+ const k = MARK_LETTER[node.nodeName];
680
+ const inner = k && marks.indexOf(k) === -1 ? (marks + k).split('').sort().join('') : marks;
681
+ domMarks(node.childNodes, inner, out);
682
+ }
683
+ return out;
684
+ }
685
+ const HTML_TAG_MARK = { strong: 'S', em: 'E', del: 'D' };
686
+ const HTML_ENTITY = { amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" };
687
+ function htmlMarks(html) {
688
+ const out = [];
689
+ const open = [];
690
+ let code = null;
691
+ const re = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>|&(amp|lt|gt|quot|#39);|([^<&]+|[<&])/g;
692
+ const current = () => open.slice().sort().filter((k, i, a) => a.indexOf(k) === i).join('');
693
+ let m;
694
+ while ((m = re.exec(html))) {
695
+ if (m[0].startsWith('<!--')) continue;
696
+ if (m[2]) {
697
+ const tag = m[2].toLowerCase();
698
+ if (tag === 'code') {
699
+ if (m[1]) { out.push('`' + (code || '').replace(/\s+/g, '') + '[' + current() + ']'); code = null; } else code = '';
700
+ continue;
701
+ }
702
+ const k = HTML_TAG_MARK[tag];
703
+ if (!k) continue;
704
+ if (m[1]) { const at = open.lastIndexOf(k); if (at !== -1) open.splice(at, 1); } else open.push(k);
705
+ continue;
706
+ }
707
+ const t = m[3] ? HTML_ENTITY[m[3]] : m[4];
708
+ if (code !== null) { code += t; continue; }
709
+ for (const ch of t) if (!SPACE.test(ch)) out.push(ch + '[' + current() + ']');
710
+ }
711
+ return out;
712
+ }
713
+
714
+ // `opts` is optional; its one field, `codePipe`, is described at
715
+ // serializeCode(). Every caller but table-md.js passes nothing.
716
+ function serializeInline(rootEl, opts) {
534
717
  const unsupported = [];
535
- const md = walkChildren(rootEl.childNodes, unsupported).md;
718
+ const md = walkChildren(rootEl.childNodes, unsupported, opts).md;
719
+ // The spellings below only differ where a STRONG / EM sits inside another
720
+ // one, so a block without that is written as walked, unparsed: no cost
721
+ // on the per-keystroke shortcut check or for callers that only want
722
+ // `unsupported`.
723
+ if (unsupported.length || !hasNestedStar(rootEl.childNodes, false)) return { md, unsupported };
724
+ const parse = getInlineParser();
725
+ if (!parse) return { md, unsupported };
726
+ const want = domMarks(rootEl.childNodes, '', []).join('|');
727
+ // table-md.js writes a code `|` as its placeholder; the cell shows `|`
728
+ // (GFM drops the `\` of `\|` before the cell is parsed inline).
729
+ const pipe = opts && opts.codePipe;
730
+ const readsBack = (s) => {
731
+ try {
732
+ return htmlMarks(parse(pipe ? s.split(pipe).join('|') : s)).join('|') === want;
733
+ } catch (e) {
734
+ return false;
735
+ }
736
+ };
737
+ if (readsBack(md)) return { md, unsupported };
738
+ const one = walkChildren(rootEl.childNodes, [], opts, NESTED_UNDERSCORE).md;
739
+ if (readsBack(one)) return { md: one, unsupported };
740
+ if (domText(rootEl.childNodes).indexOf(PENDING) === -1) {
741
+ const parts = walkChildren(rootEl.childNodes, [], opts, NESTED_GUARDED).md.split(PENDING);
742
+ // guard k sits right after parts[k]; none is kept at a line start
743
+ let before = '';
744
+ const keep = parts.slice(1).map((_, k) => {
745
+ before += parts[k];
746
+ return !/(^|\n)[ \t]*$/.test(before);
747
+ });
748
+ const build = () => parts.reduce((acc, part, i) =>
749
+ (i ? acc + (keep[i - 1] ? GUARD : '') : '') + part, '');
750
+ if (readsBack(build())) {
751
+ // Each drop is one more parse of the whole block. Past a handful of
752
+ // guards keep them all: correct bytes, a few extra `<!-- -->`
753
+ // (final review measured 400 repaired marks at 3.4 s otherwise).
754
+ for (let i = 0; i < keep.length && keep.length <= MAX_GUARDS_TO_DROP; i++) {
755
+ if (!keep[i]) continue;
756
+ keep[i] = false;
757
+ if (!readsBack(build())) keep[i] = true;
758
+ }
759
+ return { md: build(), unsupported };
760
+ }
761
+ }
536
762
  return { md, unsupported };
537
763
  }
764
+ const MAX_GUARDS_TO_DROP = 16;
765
+ const STAR_TAGS = { STRONG: 1, B: 1, EM: 1, I: 1 };
766
+ // Whether a STRONG / EM sits inside another one, through any wrapper the
767
+ // walk spells with the context it was given (not CODE, not a link label).
768
+ function hasNestedStar(nodes, inStar) {
769
+ for (let i = 0; i < nodes.length; i++) {
770
+ const node = nodes[i];
771
+ if (node.nodeType !== 1 || node.nodeName === 'CODE' || node.nodeName === 'A') continue;
772
+ const star = STAR_TAGS[node.nodeName] === 1;
773
+ if (star && inStar) return true;
774
+ if (hasNestedStar(node.childNodes, inStar || star)) return true;
775
+ }
776
+ return false;
777
+ }
778
+ function domText(nodes) {
779
+ let s = '';
780
+ for (let i = 0; i < nodes.length; i++) s += nodes[i].textContent || '';
781
+ return s;
782
+ }
538
783
 
539
784
  function canWysiwyg(rootEl) {
540
785
  return serializeInline(rootEl).unsupported.length === 0;
@@ -698,5 +943,5 @@
698
943
  return out;
699
944
  }
700
945
 
701
- return { serializeInline, canWysiwyg, escapeText, escapeBlockStarts, normalizeHref, isPastedUrl, parseImageParagraph, rewriteImage, setNamedReferenceDecoder };
946
+ return { serializeInline, canWysiwyg, escapeText, escapeBlockStarts, normalizeHref, isPastedUrl, parseImageParagraph, rewriteImage, setNamedReferenceDecoder, setInlineParser };
702
947
  });
@@ -215,8 +215,47 @@ function isHeadingRow(tr) {
215
215
  // against the header. Escape any `|` that isn't already an escaped `\|`
216
216
  // (the latter can arrive when a nested table's own already-escaped cell
217
217
  // content is flattened into its parent cell, see cellMarkdown below).
218
+ //
219
+ // v3.17.7 F2: "already escaped" means an ODD run of backslashes before the
220
+ // `|`, which is how marked's splitCells() decides it (the parity rule
221
+ // v3.17.6 wrote down in table-md.js). The old test was one backslash, any
222
+ // run: text `a \| b` (turndown writes its backslash as `\\`) came out
223
+ // `a \\| b`, an even run, and the cell was cut in two (measured on 71fedaa).
218
224
  function escapePipes(s) {
219
- return String(s).replace(/(?<!\\)\|/g, '\\|');
225
+ return String(s).replace(/(\\*)\|/g, function (m, run) {
226
+ return run.length % 2 ? m : run + '\\|';
227
+ });
228
+ }
229
+
230
+ // v3.17.7 F2: code whose text has an odd backslash run before a `|` has no
231
+ // spelling in a GFM table cell. The backslash that keeps the pipe in the
232
+ // cell is the one GFM deletes before the code span is read, so the code
233
+ // shows one backslash fewer (or, with one backslash more, the run turns even
234
+ // and the pipe ends the cell); table-md.js refuses the same code (v3.17.6).
235
+ // Paste cannot refuse, so such a span is written as text with EVERY ASCII
236
+ // punctuation character backslash-escaped (CommonMark allows that for all
237
+ // of them): then nothing in it is read as markup -- not `<!--`, not an
238
+ // entity, not md2doc's `^sup^` / `~sub~` / `$math$`. turndown's own
239
+ // escape() leaves those alone (final review: `a<!-- \| -->` lost its text,
240
+ // `^2^` became superscript). The `|` comes out `\|`, an odd run, which
241
+ // escapePipes() then leaves as it is. Every character arrives; only the
242
+ // code style does not.
243
+ var ODD_BACKSLASH_PIPE = /(?:^|[^\\])(?:\\\\)*\\\|/;
244
+ function addUnspellableCellCodeRule(service) {
245
+ service.addRule('unspellableCellCode', {
246
+ filter: function (node) {
247
+ if (node.nodeName !== 'CODE') return false;
248
+ if (node.parentNode && node.parentNode.nodeName === 'PRE') return false;
249
+ if (!ODD_BACKSLASH_PIPE.test(node.textContent || '')) return false;
250
+ for (var p = node.parentNode; p; p = p.parentNode) {
251
+ if (p.nodeName === 'TD' || p.nodeName === 'TH') return true;
252
+ }
253
+ return false;
254
+ },
255
+ replacement: function (content, node) {
256
+ return (node.textContent || '').replace(/[!-\/:-@\[-`{-~]/g, '\\$&');
257
+ }
258
+ });
220
259
  }
221
260
 
222
261
  // Only the ROWS THIS TABLE OWNS -- direct <tr> children, and <tr> children
@@ -345,6 +384,7 @@ function htmlToMarkdown(html) {
345
384
  addStrikethroughRule(service);
346
385
  addFlankingEmphasisRules(service);
347
386
  addTableRules(service);
387
+ addUnspellableCellCodeRule(service);
348
388
  return service.turndown(html == null ? '' : String(html));
349
389
  }
350
390
 
@@ -8,6 +8,12 @@ const { renderMarkdown, buildKatexStyleTag, diagramEnginePaths } = require('../m
8
8
  const { createDotPreview } = require('./dot-preview.js');
9
9
 
10
10
  const LINEOPS_SRC = fs.readFileSync(path.join(__dirname, 'lineops.js'), 'utf8');
11
+ // v3.17.7: marked's browser build (the package's exported `marked.min.js`,
12
+ // UMD, defines the global `marked`), so inline-md.js can parse the bytes it
13
+ // is about to save and check that the reader shows the marks the editor held
14
+ // (serializeInline()). Injected before inline-md.js; inline-md.js looks the
15
+ // global up at its first serialize, so the order is for reading, not load.
16
+ const MARKED_SRC = fs.readFileSync(require.resolve('marked/marked.min.js'), 'utf8');
11
17
  const INLINE_MD_SRC = fs.readFileSync(path.join(__dirname, 'inline-md.js'), 'utf8');
12
18
  const TABLE_MD_SRC = fs.readFileSync(path.join(__dirname, 'table-md.js'), 'utf8');
13
19
  // Task 4 (Phase 3): list-md.js's browser factory reads `root.md2docInlineMd`
@@ -776,6 +782,7 @@ async function createEditorServer({
776
782
  const inject =
777
783
  `<script>window.__ED__ = ${payload.replace(/</g, '\\u003c')}</script>\n` +
778
784
  `<script>${LINEOPS_SRC}</script>\n` +
785
+ `<script>${MARKED_SRC}</script>\n` +
779
786
  `<script>${INLINE_MD_SRC}</script>\n` +
780
787
  `<script>${TABLE_MD_SRC}</script>\n` +
781
788
  `<script>${LIST_MD_SRC}</script>\n` +
@@ -70,8 +70,8 @@
70
70
  // it has rows. No trailing whitespace on any emitted line (every row/
71
71
  // separator line is built as '|'-delimited fields, so it always ends in
72
72
  // '|', never in whitespace, by construction). A '|' inside a <code> span
73
- // has no faithful gate-safe emission — see cellHasCodePipe() below —
74
- // and reports 'CODE' unsupported instead of corrupting it.
73
+ // is written as '\|' instead (v3.17.6) — see codePipesOf() below; the one
74
+ // code text that has no table spelling reports 'CODE' unsupported.
75
75
  //
76
76
  // Constrained (same as inline-md.js, and for the same reason: the node
77
77
  // test drives this with the hand-rolled element stub from
@@ -123,32 +123,63 @@
123
123
  return s.replace(/\r\n|\r|\n/g, '<br>');
124
124
  }
125
125
 
126
- // Finding 4: a '|' inside a <code> span has no faithful gate-safe
127
- // emission — escapePipes() above runs on the WHOLE cell's serialized md
128
- // and can't tell a code span's content from plain text, so naively
129
- // applying it would corrupt the code span (turning `` `a|b` `` into the
130
- // broken `` `a&#124;b` ``, which never decodes back inside a code span).
131
- // `\|` (some other renderers' escape) is the gate's documented trap, so
132
- // there's no safe escape at all here — degrade-never-lose: detect a CODE
133
- // element anywhere in the cell whose textContent contains '|' and report
134
- // it unsupported instead, same signal an IMG/SPAN/etc. already uses to
135
- // degrade the whole table to raw-edit (see canWysiwygForTable() in
136
- // client.js). Recurses through the cell's element children looking for
137
- // CODE — constrained to childNodes/nodeType/nodeName like the rest of
138
- // this file (no querySelector, per the node-test stub).
139
- function cellHasCodePipe(node) {
126
+ // A '|' inside a <code> span (v3.17.6; Finding 4 of 2026-08-25 refused
127
+ // every one). escapePipes() above cannot be used on it: '&#124;' is never
128
+ // decoded inside a code span, so `` `a|b` `` would come back as `a&#124;b`.
129
+ // GFM's own spelling is '\|': the table tokenizer drops that backslash when
130
+ // it splits the row into cells, before the cell is parsed inline, so the
131
+ // code span shows a bare '|'. It is also what paperwork asks for. Its
132
+ // quality gate (G-MD-TABLE-PIPE,
133
+ // `scan_table_rows_for_unescaped_pipe_in_backtick` in scripts/gate/shared.py,
134
+ // 10.4.0) hard-FAILs an unescaped pipe inside a backtick span of a table
135
+ // row and gives '\|' as the fix. The paperwork-workspace-rtk design-docs
136
+ // are written that way, and those tables used to fall back to raw-edit.
137
+ //
138
+ // Correction, recorded because the old comment here said otherwise: it
139
+ // called '\|' "the gate's documented trap". Nothing in paperwork 10.4.0
140
+ // backs that. The only trap note about an escaped pipe there is a docstring
141
+ // in scripts/gate/hwe1.py (`_hwe1_requirement_units`): that helper splits
142
+ // hwe1 requirement rows with a plain split("|"), so it would cut such a
143
+ // cell in two, and the docstring itself says no gate reads that cell text
144
+ // today. It is a hazard for whoever extends that helper. It is not a rule
145
+ // against the form, and design-doc gates do not use that helper.
146
+ //
147
+ // One code text still has no spelling. marked's splitCells() treats a pipe
148
+ // as escaped when an ODD run of backslashes precedes it, then deletes one
149
+ // backslash. Code text with an even run before its '|' (none, two, …) is
150
+ // written with one more backslash and reads back exactly. With an odd run,
151
+ // that extra backslash makes the run even and the pipe becomes a cell
152
+ // boundary. That case reports 'CODE' unsupported, and the whole table falls
153
+ // back to raw-edit as before (canWysiwygForTable() in client.js).
154
+ //
155
+ // Returns { pipes, unwritable } over every CODE element in the cell. It
156
+ // recurses through elements that are not CODE and does not descend into
157
+ // CODE, because serializeCode() writes a CODE element's textContent whole.
158
+ // It sticks to childNodes/nodeType/nodeName like the rest of this file (no
159
+ // querySelector, per the node-test stub).
160
+ const ODD_BACKSLASH_PIPE = /(?:^|[^\\])(?:\\\\)*\\\|/;
161
+ function codePipesOf(node, acc) {
162
+ const out = acc || { pipes: 0, unwritable: false };
140
163
  const kids = elementChildren(node);
141
164
  for (let i = 0; i < kids.length; i++) {
142
165
  const k = kids[i];
143
166
  if (k.nodeName === 'CODE') {
144
- if (k.textContent.indexOf('|') !== -1) return true;
145
- } else if (cellHasCodePipe(k)) {
146
- return true;
167
+ const t = k.textContent;
168
+ out.pipes += t.split('|').length - 1;
169
+ if (ODD_BACKSLASH_PIPE.test(t)) out.unwritable = true;
170
+ } else {
171
+ codePipesOf(k, out);
147
172
  }
148
173
  }
149
- return false;
174
+ return out;
150
175
  }
151
176
 
177
+ // The placeholder serializeCode() writes for a code '|' while the cell's
178
+ // markdown goes through escapePipes(). A private-use character, so real
179
+ // text almost never holds it. If one does, the count check in
180
+ // cellContentOf() catches it and the cell degrades rather than guessing.
181
+ const CODE_PIPE_MARK = '\uE000';
182
+
152
183
  // ── v3.0.1 欄寬保留 ────────────────────────────────────────────────────
153
184
  // A cell FIELD is the text between two '|' delimiters, PADDING INCLUDED:
154
185
  // in '| a | b |' the fields are ' a ' and ' b '. A column only counts
@@ -159,8 +190,8 @@
159
190
  // width of 3 is indistinguishable from this serializer's own minimal
160
191
  // form, which is exactly 3 on every row by construction).
161
192
  //
162
- // A raw '|' never appears inside a cell in source THIS serializer produced
163
- // (it emits '&#124;'), but a hand-written table may use the '\|' escape, so
193
+ // Source THIS serializer produced holds '\|' only inside code spans (plain
194
+ // text gets '&#124;'), and a hand-written table may use '\|' anywhere, so
164
195
  // the split must not break on an escaped delimiter.
165
196
  function splitFields(line) {
166
197
  const t = line.trim();
@@ -312,10 +343,12 @@
312
343
  // `unsupported` for the same cell), so width measurement and emission
313
344
  // below both read from these same strings.
314
345
  function cellContentOf(cell, unsupported) {
315
- if (cellHasCodePipe(cell)) unsupported.push('CODE');
316
- const res = inlineMd.serializeInline(cell);
346
+ const code = codePipesOf(cell);
347
+ const res = inlineMd.serializeInline(cell, { codePipe: CODE_PIPE_MARK });
317
348
  res.unsupported.forEach((u) => unsupported.push(u));
318
- return escapeNewlines(escapePipes(res.md));
349
+ const parts = escapeNewlines(escapePipes(res.md)).split(CODE_PIPE_MARK);
350
+ if (code.unwritable || parts.length - 1 !== code.pipes) unsupported.push('CODE');
351
+ return parts.join('\\|');
319
352
  }
320
353
 
321
354
  function serializeRow(contents, widths) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "3.17.5",
3
+ "version": "3.17.7",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, Graphviz, and draw.io support",
5
5
  "keywords": [
6
6
  "markdown",