@helping-ai-workflow/md2doc 3.17.4 → 3.17.5

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.
@@ -171,6 +171,10 @@
171
171
  return '[' + label + '](' + (href || '') + ')';
172
172
  }
173
173
 
174
+ // (What a mark's own edges must look like -- whitespace and punctuation
175
+ // just inside its delimiters -- is a separate rule, v3.17.5, written above
176
+ // `splitEdges` / `appendPiece` further down.)
177
+ //
174
178
  // v3.4.0 / backlog 3 (Ruling T6-7, then a fix-round regression): STRONG
175
179
  // and EM each emit a fixed, context-blind '*'-based delimiter for their
176
180
  // own tag — '**'/'**' or '*'/'*' — with no awareness of what the
@@ -267,6 +271,116 @@
267
271
  // STRONG, DEL/S, U, the citation label) only ever needs `.md`, because
268
272
  // its own outer delimiter is a fixed, known tag regardless of what's
269
273
  // nested inside.
274
+ // v3.17.5 M1 + R1: a delimiter run only counts when CommonMark's flanking
275
+ // rule accepts it, and STRONG / EM / DEL used to wrap their content blind.
276
+ // Measured on 4d7669f, all three rendered literal asterisks in the reader:
277
+ // Delete down to a leading space `alpha ** charlie** delta`
278
+ // Ctrl+B on 「引號」 `前面**「引號」**後面`
279
+ // Ctrl+B on `charlie ` (with space) `alpha **charlie **delta`
280
+ // The rule, in marked 14's own classes (whitespace = JS `\s`, punctuation =
281
+ // `\p{P}\p{S}`): the character just INSIDE a run may not be whitespace, and
282
+ // when it is punctuation the character just OUTSIDE must be whitespace,
283
+ // punctuation or the edge of the text.
284
+ //
285
+ // Two fixes, one per half of the rule:
286
+ // * whitespace inside -> moved outside (`splitEdges`), byte for byte, so
287
+ // ` charlie` in bold becomes ` **charlie**`; a mark holding nothing
288
+ // else is no mark at all. A hard break (`\` + newline) at an edge
289
+ // moves out whole: peeling only its newline would leave the backslash
290
+ // escaping the delimiter.
291
+ // * punctuation inside, a word character outside -> `<!-- -->` between
292
+ // them (`appendPiece`). The comment is the same inert separator the
293
+ // same-tag guard above uses; marked masks an HTML tag as punctuation,
294
+ // so the run then has punctuation on both sides, which the rule allows.
295
+ // Only `*` runs need it: marked's `~~` is not punctuation-flanking
296
+ // (`~~中文。~~後面` parses), and `_` is never emitted.
297
+ // The punctuation check runs where two pieces are joined, on the bytes
298
+ // themselves, because the character outside a run may belong to a sibling,
299
+ // or to a parent's sibling when the run starts or ends a bare SPAN: every
300
+ // level joins its own pieces, so a run at the edge of a nested call is
301
+ // checked again by its parent. A run's inside is found by skipping its bare
302
+ // `*`s: escapeText() escapes every literal `*`, so a `*` at either end of a
303
+ // piece is a delimiter unless an odd run of backslashes precedes it.
304
+ const FLANK_WS = /\s/;
305
+ const FLANK_PUNCT = /[\p{P}\p{S}]/u;
306
+ function isWordish(ch) {
307
+ return ch !== undefined && !FLANK_WS.test(ch) && !FLANK_PUNCT.test(ch);
308
+ }
309
+ function lastCodePoint(s, end) {
310
+ if (end <= 0) return undefined;
311
+ const lo = s.charCodeAt(end - 1);
312
+ if (lo >= 0xdc00 && lo <= 0xdfff && end >= 2) {
313
+ const hi = s.charCodeAt(end - 2);
314
+ if (hi >= 0xd800 && hi <= 0xdbff) return s.slice(end - 2, end);
315
+ }
316
+ return s[end - 1];
317
+ }
318
+ function firstCodePoint(s, start) {
319
+ if (start >= s.length) return undefined;
320
+ return String.fromCodePoint(s.codePointAt(start));
321
+ }
322
+ // The character just inside a leading `*` run, or undefined when `s` does
323
+ // not open with one.
324
+ function openingRunInside(s) {
325
+ let i = 0;
326
+ while (s[i] === '*') i++;
327
+ return i ? firstCodePoint(s, i) : undefined;
328
+ }
329
+ // The character just inside a trailing `*` run, or undefined when `s` does
330
+ // not close with one.
331
+ function closingRunInside(s) {
332
+ let k = 0;
333
+ while (k < s.length && s[s.length - 1 - k] === '*') k++;
334
+ if (!k) return undefined;
335
+ let b = 0;
336
+ while (s[s.length - 1 - k - b] === '\\') b++;
337
+ if (b % 2) return k > 1 ? '*' : undefined; // the run's first `*` is an escaped literal
338
+ return lastCodePoint(s, s.length - k);
339
+ }
340
+ // Two v3.17.5 final-review findings shape the outside characters:
341
+ // * I2 -- marked reads the character before an opening run as ONE UTF-16
342
+ // unit (`token.raw.slice(-1)`), so an astral symbol there (📌, \p{So})
343
+ // is a lone surrogate to it, i.e. a word character. Match that.
344
+ // * I1 -- a closing run touching the next piece's opening run fuses with
345
+ // it (`**注意:***斜體*`); the character after the fused run is the one
346
+ // inside the right piece's run, not its `*`. The opening side needs no
347
+ // such care: once the left run has closed, marked sees a `*`
348
+ // (punctuation) before the rest, which the rule accepts.
349
+ function needsFlankSeparator(left, right) {
350
+ if (!left || !right) return false;
351
+ const open = openingRunInside(right);
352
+ if (open !== undefined && FLANK_PUNCT.test(open) && isWordish(left[left.length - 1])) return true;
353
+ const close = closingRunInside(left);
354
+ const after = right[0] === '*' ? openingRunInside(right) : firstCodePoint(right, 0);
355
+ return close !== undefined && FLANK_PUNCT.test(close) && isWordish(after);
356
+ }
357
+ function appendPiece(out, piece) {
358
+ return out + (needsFlankSeparator(out, piece) ? '<!-- -->' : '') + piece;
359
+ }
360
+ // `md` -> { lead, core, trail }: whitespace (and whole hard breaks) peeled
361
+ // off both ends of a mark's content.
362
+ function splitEdges(md) {
363
+ let start = 0;
364
+ let end = md.length;
365
+ for (;;) {
366
+ if (start < end && FLANK_WS.test(md[start])) { start++; continue; }
367
+ // a leading `\` is always a hard break's: a literal one is `\\`
368
+ if (md[start] === '\\' && md[start + 1] === '\n' && start + 2 <= end) { start += 2; continue; }
369
+ break;
370
+ }
371
+ for (;;) {
372
+ if (end > start && md[end - 1] === '\n') {
373
+ let b = 0;
374
+ while (end - 2 - b >= start && md[end - 2 - b] === '\\') b++;
375
+ end -= b % 2 ? 2 : 1;
376
+ continue;
377
+ }
378
+ if (end > start && FLANK_WS.test(md[end - 1])) { end--; continue; }
379
+ break;
380
+ }
381
+ return { lead: md.slice(0, start), core: md.slice(start, end), trail: md.slice(end) };
382
+ }
383
+
270
384
  function markIsSearchHit(node) {
271
385
  if (typeof node.getAttribute !== 'function') return false;
272
386
  return (' ' + (node.getAttribute('class') || '') + ' ').indexOf(' search-hit ') !== -1;
@@ -279,7 +393,7 @@
279
393
  const node = nodes[i];
280
394
  if (node.nodeType === 3) {
281
395
  const text = escapeText(node.textContent);
282
- out += text;
396
+ out = appendPiece(out, text);
283
397
  if (text.length) lastMarkTag = null;
284
398
  firstSegment = false;
285
399
  continue;
@@ -291,7 +405,7 @@
291
405
  if (!firstSegment) added += '<br>';
292
406
  const inner = walkChildren(node.childNodes, unsupported);
293
407
  added += inner.md;
294
- out += added;
408
+ out = appendPiece(out, added);
295
409
  if (added.length) lastMarkTag = inner.md.length ? inner.lastMarkTag : null;
296
410
  firstSegment = false;
297
411
  continue;
@@ -300,7 +414,7 @@
300
414
  // v3.14.0 T9: the reader's search paints its hits as this element and
301
415
  // it is transient, so it serializes as what it wraps.
302
416
  const inner = walkChildren(node.childNodes, unsupported);
303
- out += inner.md;
417
+ out = appendPiece(out, inner.md);
304
418
  if (inner.md.length) lastMarkTag = inner.lastMarkTag;
305
419
  firstSegment = false;
306
420
  continue;
@@ -310,23 +424,25 @@
310
424
  unsupported.push(name);
311
425
  } else {
312
426
  const inner = walkChildren(node.childNodes, unsupported);
313
- out += inner.md;
427
+ out = appendPiece(out, inner.md);
314
428
  if (inner.md.length) lastMarkTag = inner.lastMarkTag;
315
429
  }
316
430
  firstSegment = false;
317
431
  continue;
318
432
  }
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';
433
+ if (name === 'STRONG' || name === 'B' || name === 'EM' || name === 'I') {
434
+ const tag = (name === 'STRONG' || name === 'B') ? 'STRONG' : 'EM';
435
+ const delim = tag === 'STRONG' ? '**' : '*';
436
+ const e = splitEdges(walkChildren(node.childNodes, unsupported).md);
437
+ if (e.core) {
438
+ // a hoisted leading space already separates two same-tag runs
439
+ const guard = !e.lead && lastMarkTag === tag ? '<!-- -->' : '';
440
+ out = appendPiece(out, e.lead + guard + delim + e.core + delim + e.trail);
441
+ lastMarkTag = e.trail ? null : tag;
442
+ } else if (e.lead || e.trail) {
443
+ out = appendPiece(out, e.lead + e.trail);
444
+ lastMarkTag = null;
445
+ }
330
446
  firstSegment = false;
331
447
  continue;
332
448
  }
@@ -336,8 +452,9 @@
336
452
  // accepted on input (some contenteditable/paste paths produce it)
337
453
  // but the toolbar/serializer always speak DEL, matching what marked
338
454
  // itself emits.
339
- out += '~~' + walkChildren(node.childNodes, unsupported).md + '~~';
340
- lastMarkTag = null;
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;
341
458
  firstSegment = false;
342
459
  continue;
343
460
  }
@@ -352,19 +469,19 @@
352
469
  // backtick, literal `<u>` typed as plain text is already escaped by
353
470
  // the `<` -> `&lt;` rule above, so it can never collide with a real
354
471
  // toolbar-made underline.
355
- out += '<u>' + walkChildren(node.childNodes, unsupported).md + '</u>';
472
+ out = appendPiece(out, '<u>' + walkChildren(node.childNodes, unsupported).md + '</u>');
356
473
  lastMarkTag = null;
357
474
  firstSegment = false;
358
475
  continue;
359
476
  }
360
477
  if (name === 'CODE') {
361
- out += serializeCode(node);
478
+ out = appendPiece(out, serializeCode(node));
362
479
  lastMarkTag = null;
363
480
  firstSegment = false;
364
481
  continue;
365
482
  }
366
483
  if (name === 'A') {
367
- out += serializeAnchor(node, unsupported);
484
+ out = appendPiece(out, serializeAnchor(node, unsupported));
368
485
  lastMarkTag = null;
369
486
  firstSegment = false;
370
487
  continue;
@@ -402,7 +519,7 @@
402
519
  // reach this code.
403
520
  const hardBreak = typeof node.getAttribute === 'function' &&
404
521
  node.getAttribute('data-hard-break') === '1';
405
- out += hardBreak ? '\\\n' : '<br>';
522
+ out = appendPiece(out, hardBreak ? '\\\n' : '<br>');
406
523
  lastMarkTag = null;
407
524
  firstSegment = false;
408
525
  continue;
@@ -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
  }
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.5",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, Graphviz, and draw.io support",
5
5
  "keywords": [
6
6
  "markdown",