@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.
- package/lib/editor/inline-md.js +268 -23
- package/lib/editor/paste-md.js +41 -1
- package/lib/editor/server.js +7 -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,14 @@
|
|
|
148
154
|
return inner.indexOf(']') === -1;
|
|
149
155
|
}
|
|
150
156
|
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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 `<` -> `<` 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
|
-
|
|
479
|
-
|
|
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
|
-
|
|
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
|
});
|
package/lib/editor/paste-md.js
CHANGED
|
@@ -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(/(
|
|
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
|
|
package/lib/editor/server.js
CHANGED
|
@@ -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` +
|
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) {
|