@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.
- package/lib/editor/inline-md.js +157 -32
- package/lib/editor/paste-md.js +135 -0
- package/lib/editor/table-md.js +58 -25
- package/package.json +1 -1
package/lib/editor/inline-md.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 + ' ' +
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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 `<` -> `<` rule above, so it can never collide with a real
|
|
354
477
|
// toolbar-made underline.
|
|
355
|
-
out
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
package/lib/editor/paste-md.js
CHANGED
|
@@ -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/lib/editor/table-md.js
CHANGED
|
@@ -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
|
-
//
|
|
74
|
-
//
|
|
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
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
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: '|' is never
|
|
128
|
+
// decoded inside a code span, so `` `a|b` `` would come back as `a|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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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
|
-
//
|
|
163
|
-
//
|
|
193
|
+
// Source THIS serializer produced holds '\|' only inside code spans (plain
|
|
194
|
+
// text gets '|'), 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
|
-
|
|
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
|
-
|
|
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) {
|