@helping-ai-workflow/md2doc 3.17.4 → 3.17.6

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,7 @@
148
154
  return inner.indexOf(']') === -1;
149
155
  }
150
156
 
151
- function serializeAnchor(node, unsupported) {
157
+ function serializeAnchor(node, unsupported, opts) {
152
158
  const href = node.getAttribute('href');
153
159
  // A raw HTML anchor (`<a id="ref-x"></a>`, NO href attribute) is not a
154
160
  // markdown link: emitting '[label]()' for it erased the anchor on the
@@ -167,10 +173,14 @@
167
173
  unsupported.push('A');
168
174
  return '';
169
175
  }
170
- const label = walkChildren(node.childNodes, unsupported).md;
176
+ const label = walkChildren(node.childNodes, unsupported, opts).md;
171
177
  return '[' + label + '](' + (href || '') + ')';
172
178
  }
173
179
 
180
+ // (What a mark's own edges must look like -- whitespace and punctuation
181
+ // just inside its delimiters -- is a separate rule, v3.17.5, written above
182
+ // `splitEdges` / `appendPiece` further down.)
183
+ //
174
184
  // v3.4.0 / backlog 3 (Ruling T6-7, then a fix-round regression): STRONG
175
185
  // and EM each emit a fixed, context-blind '*'-based delimiter for their
176
186
  // own tag — '**'/'**' or '*'/'*' — with no awareness of what the
@@ -267,11 +277,121 @@
267
277
  // STRONG, DEL/S, U, the citation label) only ever needs `.md`, because
268
278
  // its own outer delimiter is a fixed, known tag regardless of what's
269
279
  // nested inside.
280
+ // v3.17.5 M1 + R1: a delimiter run only counts when CommonMark's flanking
281
+ // rule accepts it, and STRONG / EM / DEL used to wrap their content blind.
282
+ // Measured on 4d7669f, all three rendered literal asterisks in the reader:
283
+ // Delete down to a leading space `alpha ** charlie** delta`
284
+ // Ctrl+B on 「引號」 `前面**「引號」**後面`
285
+ // Ctrl+B on `charlie ` (with space) `alpha **charlie **delta`
286
+ // The rule, in marked 14's own classes (whitespace = JS `\s`, punctuation =
287
+ // `\p{P}\p{S}`): the character just INSIDE a run may not be whitespace, and
288
+ // when it is punctuation the character just OUTSIDE must be whitespace,
289
+ // punctuation or the edge of the text.
290
+ //
291
+ // Two fixes, one per half of the rule:
292
+ // * whitespace inside -> moved outside (`splitEdges`), byte for byte, so
293
+ // ` charlie` in bold becomes ` **charlie**`; a mark holding nothing
294
+ // else is no mark at all. A hard break (`\` + newline) at an edge
295
+ // moves out whole: peeling only its newline would leave the backslash
296
+ // escaping the delimiter.
297
+ // * punctuation inside, a word character outside -> `<!-- -->` between
298
+ // them (`appendPiece`). The comment is the same inert separator the
299
+ // same-tag guard above uses; marked masks an HTML tag as punctuation,
300
+ // so the run then has punctuation on both sides, which the rule allows.
301
+ // Only `*` runs need it: marked's `~~` is not punctuation-flanking
302
+ // (`~~中文。~~後面` parses), and `_` is never emitted.
303
+ // The punctuation check runs where two pieces are joined, on the bytes
304
+ // themselves, because the character outside a run may belong to a sibling,
305
+ // or to a parent's sibling when the run starts or ends a bare SPAN: every
306
+ // level joins its own pieces, so a run at the edge of a nested call is
307
+ // checked again by its parent. A run's inside is found by skipping its bare
308
+ // `*`s: escapeText() escapes every literal `*`, so a `*` at either end of a
309
+ // piece is a delimiter unless an odd run of backslashes precedes it.
310
+ const FLANK_WS = /\s/;
311
+ const FLANK_PUNCT = /[\p{P}\p{S}]/u;
312
+ function isWordish(ch) {
313
+ return ch !== undefined && !FLANK_WS.test(ch) && !FLANK_PUNCT.test(ch);
314
+ }
315
+ function lastCodePoint(s, end) {
316
+ if (end <= 0) return undefined;
317
+ const lo = s.charCodeAt(end - 1);
318
+ if (lo >= 0xdc00 && lo <= 0xdfff && end >= 2) {
319
+ const hi = s.charCodeAt(end - 2);
320
+ if (hi >= 0xd800 && hi <= 0xdbff) return s.slice(end - 2, end);
321
+ }
322
+ return s[end - 1];
323
+ }
324
+ function firstCodePoint(s, start) {
325
+ if (start >= s.length) return undefined;
326
+ return String.fromCodePoint(s.codePointAt(start));
327
+ }
328
+ // The character just inside a leading `*` run, or undefined when `s` does
329
+ // not open with one.
330
+ function openingRunInside(s) {
331
+ let i = 0;
332
+ while (s[i] === '*') i++;
333
+ return i ? firstCodePoint(s, i) : undefined;
334
+ }
335
+ // The character just inside a trailing `*` run, or undefined when `s` does
336
+ // not close with one.
337
+ function closingRunInside(s) {
338
+ let k = 0;
339
+ while (k < s.length && s[s.length - 1 - k] === '*') k++;
340
+ if (!k) return undefined;
341
+ let b = 0;
342
+ while (s[s.length - 1 - k - b] === '\\') b++;
343
+ if (b % 2) return k > 1 ? '*' : undefined; // the run's first `*` is an escaped literal
344
+ return lastCodePoint(s, s.length - k);
345
+ }
346
+ // Two v3.17.5 final-review findings shape the outside characters:
347
+ // * I2 -- marked reads the character before an opening run as ONE UTF-16
348
+ // unit (`token.raw.slice(-1)`), so an astral symbol there (📌, \p{So})
349
+ // is a lone surrogate to it, i.e. a word character. Match that.
350
+ // * I1 -- a closing run touching the next piece's opening run fuses with
351
+ // it (`**注意:***斜體*`); the character after the fused run is the one
352
+ // inside the right piece's run, not its `*`. The opening side needs no
353
+ // such care: once the left run has closed, marked sees a `*`
354
+ // (punctuation) before the rest, which the rule accepts.
355
+ function needsFlankSeparator(left, right) {
356
+ if (!left || !right) return false;
357
+ const open = openingRunInside(right);
358
+ if (open !== undefined && FLANK_PUNCT.test(open) && isWordish(left[left.length - 1])) return true;
359
+ const close = closingRunInside(left);
360
+ const after = right[0] === '*' ? openingRunInside(right) : firstCodePoint(right, 0);
361
+ return close !== undefined && FLANK_PUNCT.test(close) && isWordish(after);
362
+ }
363
+ function appendPiece(out, piece) {
364
+ return out + (needsFlankSeparator(out, piece) ? '<!-- -->' : '') + piece;
365
+ }
366
+ // `md` -> { lead, core, trail }: whitespace (and whole hard breaks) peeled
367
+ // off both ends of a mark's content.
368
+ function splitEdges(md) {
369
+ let start = 0;
370
+ let end = md.length;
371
+ for (;;) {
372
+ if (start < end && FLANK_WS.test(md[start])) { start++; continue; }
373
+ // a leading `\` is always a hard break's: a literal one is `\\`
374
+ if (md[start] === '\\' && md[start + 1] === '\n' && start + 2 <= end) { start += 2; continue; }
375
+ break;
376
+ }
377
+ for (;;) {
378
+ if (end > start && md[end - 1] === '\n') {
379
+ let b = 0;
380
+ while (end - 2 - b >= start && md[end - 2 - b] === '\\') b++;
381
+ end -= b % 2 ? 2 : 1;
382
+ continue;
383
+ }
384
+ if (end > start && FLANK_WS.test(md[end - 1])) { end--; continue; }
385
+ break;
386
+ }
387
+ return { lead: md.slice(0, start), core: md.slice(start, end), trail: md.slice(end) };
388
+ }
389
+
270
390
  function markIsSearchHit(node) {
271
391
  if (typeof node.getAttribute !== 'function') return false;
272
392
  return (' ' + (node.getAttribute('class') || '') + ' ').indexOf(' search-hit ') !== -1;
273
393
  }
274
- function walkChildren(nodes, unsupported) {
394
+ function walkChildren(nodes, unsupported, opts) {
275
395
  let out = '';
276
396
  let firstSegment = true;
277
397
  let lastMarkTag = null;
@@ -279,7 +399,7 @@
279
399
  const node = nodes[i];
280
400
  if (node.nodeType === 3) {
281
401
  const text = escapeText(node.textContent);
282
- out += text;
402
+ out = appendPiece(out, text);
283
403
  if (text.length) lastMarkTag = null;
284
404
  firstSegment = false;
285
405
  continue;
@@ -289,9 +409,9 @@
289
409
  if (name === 'DIV') {
290
410
  let added = '';
291
411
  if (!firstSegment) added += '<br>';
292
- const inner = walkChildren(node.childNodes, unsupported);
412
+ const inner = walkChildren(node.childNodes, unsupported, opts);
293
413
  added += inner.md;
294
- out += added;
414
+ out = appendPiece(out, added);
295
415
  if (added.length) lastMarkTag = inner.md.length ? inner.lastMarkTag : null;
296
416
  firstSegment = false;
297
417
  continue;
@@ -299,8 +419,8 @@
299
419
  if (name === 'MARK' && markIsSearchHit(node)) {
300
420
  // v3.14.0 T9: the reader's search paints its hits as this element and
301
421
  // it is transient, so it serializes as what it wraps.
302
- const inner = walkChildren(node.childNodes, unsupported);
303
- out += inner.md;
422
+ const inner = walkChildren(node.childNodes, unsupported, opts);
423
+ out = appendPiece(out, inner.md);
304
424
  if (inner.md.length) lastMarkTag = inner.lastMarkTag;
305
425
  firstSegment = false;
306
426
  continue;
@@ -309,24 +429,26 @@
309
429
  if (spanHasAttributes(node)) {
310
430
  unsupported.push(name);
311
431
  } else {
312
- const inner = walkChildren(node.childNodes, unsupported);
313
- out += inner.md;
432
+ const inner = walkChildren(node.childNodes, unsupported, opts);
433
+ out = appendPiece(out, inner.md);
314
434
  if (inner.md.length) lastMarkTag = inner.lastMarkTag;
315
435
  }
316
436
  firstSegment = false;
317
437
  continue;
318
438
  }
319
- if (name === 'STRONG' || name === 'B') {
320
- out += (lastMarkTag === 'STRONG' ? '<!-- -->' : '') +
321
- '**' + walkChildren(node.childNodes, unsupported).md + '**';
322
- lastMarkTag = 'STRONG';
323
- firstSegment = false;
324
- continue;
325
- }
326
- if (name === 'EM' || name === 'I') {
327
- out += (lastMarkTag === 'EM' ? '<!-- -->' : '') +
328
- '*' + walkChildren(node.childNodes, unsupported).md + '*';
329
- lastMarkTag = 'EM';
439
+ if (name === 'STRONG' || name === 'B' || name === 'EM' || name === 'I') {
440
+ const tag = (name === 'STRONG' || name === 'B') ? 'STRONG' : 'EM';
441
+ const delim = tag === 'STRONG' ? '**' : '*';
442
+ const e = splitEdges(walkChildren(node.childNodes, unsupported, opts).md);
443
+ if (e.core) {
444
+ // a hoisted leading space already separates two same-tag runs
445
+ const guard = !e.lead && lastMarkTag === tag ? '<!-- -->' : '';
446
+ out = appendPiece(out, e.lead + guard + delim + e.core + delim + e.trail);
447
+ lastMarkTag = e.trail ? null : tag;
448
+ } else if (e.lead || e.trail) {
449
+ out = appendPiece(out, e.lead + e.trail);
450
+ lastMarkTag = null;
451
+ }
330
452
  firstSegment = false;
331
453
  continue;
332
454
  }
@@ -336,8 +458,9 @@
336
458
  // accepted on input (some contenteditable/paste paths produce it)
337
459
  // but the toolbar/serializer always speak DEL, matching what marked
338
460
  // itself emits.
339
- out += '~~' + walkChildren(node.childNodes, unsupported).md + '~~';
340
- lastMarkTag = null;
461
+ const e = splitEdges(walkChildren(node.childNodes, unsupported, opts).md);
462
+ out = appendPiece(out, e.core ? e.lead + '~~' + e.core + '~~' + e.trail : e.lead + e.trail);
463
+ if (e.core || e.lead || e.trail) lastMarkTag = null;
341
464
  firstSegment = false;
342
465
  continue;
343
466
  }
@@ -352,19 +475,19 @@
352
475
  // backtick, literal `<u>` typed as plain text is already escaped by
353
476
  // the `<` -> `&lt;` rule above, so it can never collide with a real
354
477
  // toolbar-made underline.
355
- out += '<u>' + walkChildren(node.childNodes, unsupported).md + '</u>';
478
+ out = appendPiece(out, '<u>' + walkChildren(node.childNodes, unsupported, opts).md + '</u>');
356
479
  lastMarkTag = null;
357
480
  firstSegment = false;
358
481
  continue;
359
482
  }
360
483
  if (name === 'CODE') {
361
- out += serializeCode(node);
484
+ out = appendPiece(out, serializeCode(node, opts));
362
485
  lastMarkTag = null;
363
486
  firstSegment = false;
364
487
  continue;
365
488
  }
366
489
  if (name === 'A') {
367
- out += serializeAnchor(node, unsupported);
490
+ out = appendPiece(out, serializeAnchor(node, unsupported, opts));
368
491
  lastMarkTag = null;
369
492
  firstSegment = false;
370
493
  continue;
@@ -402,7 +525,7 @@
402
525
  // reach this code.
403
526
  const hardBreak = typeof node.getAttribute === 'function' &&
404
527
  node.getAttribute('data-hard-break') === '1';
405
- out += hardBreak ? '\\\n' : '<br>';
528
+ out = appendPiece(out, hardBreak ? '\\\n' : '<br>');
406
529
  lastMarkTag = null;
407
530
  firstSegment = false;
408
531
  continue;
@@ -413,9 +536,11 @@
413
536
  return { md: out, lastMarkTag };
414
537
  }
415
538
 
416
- function serializeInline(rootEl) {
539
+ // `opts` is optional; its one field, `codePipe`, is described at
540
+ // serializeCode(). Every caller but table-md.js passes nothing.
541
+ function serializeInline(rootEl, opts) {
417
542
  const unsupported = [];
418
- const md = walkChildren(rootEl.childNodes, unsupported).md;
543
+ const md = walkChildren(rootEl.childNodes, unsupported, opts).md;
419
544
  return { md, unsupported };
420
545
  }
421
546
 
@@ -55,6 +55,140 @@ function addStrikethroughRule(service) {
55
55
  });
56
56
  }
57
57
 
58
+ // --- bold / italic next to punctuation (v3.17.5 R2) -------------------------
59
+ //
60
+ // CommonMark only takes a `*` run whose inside character, when it is
61
+ // punctuation (marked 14: \p{P}\p{S}), has whitespace, punctuation or the edge
62
+ // of the text on its outside. turndown's own strong / emphasis rules wrap
63
+ // blind, so a pasted `前面<b>「引號」</b>後面` became `前面**「引號」**後面` and
64
+ // the reader showed the asterisks. These two rules replace them and put
65
+ // `<!-- -->` (marked reads an HTML tag as punctuation) on a side that needs
66
+ // it -- the same separator, for the same rule, as walkChildren() in
67
+ // inline-md.js; the classes below are that file's FLANK_WS / FLANK_PUNCT.
68
+ // Whitespace at the edges never reaches them: turndown moves it outside
69
+ // first (`node.flankingWhitespace`), and a side that got some needs nothing.
70
+ //
71
+ // The outside character is read from the DOM, because the markdown around
72
+ // this node does not exist yet: the nearest text before / after it within the
73
+ // block, through bare inline wrappers. A neighbour that writes a delimiter or
74
+ // a bracket of its own there (a parent <b>, <a>, <code>...) is punctuation; an
75
+ // <img>, <br> or <hr> writes `)` or whitespace. Any other element with no text
76
+ // (a Word bookmark `<a name>`, an empty <span>, `<o:p>`) writes nothing and is
77
+ // looked past; the wrappers below write only their content (v3.17.5 final
78
+ // review I3: <u>, <sup>, <ins>... were missing, and an empty neighbour stopped
79
+ // the search).
80
+ //
81
+ // Before the run, the character is one UTF-16 unit, as marked reads it
82
+ // (`token.raw.slice(-1)`): an astral symbol there (📌) is a lone surrogate to
83
+ // it, a word character (final review I2).
84
+ var FLANK_WS = /\s/;
85
+ var FLANK_PUNCT = /[\p{P}\p{S}]/u;
86
+ var TRANSPARENT_INLINE = /^(SPAN|FONT|MARK|SMALL|BIG|ABBR|CITE|DFN|Q|TIME|DATA|BDI|BDO|U|INS|SUB|SUP|KBD|SAMP|VAR|LABEL|O:P)$/;
87
+ var WRITES_ITS_OWN = /^(IMG|BR|HR)$/;
88
+
89
+ function isWordish(ch) {
90
+ return ch !== undefined && !FLANK_WS.test(ch) && !FLANK_PUNCT.test(ch);
91
+ }
92
+
93
+ function edgeChar(text, last) {
94
+ if (!text) return undefined;
95
+ return last ? text[text.length - 1] : Array.from(text)[0];
96
+ }
97
+
98
+ // Only an inline element's text is read as the neighbour. A block, or an
99
+ // element not on these lists, is a line edge or unknown: the answer is
100
+ // "not a word character", so no separator -- which can never put `<!--` at a
101
+ // line start, where marked reads it as an HTML block that ends the paragraph
102
+ // (re-review I1: `<div>前</div><b>「x」</b>`).
103
+ var MARKUP_INLINE = /^(STRONG|B|EM|I|DEL|S|STRIKE|A|CODE)$/;
104
+
105
+ // An element with no text writes nothing only when it is one of the inline
106
+ // elements above and nothing inside it writes either. An <img>, <br> or <hr>
107
+ // writes `)` or a line break (re-review C1: `<span><br></span>` was looked
108
+ // past, and the separator landed at the start of a line); a block -- or an
109
+ // element not on the lists -- writes a line edge even when empty (round 3,
110
+ // I1': `<p></p>` is a blank line). A bold / italic with no text is dropped by
111
+ // its own rule whatever it holds (`<b><br></b>`).
112
+ function writesNothing(el) {
113
+ var name = el.nodeName;
114
+ if (/^(STRONG|B|EM|I)$/.test(name) && !el.textContent.trim()) return true;
115
+ if (!TRANSPARENT_INLINE.test(name) && !MARKUP_INLINE.test(name)) return false;
116
+ for (var c = el.firstChild; c; c = c.nextSibling) {
117
+ if (c.nodeType === 1 && !writesNothing(c)) return false;
118
+ }
119
+ return true;
120
+ }
121
+
122
+ // The character a neighbour writes at the edge facing the run: its last (or
123
+ // first) descendant that writes anything. A <br> / <img> / <hr> there, or a
124
+ // block or unknown element on the way, answers undefined -- so
125
+ // `<span>前<br></span>` is a line edge, not 前.
126
+ function edgeOf(node, last) {
127
+ if (node.nodeType === 3) return node.textContent ? edgeChar(node.textContent, last) : undefined;
128
+ if (node.nodeType !== 1) return undefined;
129
+ if (!TRANSPARENT_INLINE.test(node.nodeName) && !MARKUP_INLINE.test(node.nodeName)) return undefined;
130
+ for (var c = last ? node.lastChild : node.firstChild; c; c = last ? c.previousSibling : c.nextSibling) {
131
+ if (c.nodeType === 3 && !c.textContent) continue;
132
+ if (c.nodeType === 1 && !c.textContent && writesNothing(c)) continue;
133
+ if (c.nodeType !== 1 && c.nodeType !== 3) continue;
134
+ return edgeOf(c, last);
135
+ }
136
+ return undefined;
137
+ }
138
+
139
+ function outsideChar(node, dir) {
140
+ for (var n = node; n; n = n.parentNode) {
141
+ var sib = n[dir];
142
+ while (sib && (sib.nodeType === 1 ? !sib.textContent && writesNothing(sib)
143
+ : sib.nodeType !== 3 || !sib.textContent)) sib = sib[dir];
144
+ if (sib) return edgeOf(sib, dir === 'previousSibling');
145
+ var parent = n.parentNode;
146
+ if (!parent || parent.nodeType !== 1 || !TRANSPARENT_INLINE.test(parent.nodeName)) return undefined;
147
+ }
148
+ return undefined;
149
+ }
150
+
151
+ function insideChar(md, last) {
152
+ var chars = Array.from(md);
153
+ if (last) {
154
+ var i = chars.length - 1;
155
+ while (i >= 0 && chars[i] === '*') i -= 1;
156
+ return chars[i];
157
+ }
158
+ var j = 0;
159
+ while (j < chars.length && chars[j] === '*') j += 1;
160
+ return chars[j];
161
+ }
162
+
163
+ function flankedReplacement(delimiterOf) {
164
+ return function (content, node, options) {
165
+ if (!content.trim()) return '';
166
+ // turndown moves the text's own edge whitespace outside, but not a <br>'s
167
+ // ` \n` at an edge (final review): peel that too
168
+ var lead = /^\s*/.exec(content)[0];
169
+ var trail = /\s*$/.exec(content)[0];
170
+ var delim = delimiterOf(options);
171
+ var md = delim + content.slice(lead.length, content.length - trail.length) + delim;
172
+ var ws = node.flankingWhitespace || { leading: '', trailing: '' };
173
+ var before = !lead && !ws.leading && FLANK_PUNCT.test(insideChar(md, false) || '') &&
174
+ isWordish(outsideChar(node, 'previousSibling'));
175
+ var after = !trail && !ws.trailing && FLANK_PUNCT.test(insideChar(md, true) || '') &&
176
+ isWordish(outsideChar(node, 'nextSibling'));
177
+ return lead + (before ? '<!-- -->' : '') + md + (after ? '<!-- -->' : '') + trail;
178
+ };
179
+ }
180
+
181
+ function addFlankingEmphasisRules(service) {
182
+ service.addRule('strongFlanking', {
183
+ filter: ['strong', 'b'],
184
+ replacement: flankedReplacement(function (o) { return o.strongDelimiter; })
185
+ });
186
+ service.addRule('emphasisFlanking', {
187
+ filter: ['em', 'i'],
188
+ replacement: flankedReplacement(function (o) { return o.emDelimiter; })
189
+ });
190
+ }
191
+
58
192
  function isFirstTbody(el) {
59
193
  var prev = el.previousSibling;
60
194
  return (
@@ -209,6 +343,7 @@ function htmlToMarkdown(html) {
209
343
  emDelimiter: '*'
210
344
  });
211
345
  addStrikethroughRule(service);
346
+ addFlankingEmphasisRules(service);
212
347
  addTableRules(service);
213
348
  return service.turndown(html == null ? '' : String(html));
214
349
  }
@@ -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.4",
3
+ "version": "3.17.6",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, Graphviz, and draw.io support",
5
5
  "keywords": [
6
6
  "markdown",