@helping-ai-workflow/md2doc 3.3.0 → 3.4.0

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/md2doc.js CHANGED
@@ -20,11 +20,65 @@
20
20
 
21
21
  const fs = require('fs');
22
22
  const path = require('path');
23
+ const drawio = require('./drawio.js');
23
24
 
24
25
  // How long to let client-side WaveDrom / Mermaid scripts render before we
25
26
  // snapshot the DOM (PDF print, or --bake-svg inert-SVG bake).
26
27
  const DIAGRAM_RENDER_WAIT_MS = 2500;
27
28
 
29
+ // v3.4.0 batch2 final review R9 — the deferred-diagram placeholders.
30
+ //
31
+ // A placeholder is written by the synchronous marked() pass and read back by
32
+ // an async post-pass roughly four thousand lines away in this same file
33
+ // (bakeDrawio / bakeGraphviz). The two sites used to agree only by
34
+ // character-for-character attribute ordering, and nothing failed if they
35
+ // drifted: a matcher that no longer matches collects zero jobs, the post-pass
36
+ // returns the string untouched, and the placeholder — which carries the
37
+ // base64 of the WHOLE source .drawio file or dot block — ships inside an
38
+ // empty div. Silent, and invisible to every test that only asserts on the
39
+ // baked shape.
40
+ //
41
+ // So the emitter and the matcher are both generated from one ordered field
42
+ // list. Adding, renaming or reordering an attribute changes both at once;
43
+ // there is no edit that can move one without the other.
44
+ //
45
+ // Two rules the field list relies on:
46
+ // - every value must already be safe inside a double-quoted attribute —
47
+ // base64 for anything free-form (the source, the alt text), digits for
48
+ // the page index;
49
+ // - a field is never omitted. A field with nothing to say carries the empty
50
+ // string, so the matcher's arity is fixed and "this one had no alt text"
51
+ // cannot degrade into a no-match.
52
+ function makePlaceholderShape(className, fields) {
53
+ const open = `<div class="${className}" `;
54
+ const quoteRe = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, (c) => '\\' + c);
55
+ return {
56
+ // `probe` is the cheap "is there anything to do at all?" test the bake
57
+ // passes run before doing any work. Derived from the first field rather
58
+ // than spelled out again, for the same no-drift reason as `re`.
59
+ probe: new RegExp(quoteRe(fields[0][0]) + '='),
60
+ html(values) {
61
+ return open + fields.map((f, i) => `${f[0]}="${values[i]}"`).join(' ') + '></div>';
62
+ },
63
+ re: new RegExp(
64
+ quoteRe(open) + fields.map((f) => quoteRe(f[0]) + '="' + f[1] + '"').join(' ') + '><\\/div>',
65
+ 'g'),
66
+ };
67
+ }
68
+
69
+ const DRAWIO_PLACEHOLDER = makePlaceholderShape('drawio', [
70
+ ['data-drawio-src', '([^"]*)'],
71
+ ['data-drawio-page', '(\\d+)'],
72
+ // v3.4.0 batch2 final review R5 — the markdown author's alt text, base64 of
73
+ // the already-HTML-escaped string marked hands us, so it survives the trip
74
+ // to bakeDrawio() and becomes the baked box's accessible name.
75
+ ['data-drawio-alt', '([^"]*)'],
76
+ ]);
77
+
78
+ const GRAPHVIZ_PLACEHOLDER = makePlaceholderShape('graphviz', [
79
+ ['data-graphviz-src', '([^"]*)'],
80
+ ]);
81
+
28
82
  function firstExistingPath(candidates) {
29
83
  for (const candidate of candidates) {
30
84
  if (!candidate) {
@@ -321,6 +375,29 @@ async function renderMarkdown(mdText, srcPath, opts = {}) {
321
375
  const src = srcPath;
322
376
  let usesMermaid = false;
323
377
  let usesWaveDrom = false;
378
+ let usesDrawio = false;
379
+ // Set only when some drawio reference actually resolves to 2+ pages — the
380
+ // sheet bar (CSS + runtime) is gated on this, not on usesDrawio alone, so a
381
+ // single-page .drawio file never pays for multi-page UI.
382
+ let usesDrawioMultiPage = false;
383
+ // v3.4.0 batch2 Task 6: every `.drawio`/`.xml` source path this render
384
+ // actually resolved and read, so a caller (lib/editor/server.js) can stat
385
+ // them later to notice an external edit — resolveAssetPath()'s result was
386
+ // previously a pure local inside drawioPlaceholderFor(), never surviving
387
+ // past the base64 it gets embedded as (see the investigation note this
388
+ // task shipped with). A Map, not an array, so the same referenced file
389
+ // linked from two different images is only stat'd once.
390
+ //
391
+ // Fix round 1 (review F4): the VALUE is the file's stamp taken here, at
392
+ // read time, not later by the caller. bakeDrawio() launches headless
393
+ // Chromium and bakes every page after this function returns — seconds, not
394
+ // microseconds, on first use — so a stamp taken by the caller AFTER the
395
+ // bake records bytes that were never baked, and every later comparison
396
+ // then answers "up to date" about a revision nobody ever rendered. Taken
397
+ // BEFORE the readFileSync below for the same reason in miniature: a stamp
398
+ // that is older than the bytes we baked costs one redundant re-render,
399
+ // while one that is newer loses the change permanently.
400
+ const drawioRefs = new Map();
324
401
  let usesMath = false;
325
402
  let blocks = null;
326
403
  // ── Local image assets ───────────────────────────────────────────────────────
@@ -436,6 +513,68 @@ async function renderMarkdown(mdText, srcPath, opts = {}) {
436
513
  return uri ? uri + fragment : null;
437
514
  }
438
515
 
516
+ // v3.4.0 §4. drawio (.drawio / .xml) goes down the graphviz-style path — a
517
+ // placeholder carrying the source, baked to inline SVG by an async post-pass
518
+ // — instead of image inlining. Returns the placeholder markup, or null to
519
+ // mean "not a drawio reference, fall through to the normal image path"
520
+ // (missing file, non-drawio content, or an extension we don't touch at all).
521
+ // Must run BEFORE inlineImageSrc(): that function warnOnce()s "not a known
522
+ // image extension" for anything outside IMAGE_MIME_BY_EXT, and .drawio/.xml
523
+ // are not image extensions — by the time control reached inlineImageSrc it
524
+ // would already be too late.
525
+ function drawioPlaceholderFor(href, altText) {
526
+ if (!href || isExternalRef(href)) return null;
527
+ const { filePart, fragment } = splitAssetRef(href);
528
+ if (!filePart) return null;
529
+ const ext = path.extname(filePart).toLowerCase();
530
+ if (ext !== '.drawio' && ext !== '.xml') return null;
531
+
532
+ const abs = resolveAssetPath(filePart);
533
+ if (!abs) return null; // No such file: let the ordinary image path warn.
534
+
535
+ // Stamp BEFORE the read — see the drawioRefs comment above.
536
+ const stamp = drawio.drawioStampOf(abs);
537
+ let xml;
538
+ try { xml = fs.readFileSync(abs, 'utf8'); }
539
+ catch (e) { return null; }
540
+
541
+ if (!drawio.isDrawioXml(xml)) {
542
+ // Extension matched but the content didn't — worth a warning for
543
+ // .drawio (that extension promises mxfile/mxGraphModel content), but
544
+ // not for .xml (most .xml files are not diagrams, so silently falling
545
+ // through to the normal image/HTML path is the right default).
546
+ if (ext === '.drawio') {
547
+ warnOnce(href, `[WARN] .drawio file is not mxfile/mxGraphModel, left as-is: ${href}`);
548
+ }
549
+ return null;
550
+ }
551
+
552
+ usesDrawio = true;
553
+ drawioRefs.set(abs, stamp);
554
+ const names = drawio.pageNamesOf(xml);
555
+ if (names.length >= 2) usesDrawioMultiPage = true;
556
+ // splitAssetRef() keeps the leading '#' (it's re-appended verbatim to a
557
+ // data: URI elsewhere), but resolvePageIndex() expects the bare page
558
+ // name/number — strip it here rather than teach resolvePageIndex two
559
+ // input shapes.
560
+ const pageFragment = fragment ? fragment.slice(1) : '';
561
+ const pageIndex = drawio.resolvePageIndex(names, pageFragment);
562
+ const b64 = Buffer.from(xml, 'utf8').toString('base64');
563
+ // v3.4.0 batch2 final review R5 — carry the author's alt text through to
564
+ // bakeDrawio(), which turns it into the baked box's accessible name. It
565
+ // travels base64-encoded for the same reason the source does: the shared
566
+ // placeholder shape's contract is that every value is already safe inside
567
+ // a double-quoted attribute, and an alt text can contain anything.
568
+ //
569
+ // `altText` is marked's `token.text`, which is ALREADY HTML-escaped
570
+ // (MEASURED on marked 14: `![A "q" & <b>](x)` arrives as
571
+ // `A &quot;q&quot; &amp; &lt;b&gt;`) — the same string the ordinary
572
+ // `<img alt="${text}">` path below interpolates raw. So it must not be
573
+ // escaped again here or on the way out.
574
+ const altB64 = Buffer.from(String(altText || ''), 'utf8').toString('base64');
575
+ return '\n' + DRAWIO_PLACEHOLDER.html([b64, pageIndex, altB64]) + '\n';
576
+ }
577
+
439
578
  // A srcset value is a comma-separated list of `<url> <descriptor>` candidates.
440
579
  function inlineSrcsetValue(value) {
441
580
  const parts = String(value).split(',');
@@ -588,6 +727,8 @@ ${itemsHtml}
588
727
  </ul>`;
589
728
  }
590
729
 
730
+ // 契約:一律回傳純文字,永遠不帶 HTML 實體。呼叫端(TOC 標籤、
731
+ // slugifyHeading、startSection)三者都假設這一點。
591
732
  function flattenTokenText(tokens) {
592
733
  if (!Array.isArray(tokens)) {
593
734
  return '';
@@ -598,12 +739,12 @@ ${itemsHtml}
598
739
  return flattenTokenText(item.tokens);
599
740
  }
600
741
  if (item.type === 'codespan') {
601
- return item.text || '';
742
+ return unescapeHtml(item.text || '');
602
743
  }
603
744
  if (item.tokens) {
604
745
  return flattenTokenText(item.tokens);
605
746
  }
606
- return item.text || '';
747
+ return unescapeHtml(item.text || '');
607
748
  })
608
749
  .join('');
609
750
  }
@@ -667,8 +808,21 @@ ${itemsHtml}
667
808
  return classes;
668
809
  }
669
810
 
811
+ // Real HTML tags always have a letter (optionally after a `/`) immediately
812
+ // after `<` — `<x>`, `</div>`, `<br/>`. This deliberately does NOT match
813
+ // `<[^>]*>` generically: once flattenTokenText() started handing back
814
+ // unescaped plain text (v3.4.0 §1), literal author text like "C < D > E"
815
+ // would otherwise look like a tag and get silently eaten here.
816
+ //
817
+ // HTML comments (`<!-- ... -->`) don't start with a letter either, so they
818
+ // need their own alternative — without it a heading like
819
+ // "## Section <!-- note --> Title" leaks "note" into the slug
820
+ // (v3.4.0 Task 1 fix round 1). Known gap, deliberately left open: this
821
+ // does NOT special-case `<!DOCTYPE ...>` or `<? ... ?>` (processing
822
+ // instructions) — same non-letter-after-`<` shape, but neither has shown
823
+ // up in a real heading/prose fixture. Add an alternative here if one does.
670
824
  function stripHtmlTags(value) {
671
- return String(value || '').replace(/<[^>]*>/g, '');
825
+ return String(value || '').replace(/<!--[\s\S]*?-->|<\/?[a-zA-Z][^<>]*>/g, '');
672
826
  }
673
827
 
674
828
  function slugifyHeading(value) {
@@ -694,6 +848,19 @@ ${itemsHtml}
694
848
  .replace(/'/g, '&#39;');
695
849
  }
696
850
 
851
+ // escapeHtml() 的精確逆函式。marked 的 inline lexer 會把 text / codespan
852
+ // token 的 `text` 欄位跳脫過再交出來,而 heading token 自己的 `text` 是
853
+ // 原始的 —— flattenTokenText() 用這一支把兩邊拉回同一個狀態。
854
+ // &amp; 必須最後解:先解它會讓 `&amp;lt;` 變成 `<` 而不是 `&lt;`。
855
+ function unescapeHtml(value) {
856
+ return String(value || '')
857
+ .replace(/&lt;/g, '<')
858
+ .replace(/&gt;/g, '>')
859
+ .replace(/&quot;/g, '"')
860
+ .replace(/&#39;/g, "'")
861
+ .replace(/&amp;/g, '&');
862
+ }
863
+
697
864
  // marked's tokenizer already escapes alt / title (its default image renderer
698
865
  // interpolates both raw), so this must NOT escape them again. Only the src is
699
866
  // ours; everything else — including cleanUrl() on a src we leave alone — stays
@@ -704,6 +871,13 @@ ${itemsHtml}
704
871
  const href = isObj ? (token.href || '') : (token || '');
705
872
  const title = isObj ? (token.title || '') : (arguments[1] || '');
706
873
  const text = isObj ? (token.text || '') : (arguments[2] || '');
874
+
875
+ // v3.4.0 §4: drawio 走 graphviz 那條路(建置時烤成內嵌 SVG),不走圖片
876
+ // 內嵌。判別看的是檔案的根元素而不是副檔名 —— ![](foo.xml) 只有在內容
877
+ // 真的是 mxfile / mxGraphModel 時才是圖,否則原樣落到下面既有的路徑。
878
+ const drawioOut = drawioPlaceholderFor(href, text);
879
+ if (drawioOut) return drawioOut;
880
+
707
881
  const uri = inlineImageSrc(href);
708
882
  if (!uri) return baseImage.apply(this, arguments);
709
883
  // A data: URI is base64 (or an already-encoded payload): no quote, no '<'.
@@ -774,7 +948,7 @@ ${itemsHtml}
774
948
  // base64 in a data attribute — safe for arbitrary dot syntax (quotes,
775
949
  // angle brackets, newlines) inside an HTML attribute.
776
950
  const b64 = Buffer.from(code, 'utf8').toString('base64');
777
- return `\n<div class="graphviz" data-graphviz-src="${b64}"></div>\n`;
951
+ return '\n' + GRAPHVIZ_PLACEHOLDER.html([b64]) + '\n';
778
952
  }
779
953
  // Default: syntax-highlighted code block
780
954
  const escaped = code.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
@@ -932,6 +1106,20 @@ ${itemsHtml}
932
1106
  // falls entirely inside one part, so this is byte-identical to baking the
933
1107
  // joined string.
934
1108
  parts = await Promise.all(parts.map(bakeGraphviz));
1109
+ // v3.4.0 §4: 與 graphviz 同一個理由 —— 逐 part 烤,不是 join 之後才烤。
1110
+ // patch 路徑會把 parts[i] 直接插進 DOM,而用戶端沒有 drawio 渲染器
1111
+ // (__md2docInitDiagrams 只處理 mermaid 與 WaveDrom),沒烤的 part 會
1112
+ // 變成一個空的 <div class="drawio">。與 bakeGraphviz 不同的是 drawio
1113
+ // 需要一顆 headless Chromium,所以逐 part 依序(而非並行)烤,讓所有
1114
+ // part 共用同一顆延遲啟動的 browser,一份文件最多開一次。
1115
+ const drawioBrowser = makeLazyBrowserRef();
1116
+ try {
1117
+ for (let i = 0; i < parts.length; i++) {
1118
+ parts[i] = await bakeDrawio(parts[i], drawioBrowser);
1119
+ }
1120
+ } finally {
1121
+ await drawioBrowser.closeIfOpen();
1122
+ }
935
1123
  bodyHtml = parts.join('\n');
936
1124
  } else {
937
1125
  bodyHtml = marked.parse(mdPre);
@@ -1428,7 +1616,164 @@ ${itemsHtml}
1428
1616
  it against the textarea's own line numbers instead is a separate
1429
1617
  feasibility question, not answered here. */
1430
1618
  body[data-ed-mode="source"] .toc,
1431
- body[data-ed-mode="source"] .search-results { display: none !important; }`;
1619
+ body[data-ed-mode="source"] .search-results { display: none !important; }
1620
+
1621
+ /* ── v3.4.0 batch3 Task 6: the wavedrom editing surface ───────────────
1622
+ Both of these are mounted on document.body by lib/editor/client.js, and
1623
+ that is not a styling choice: .content's children are what the client
1624
+ reads back as its own render and what the WYSIWYG path serialises into
1625
+ the user's markdown, so a panel or a button parked in there ends up in
1626
+ their file. Everything below is therefore positioned against the
1627
+ viewport rather than against .content.
1628
+
1629
+ z-index 998 sits UNDER .ed-conflict (999) on purpose — a save conflict
1630
+ has to stay reachable even while the wave editor is open — and over the
1631
+ toolbar's own band, because the editor is modal over the document. */
1632
+ .ed-wave-edit-btn {
1633
+ position: fixed; z-index: 12; padding: 3px 10px; font-size: 12px;
1634
+ border: 1px solid #bbb; border-radius: 4px; background: #ffffffee;
1635
+ color: #222; cursor: pointer; box-shadow: 0 1px 3px rgba(0,0,0,.2);
1636
+ }
1637
+ .ed-wave-edit-btn:hover { background: #fff; border-color: #888; }
1638
+ .ed-wave-overlay {
1639
+ position: fixed; inset: 0; z-index: 998; background: rgba(0,0,0,.45);
1640
+ display: flex; align-items: center; justify-content: center;
1641
+ }
1642
+ .ed-wave-panel {
1643
+ background: #fff; border-radius: 6px; width: min(1200px, 94vw);
1644
+ height: min(760px, 90vh); display: flex; flex-direction: column;
1645
+ box-shadow: 0 6px 28px rgba(0,0,0,.35); overflow: hidden;
1646
+ }
1647
+ .ed-wave-head {
1648
+ display: flex; align-items: center; justify-content: space-between;
1649
+ padding: 8px 12px; border-bottom: 1px solid #e2e2e2; font-weight: bold;
1650
+ }
1651
+ .ed-wave-close {
1652
+ border: 1px solid #bbb; border-radius: 4px; background: #f6f6f6;
1653
+ padding: 3px 12px; cursor: pointer;
1654
+ }
1655
+ /* Which way out keeps the work and which throws it away. Escape is a modal's
1656
+ universal close key and here it discards a whole session, so the header
1657
+ says so next to the button that does not. Normal weight against the bar's
1658
+ bold heading: it is a caption, not a warning the user has to clear. */
1659
+ .ed-wave-escape-hint {
1660
+ margin-left: auto; margin-right: 12px; font-weight: normal;
1661
+ font-size: 11px; color: #7a4b00;
1662
+ }
1663
+ .ed-wave-toolbar {
1664
+ display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
1665
+ padding: 8px 12px; border-bottom: 1px solid #e2e2e2; background: #fafafa;
1666
+ }
1667
+ .ed-wave-brushes, .ed-wave-cycleops, .ed-wave-meta {
1668
+ display: flex; flex-wrap: wrap; gap: 4px; align-items: center;
1669
+ }
1670
+ .ed-wave-brush {
1671
+ width: 28px; height: 28px; border: 1px solid #c4c4c4; border-radius: 4px;
1672
+ background: #fff; font-family: monospace; font-size: 14px; cursor: pointer;
1673
+ }
1674
+ .ed-wave-brush.is-on { background: #1a73e8; color: #fff; border-color: #1a73e8; }
1675
+ .ed-wave-cycleops button, .ed-wave-toolbar button {
1676
+ border: 1px solid #c4c4c4; border-radius: 4px; background: #fff;
1677
+ padding: 3px 8px; font-size: 12px; cursor: pointer;
1678
+ }
1679
+ .ed-wave-toolbar button:disabled { opacity: .45; cursor: default; }
1680
+ .ed-wave-metafield { display: flex; align-items: center; gap: 4px; font-size: 12px; }
1681
+ .ed-wave-metafield input { width: 110px; font-size: 12px; padding: 2px 4px; }
1682
+ /* The "this drawing is not showing you everything" notice. period / phase /
1683
+ config.hscale all change what the ENGINE paints and none of them is
1684
+ modelled by the hand drawing, so a document carrying one says so instead
1685
+ of drawing a confident wrong picture. Full row, so it cannot be mistaken
1686
+ for another toolbar control. */
1687
+ .ed-wave-unmodelled {
1688
+ flex: 1 1 100%; font-size: 12px; color: #7a4b00; background: #fff6e0;
1689
+ border: 1px solid #e8c987; border-radius: 4px; padding: 4px 8px;
1690
+ }
1691
+ /* The stronger of the two notices: not "this drawing is incomplete" but
1692
+ "this block has nothing this editor can edit" (a reg: / assign: block).
1693
+ Same shape as its neighbour so the two read as one family, one step
1694
+ louder because the information gap is bigger. */
1695
+ .ed-wave-nolanes { color: #7a1f00; background: #ffe9e0; border-color: #e8a987; }
1696
+ .ed-wave-body { display: flex; flex: 1 1 auto; min-height: 0; }
1697
+ /* Widths chosen so the DRAWING still gets room at the narrowest viewport this
1698
+ repo's browser tests use (800x600 -> panel 752): 752 - 200 - 160 = 392px,
1699
+ which is 8 cycles at .ed-wave-canvas's 48px pitch. The canvas is a scroll
1700
+ container, so a wider diagram scrolls rather than pushing the preview off —
1701
+ but a canvas column narrower than a couple of cycles would make the drag
1702
+ gesture unusable, and that was the first thing measured to go wrong here
1703
+ (a press landed on the preview column, 116px past the canvas's clip). */
1704
+ .ed-wave-lanes {
1705
+ flex: 0 0 200px; border-right: 1px solid #e2e2e2; overflow: auto;
1706
+ padding: 8px 0 8px 0;
1707
+ }
1708
+ /* The rows are absolutely positioned at index * laneHeight so they line up
1709
+ with the drawing beside them cell for cell. A group title is NOT a row —
1710
+ measured against the pinned engine in Task 2 — so it is drawn as a label in
1711
+ the rail spanning its lanes' rows instead, which is the shape the engine
1712
+ itself gives it (horizontal space in the name column, no vertical space). */
1713
+ .ed-wave-lane-rows { position: relative; }
1714
+ .ed-wave-lane-row {
1715
+ position: absolute; left: 0; right: 0; display: flex; align-items: center;
1716
+ gap: 3px; padding-right: 6px; box-sizing: border-box;
1717
+ }
1718
+ .ed-wave-lane-row.is-selected { background: #eaf2fe; }
1719
+ .ed-wave-lane-name { flex: 1 1 auto; min-width: 0; font-size: 12px; padding: 2px 4px; }
1720
+ .ed-wave-lane-row button {
1721
+ border: 1px solid #cfcfcf; border-radius: 3px; background: #fff;
1722
+ font-size: 11px; line-height: 1; padding: 3px 4px; cursor: pointer;
1723
+ }
1724
+ .ed-wave-lane-row button:disabled { opacity: .4; cursor: default; }
1725
+ .ed-wave-lane-add-tail { position: absolute; left: 6px; }
1726
+ .ed-wave-group, .ed-wave-group-input {
1727
+ position: absolute; width: 14px; padding: 0; font-size: 11px;
1728
+ writing-mode: vertical-rl; text-orientation: mixed; overflow: hidden;
1729
+ border: 1px solid #d8d8d8; border-radius: 3px; background: #f2f2f2;
1730
+ color: #555; cursor: pointer; box-sizing: border-box;
1731
+ }
1732
+ .ed-wave-group-input { width: 16px; height: 90px; cursor: text; }
1733
+ .ed-wave-canvas-wrap { flex: 1 1 auto; overflow: auto; padding: 8px; }
1734
+ .ed-wave-canvas { display: block; }
1735
+ .ed-wave-grid { stroke: #ececec; stroke-width: 1; }
1736
+ .ed-wave-line, .ed-wave-edge { stroke: #111; stroke-width: 2; fill: none; }
1737
+ .ed-wave-weak { stroke-dasharray: 4 3; }
1738
+ .ed-wave-x { fill: #d9d9d9; stroke: #111; stroke-width: 2; }
1739
+ .ed-wave-bus { fill: #f5e9c8; stroke: #111; stroke-width: 2; }
1740
+ .ed-wave-bus-3 { fill: #d8ead3; }
1741
+ .ed-wave-bus-4 { fill: #d6e4f7; }
1742
+ .ed-wave-bus-5 { fill: #f7d6e4; }
1743
+ .ed-wave-buslabel { font-size: 11px; fill: #222; }
1744
+ .ed-wave-selection { fill: rgba(26,115,232,.18); stroke: #1a73e8; stroke-width: 1; }
1745
+ /* The KEYBOARD's own cell, and deliberately not the same mark as the
1746
+ selection: the selection is the RANGE a cycle operation acts on, the
1747
+ cursor is the ONE cell the next keystroke paints, and after Shift+left /
1748
+ right both are on screen at once. pointer-events: none so the rect can
1749
+ never swallow the press the drag underneath it is made of. */
1750
+ .ed-wave-cursor {
1751
+ fill: none; stroke: #d93025; stroke-width: 2; pointer-events: none;
1752
+ }
1753
+ /* The drawing is in the Tab cycle, so it has to SAY when it holds the
1754
+ keyboard: a cell cursor alone leaves a user who just Tabbed onto it with
1755
+ nothing that distinguishes "the drawing has the keyboard" from "the
1756
+ drawing is where I left a cursor before I walked away". */
1757
+ .ed-wave-canvas:focus { outline: 2px solid #1a73e8; outline-offset: 2px; }
1758
+ /* The discontinuity marker. The engine draws one gap <use> per pipe
1759
+ character; drawing none left the two pictures disagreeing on a lane whose
1760
+ levels agreed exactly. */
1761
+ .ed-wave-gap { stroke: #111; stroke-width: 2; fill: none; }
1762
+ .ed-wave-preview {
1763
+ flex: 0 1 340px; min-width: 160px; border-left: 1px solid #e2e2e2;
1764
+ overflow: auto; padding: 8px;
1765
+ }
1766
+ .ed-wave-preview-label { font-size: 11px; color: #666; margin-bottom: 6px; }
1767
+ .ed-wave-status {
1768
+ border-top: 1px solid #e2e2e2; padding: 6px 12px; font-size: 12px;
1769
+ color: #444; min-height: 18px;
1770
+ }
1771
+ .ed-wave-parse-error { padding: 16px; }
1772
+ .ed-wave-parse-message { color: #b00020; font-weight: bold; }
1773
+ .ed-wave-parse-excerpt {
1774
+ background: #f6f6f6; border: 1px solid #e2e2e2; padding: 6px;
1775
+ overflow-x: auto; font-family: monospace; font-size: 12px;
1776
+ }`;
1432
1777
 
1433
1778
  const html = `<!DOCTYPE html>
1434
1779
  <html lang="en">
@@ -1576,6 +1921,24 @@ ${itemsHtml}
1576
1921
  z-index: 98;
1577
1922
  }
1578
1923
  body[data-sidebar-open] .sidebar-scrim { display: block; }
1924
+ /* The scroll LOCK for this same attribute lives inside the
1925
+ '@media (max-width: 1080px)' block below, next to
1926
+ 'body[data-sidebar-open] .reader-sidebar { transform: translateX(0); }'
1927
+ — deliberately NOT here at base scope, unlike the rule immediately
1928
+ above. Reason (Ruling I1, review round 2): the mobile-drawer meaning of
1929
+ data-sidebar-open, including .sidebar-scrim's own display:block rule
1930
+ just above, already leaks past its breakpoint by design (see the
1931
+ ⚠ comment above '@media (min-width: 1081px) { body[data-ed-outline-hidden]
1932
+ … }' for why the desktop 大綱 toggle needed its OWN attribute instead of
1933
+ reusing this one — reusing it once already painted the whole desktop
1934
+ viewport grey and click-dead). Nothing here clears data-sidebar-open on
1935
+ resize (grep this file's two resize listeners — scroll anchor and
1936
+ lightbox fit — neither touches it), so a lock at base scope would ALSO
1937
+ survive: open the drawer narrow, widen the window, and the desktop page
1938
+ would go unscrollable on top of the pre-existing grey scrim. The grey
1939
+ scrim is that already-accepted gap; an unscrollable desktop page is not
1940
+ — it is new, and scoping the lock to the same breakpoint the drawer
1941
+ itself is scoped to is what keeps it from outliving the drawer. */
1579
1942
  .reader-tools,
1580
1943
  .search-results,
1581
1944
  .toc {
@@ -1997,7 +2360,29 @@ ${itemsHtml}
1997
2360
  hr { border: none; border-top: 1px solid #e1e4e8; margin: 24px 0; }
1998
2361
  .mermaid { text-align: center; margin: 20px 0; }
1999
2362
  .graphviz { text-align: center; margin: 20px 0; }
2000
- .content img {
2363
+ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pay for this —
2364
+ conditional injection, same pattern as usesWaveDrom / usesMath below.
2365
+ .drawio needs position: relative because the (conditionally injected,
2366
+ see below) sheet bar is its absolute child — same relationship as
2367
+ .lightbox-stage / .lightbox-canvas, see CLAUDE.md. */
2368
+ .drawio { text-align: center; margin: 20px 0; position: relative; }
2369
+ .drawio svg { max-width: 100%; height: auto; }
2370
+ .drawio-page[hidden] { display: none !important; }
2371
+ .drawio-failed { color: #b91c1c; }
2372
+ ` : ''}${usesDrawioMultiPage ? ` /* Sheet bar: only when a document actually has a multi-page drawio
2373
+ diagram — a single-page file must not pay for it either. */
2374
+ .drawio-sheetbar {
2375
+ position: absolute; left: 50%; transform: translateX(-50%); bottom: 4px;
2376
+ display: none; gap: 4px; padding: 4px 6px; border-radius: 6px;
2377
+ background: rgba(255,255,255,.92); box-shadow: 0 1px 4px rgba(0,0,0,.18);
2378
+ }
2379
+ .drawio:hover .drawio-sheetbar { display: flex; }
2380
+ .drawio-sheetbar button {
2381
+ border: 0; background: transparent; cursor: pointer;
2382
+ font: inherit; padding: 2px 8px; border-radius: 4px;
2383
+ }
2384
+ .drawio-sheetbar button[aria-pressed="true"] { background: #dbeafe; }
2385
+ ` : ''} .content img {
2001
2386
  max-width: 100%;
2002
2387
  height: auto;
2003
2388
  }
@@ -2014,6 +2399,7 @@ ${itemsHtml}
2014
2399
  .content img,
2015
2400
  .content .mermaid,
2016
2401
  .content .graphviz,
2402
+ .content .drawio,
2017
2403
  .content [id^="WaveDrom_Display_"],
2018
2404
  .content .wavedrom-diagram { cursor: zoom-in; }
2019
2405
  .content a img { cursor: pointer; }
@@ -2179,6 +2565,21 @@ ${itemsHtml}
2179
2565
  flex: initial;
2180
2566
  }
2181
2567
  body[data-sidebar-open] .reader-sidebar { transform: translateX(0); }
2568
+ /* Scroll lock for the drawer, same two-rule shape the lightbox uses a
2569
+ few lines above (body[data-lightbox-open] / html[data-lightbox-open])
2570
+ and for the identical reason: 'html, body { overflow-x: clip }' keeps
2571
+ documentElement's own overflow-y at the visible default, so it stays
2572
+ the scrolling element regardless of what 'body' says — a body-only
2573
+ rule leaves the page scrollable behind the scrim. Was missing
2574
+ entirely before this task (a missing lock, not a defeated one). Scoped
2575
+ to THIS breakpoint on purpose — see the comment above
2576
+ '.sidebar-scrim { display: none; … }' near the top of this stylesheet
2577
+ for why base scope would leak an unscrollable desktop page past a
2578
+ resize, since nothing clears data-sidebar-open on one. JS mirrors
2579
+ data-sidebar-open onto documentElement in setSidebarOpen(), matching
2580
+ openLightbox/closeLightbox. */
2581
+ body[data-sidebar-open] { overflow: hidden; }
2582
+ html[data-sidebar-open] { overflow: hidden; }
2182
2583
  .sidebar-splitter { display: none; }
2183
2584
  .heading-anchor { opacity: 1; }
2184
2585
  }
@@ -2639,7 +3040,31 @@ ${itemsHtml}
2639
3040
  box-sizing: border-box; height: var(--ed-toolbar-h);
2640
3041
  display: flex; align-items: center; justify-content: flex-start;
2641
3042
  flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden;
2642
- gap: 6px; padding: 8px 12px;
3043
+ /* v3.4.0 §3: gap was 6px until the save button (id 'save', file group)
3044
+ landed at the FRONT of the row. Ruling T11-2 (below) requires the
3045
+ outline (☰) button's own centre point to hit-test successfully at
3046
+ 800/1080px WITHOUT any scrolling — it is the only way to open the
3047
+ sidebar drawer in edit mode, and that test deliberately does not
3048
+ scroll first. A leading addition shifts every button after it right
3049
+ by its own width; MEASURED at 800px wide before this change: 22
3050
+ buttons put outline's centre at x=795 (row content 975px, overflow
3051
+ 175px) — already only a 5px margin inside the 800px viewport. Adding
3052
+ save (23 buttons, row content 1026px, overflow 226px) pushed it to
3053
+ x=846, entirely off screen (elementFromPoint at that point returns
3054
+ null — off-screen, not occluded by another element; contrast the
3055
+ .ed-toolbar-status overlap a few lines down, which returns the
3056
+ covered BUTTON because that slot has pointer-events: none, an
3057
+ entirely different failure shape). This gap value was chosen
3058
+ empirically (a one-off puppeteer script, not a formula — flex gap
3059
+ plus intrinsic button/separator widths do not sum cleanly by hand):
3060
+ re-measured at 800px, outline's centre lands at x=765, a 35px margin
3061
+ — comfortably more headroom than the original 5px, not just barely
3062
+ restored. Also re-verified against the F12 "narrow viewport,
3063
+ scrolling allowed" sweep just below (420/640/820px): every button,
3064
+ save included, still reaches an empty unreachable list and an empty
3065
+ neverClear list at all three — this change does not trade the T11-2
3066
+ no-scroll guarantee for the F12 with-scroll one. */
3067
+ gap: 3px; padding: 8px 12px;
2643
3068
  /* F12, both halves measured on this build at 900px viewport height.
2644
3069
  justify-content was center. Centering an overflowing flex row splits
2645
3070
  the overflow across BOTH ends, and the leading end of a scroll
@@ -2805,6 +3230,18 @@ ${itemsHtml}
2805
3230
  font: inherit; font-size: 13px; line-height: 1.4; cursor: pointer;
2806
3231
  }
2807
3232
  .ed-toolbar-menu-btn:hover { background: rgba(255, 255, 255, 0.18); }
3233
+ /* backlog #7 (Task 9 review, M1): the panel's OWN keyboard cursor
3234
+ (lib/editor/client.js's toolbarMenuIndex / data-ed-tb-menu-cursor) had
3235
+ no matching rule at all — the fix made the six items operable by
3236
+ keyboard but left the cursor invisible, the exact backlog #10 class of
3237
+ defect ("can act, but cannot see where"). Same outline idiom as
3238
+ .ed-toolbar-btn[data-ed-tb-cursor] above, for the same reason (legible
3239
+ over :hover's own background, and over nothing at all when neither
3240
+ applies). */
3241
+ .ed-toolbar-menu-btn[data-ed-tb-menu-cursor] {
3242
+ outline: 2px solid #6ea8fe; outline-offset: -2px;
3243
+ background: rgba(255, 255, 255, 0.18);
3244
+ }
2808
3245
  /* 追加 4: the whole-document source escape hatch. Replaces the rendered
2809
3246
  content column for the duration of source mode; the document's own
2810
3247
  lines[] is untouched until the mode is left (see docsource.js). */
@@ -2828,8 +3265,45 @@ ${itemsHtml}
2828
3265
  }
2829
3266
  .ed-commit { color: #0a7a0a; }
2830
3267
  .ed-cancel { color: #b00020; }
3268
+ /* backlog #8: 'top: 0' used to put this banner directly OVER .ed-toolbar
3269
+ (same top-of-viewport band, and z-index 999 against the bar's 101 wins
3270
+ the paint) — MEASURED (task-10-toolbar-probe.js): all 23 toolbar
3271
+ buttons' own centre points hit-tested to the banner, not the button,
3272
+ for every .ed-conflict banner showBanner() raises (conflict /
3273
+ render-failed / save-failed / dropped-gesture / structural-refusal /
3274
+ swallow all share this one class — there is exactly one producer, see
3275
+ showBanner() in lib/editor/client.js). Floors at the SAME
3276
+ --ed-toolbar-h token placeTeMenuAboveCell()'s .ed-te-menu already
3277
+ clamps to (if top < topInset, top = r.bottom + …, both in
3278
+ lib/editor/client.js) — rather than inventing a new offset.
3279
+ Final-review I1 correction: an earlier draft of this comment also named
3280
+ positionSelToolbar()'s .ed-seltb here, as if it clamped to this same
3281
+ token. Retracted — MEASURED: positionSelToolbar() reads --ed-toolbar-h
3282
+ only to decide whether a selection scrolled fully out of view should
3283
+ hide the bar entirely (rect.bottom <= topInset); the vertical floor it
3284
+ actually clamps 'top' to is a bare margin = 4 local, independent
3285
+ of the toolbar's height. Swept 66 scroll positions: at 17 of them
3286
+ .ed-seltb's own top measures as low as 14.75px — inside this banner's
3287
+ band — with no --ed-toolbar-h involved (task10-seltb-check-probe.js).
3288
+ Not reproduced with a LIVE banner open at the same time (two attempts:
3289
+ a burst's bold toggle never hits /api/render, and Ctrl+S's
3290
+ switchAwayFrom() tears the bar down before a banner could show) — left
3291
+ as a geometry note in CHANGELOG.md's Known issues rather than a
3292
+ behaviour change.
3293
+ Deliberately NOT a z-index change: the toolbar comment above
3294
+ ("z-index sits ... below .ed-conflict (999): a save conflict must
3295
+ always be reachable") still holds — the banner stays the topmost
3296
+ thing, it just no longer shares the toolbar's own band to be on top
3297
+ OF. showBanner() additionally closes .ed-te-grip / .ed-tb-insert /
3298
+ .ed-te-menu / .ed-toolbar-menu — four dynamic fixed overlays that can
3299
+ each land in that band, though (per the correction above) only
3300
+ .ed-te-menu's own code floors there via --ed-toolbar-h; the others
3301
+ land nearby as a side effect of their own trigger's position, not a
3302
+ shared clamp — see showBanner()'s parallel comment in
3303
+ lib/editor/client.js. Closed regardless, so a banner can never land on
3304
+ top of one of those instead. */
2831
3305
  .ed-conflict {
2832
- position: fixed; top: 0; left: 0; right: 0; padding: 10px;
3306
+ position: fixed; top: var(--ed-toolbar-h); left: 0; right: 0; padding: 10px;
2833
3307
  background: #b00020; color: #fff; z-index: 999; text-align: center;
2834
3308
  display: flex; align-items: center; justify-content: center; gap: 12px;
2835
3309
  }
@@ -3423,8 +3897,10 @@ ${mermaidInitTag}`)
3423
3897
  function setSidebarOpen(open) {
3424
3898
  if (open) {
3425
3899
  document.body.setAttribute('data-sidebar-open', '');
3900
+ document.documentElement.setAttribute('data-sidebar-open', '');
3426
3901
  } else {
3427
3902
  document.body.removeAttribute('data-sidebar-open');
3903
+ document.documentElement.removeAttribute('data-sidebar-open');
3428
3904
  }
3429
3905
  if (sidebarToggle) sidebarToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
3430
3906
  }
@@ -3591,11 +4067,62 @@ ${mermaidInitTag}`)
3591
4067
 
3592
4068
  captureScrollAnchor();
3593
4069
 
4070
+ // ── drawio sheet bar ─────────────────────────────────────────────────────
4071
+ // v3.4.0 §4: switching pages is a pure hidden-attribute toggle. Every page was
4072
+ // rendered to inert SVG at build time (lib/drawio.js's bake pass), so there
4073
+ // is no engine, no diagrams.net script, and no network request here — this
4074
+ // works exactly the same with the network unplugged. Only injected when
4075
+ // usesDrawioMultiPage: a single-page .drawio file must not pay for this UI
4076
+ // at all (matching CSS gate is in the <style> block above).
4077
+ ${usesDrawioMultiPage ? ` // v3.4.0 batch2 Task 6: wired through a NAMED, reusable hook instead of a
4078
+ // bare one-shot forEach, so the edit-mode client runtime can re-invoke it
4079
+ // — scoped to just one freshly swapped-in block — after a background
4080
+ // refresh re-bakes a \`.drawio\`/\`.xml\` source that changed on disk (see
4081
+ // lib/editor/client.js's stale-drawio handler). Still gated on
4082
+ // usesDrawioMultiPage exactly as before: a single-page document must never
4083
+ // pay for (or even mention) this UI at all — a guard test asserts the
4084
+ // whole feature's class name is absent byte-for-byte from that output, so
4085
+ // nothing below this line may ever run, or exist as source text, outside
4086
+ // this branch.
4087
+ window.__md2docInitDrawioSheetBar = function (rootEl) {
4088
+ var scope = rootEl || document;
4089
+ scope.querySelectorAll('.drawio[data-drawio-pages]').forEach(function (box) {
4090
+ if (box.querySelector('.drawio-sheetbar')) return; // already wired
4091
+ var names;
4092
+ try { names = JSON.parse(atob(box.getAttribute('data-drawio-pages') || '')); }
4093
+ catch (e) { return; }
4094
+ if (!Array.isArray(names)) return;
4095
+ if (names.length < 2) return;
4096
+ var pages = box.querySelectorAll('.drawio-page');
4097
+ var bar = document.createElement('div');
4098
+ bar.className = 'drawio-sheetbar';
4099
+ names.forEach(function (name, i) {
4100
+ var b = document.createElement('button');
4101
+ b.type = 'button';
4102
+ b.textContent = name || String(i + 1);
4103
+ b.setAttribute('aria-pressed', pages[i] && !pages[i].hidden ? 'true' : 'false');
4104
+ b.addEventListener('click', function (event) {
4105
+ // The bar lives inside .drawio, which is also a lightbox click
4106
+ // target below (LIGHTBOX_TARGETS) — without stopPropagation a page
4107
+ // switch would also bubble up and pop the lightbox open.
4108
+ event.stopPropagation();
4109
+ for (var j = 0; j < pages.length; j++) pages[j].hidden = j !== i;
4110
+ bar.querySelectorAll('button').forEach(function (x, j) {
4111
+ x.setAttribute('aria-pressed', j === i ? 'true' : 'false');
4112
+ });
4113
+ });
4114
+ bar.appendChild(b);
4115
+ });
4116
+ box.appendChild(bar);
4117
+ });
4118
+ };
4119
+ window.__md2docInitDrawioSheetBar(document);
4120
+ ` : ''}
3594
4121
  // ── Diagram / image lightbox ──────────────────────────────────────────────
3595
4122
  // Specs are read at 100% but their block diagrams and waveforms are drawn far
3596
4123
  // wider than the column, so the inline copy is unreadably small. Clicking one
3597
4124
  // pops it into a modal stage that zooms and scrolls.
3598
- var LIGHTBOX_TARGETS = 'img, .mermaid, .graphviz, [id^="WaveDrom_Display_"], .wavedrom-diagram';
4125
+ var LIGHTBOX_TARGETS = 'img, .mermaid, .graphviz, .drawio, [id^="WaveDrom_Display_"], .wavedrom-diagram';
3599
4126
  var LIGHTBOX_MIN_ZOOM = 0.05;
3600
4127
  var LIGHTBOX_MAX_ZOOM = 8;
3601
4128
  var LIGHTBOX_STEP = 1.25;
@@ -4333,6 +4860,15 @@ ${mermaidInitTag}`)
4333
4860
 
4334
4861
  function lightboxSourceOf(node) {
4335
4862
  if (node.tagName === 'IMG') return node;
4863
+ // A multi-page .drawio bakes every page's <svg> into the DOM (hidden
4864
+ // ones included, so page switching needs no engine) — a plain
4865
+ // querySelector('svg') returns the FIRST one in document order, which is
4866
+ // wrong whenever page 1 isn't the one currently shown. Pick the visible
4867
+ // page's svg instead.
4868
+ if (node.classList && node.classList.contains('drawio')) {
4869
+ var visiblePage = node.querySelector('.drawio-page:not([hidden])');
4870
+ if (visiblePage) return visiblePage.querySelector('svg') || node;
4871
+ }
4336
4872
  return node.querySelector('svg') || node;
4337
4873
  }
4338
4874
 
@@ -4468,12 +5004,34 @@ ${mermaidInitTag}`)
4468
5004
  </body>
4469
5005
  </html>`;
4470
5006
 
4471
- const bakedHtml = await bakeGraphviz(html);
5007
+ const gvHtml = await bakeGraphviz(html);
4472
5008
  // editMode's bodyHtml is already baked per-part above; re-baking here is a
4473
5009
  // short-circuit no-op (bakeGraphviz bails out when there's no
4474
5010
  // data-graphviz-src left). Non-editMode bodyHtml is baked here as before.
4475
- const bakedBody = await bakeGraphviz(bodyHtml);
4476
- return { html: bakedHtml, bodyHtml: bakedBody, parts, blocks };
5011
+ const gvBody = await bakeGraphviz(bodyHtml);
5012
+ // v3.4.0 §4: same short-circuit story for drawio. editMode already baked
5013
+ // every part above, so both calls below are no-ops there (no browser is
5014
+ // opened) — this is only load-bearing for the non-editMode
5015
+ // `bodyHtml = marked.parse(mdPre)` path, which never went through the
5016
+ // per-part loop. `html` and `bodyHtml` share the same placeholders (html
5017
+ // embeds bodyHtml), so both bakes share one lazily-opened browser instead
5018
+ // of paying for two.
5019
+ const drawioBrowser = makeLazyBrowserRef();
5020
+ let bakedHtml, bakedBody;
5021
+ try {
5022
+ bakedHtml = await bakeDrawio(gvHtml, drawioBrowser);
5023
+ bakedBody = await bakeDrawio(gvBody, drawioBrowser);
5024
+ } finally {
5025
+ await drawioBrowser.closeIfOpen();
5026
+ }
5027
+ return {
5028
+ html: bakedHtml, bodyHtml: bakedBody, parts, blocks,
5029
+ // v3.4.0 batch2 Task 6: an added field, not a shape change to the four
5030
+ // existing ones — every call site destructures the specific keys it
5031
+ // wants (checked across lib/editor/server.js and test/*.js), none reads
5032
+ // the object as a whole, so an addition here is backward compatible.
5033
+ drawioRefs: Array.from(drawioRefs, ([p, stamp]) => ({ path: p, stamp })),
5034
+ };
4477
5035
  }
4478
5036
  module.exports = { renderMarkdown };
4479
5037
 
@@ -4481,7 +5039,7 @@ module.exports = { renderMarkdown };
4481
5039
  // in-process WASM engine. Loads the WASM module only when at least one dot
4482
5040
  // block exists, so text-only docs pay nothing.
4483
5041
  async function bakeGraphviz(htmlStr) {
4484
- if (!/data-graphviz-src=/.test(htmlStr)) return htmlStr;
5042
+ if (!GRAPHVIZ_PLACEHOLDER.probe.test(htmlStr)) return htmlStr;
4485
5043
  let Graphviz;
4486
5044
  try {
4487
5045
  ({ Graphviz } = require('@hpcc-js/wasm-graphviz'));
@@ -4490,7 +5048,7 @@ async function bakeGraphviz(htmlStr) {
4490
5048
  return htmlStr;
4491
5049
  }
4492
5050
  const gv = await Graphviz.load();
4493
- return htmlStr.replace(/<div class="graphviz" data-graphviz-src="([^"]*)"><\/div>/g, (m, b64) => {
5051
+ return htmlStr.replace(GRAPHVIZ_PLACEHOLDER.re, (m, b64) => {
4494
5052
  const dotSrc = Buffer.from(b64, 'base64').toString('utf8');
4495
5053
  try {
4496
5054
  const svg = gv.dot(dotSrc)
@@ -4515,6 +5073,111 @@ function launchBrowser(puppeteer) {
4515
5073
  });
4516
5074
  }
4517
5075
 
5076
+ // v3.4.0 §4. A document with no drawio reference must not pay for opening
5077
+ // Chromium. `get()` launches on its first call only (and every later caller
5078
+ // awaits the same promise, so a document with several drawio placeholders
5079
+ // still opens exactly one browser); `closeIfOpen()` closes it only if `get()`
5080
+ // was ever actually called.
5081
+ function makeLazyBrowserRef() {
5082
+ let browserPromise = null;
5083
+ return {
5084
+ get() {
5085
+ if (!browserPromise) {
5086
+ const puppeteer = require('puppeteer');
5087
+ browserPromise = launchBrowser(puppeteer);
5088
+ }
5089
+ return browserPromise;
5090
+ },
5091
+ async closeIfOpen() {
5092
+ if (!browserPromise) return;
5093
+ const browser = await browserPromise;
5094
+ await browser.close();
5095
+ },
5096
+ };
5097
+ }
5098
+
5099
+ // Render every deferred drawio placeholder to inline SVG using a headless
5100
+ // Chromium running the vendored drawio viewer (lib/drawio.js). Every page is
5101
+ // baked, not just the one selected by the markdown fragment (`#PageName`),
5102
+ // so switching pages client-side is a pure `hidden`-attribute toggle with no
5103
+ // engine involved — see lib/drawio.js's own comment on why a Node-side XML
5104
+ // parser can't substitute for the real layout engine.
5105
+ async function bakeDrawio(htmlStr, browserRef) {
5106
+ if (!DRAWIO_PLACEHOLDER.probe.test(htmlStr)) return htmlStr;
5107
+
5108
+ // replace()'s callback can't be async, so collect the jobs first and apply
5109
+ // them (each with its own possible browser-page await) afterwards.
5110
+ const jobs = [];
5111
+ htmlStr.replace(DRAWIO_PLACEHOLDER.re, (m, b64, pageIdx, altB64) => {
5112
+ jobs.push({
5113
+ m,
5114
+ b64,
5115
+ pageIdx: Number(pageIdx),
5116
+ alt: Buffer.from(altB64, 'base64').toString('utf8'),
5117
+ });
5118
+ return m;
5119
+ });
5120
+
5121
+ for (const job of jobs) {
5122
+ const xml = Buffer.from(job.b64, 'base64').toString('utf8');
5123
+ const names = drawio.pageNamesOf(xml);
5124
+ let svgs;
5125
+ try {
5126
+ // One document shares one browser: browserRef.get() only really opens
5127
+ // Chromium on its first call.
5128
+ svgs = await drawio.bakeDrawioSvg(xml, { browser: await browserRef.get() });
5129
+ } catch (e) {
5130
+ // page.evaluate() can reject with a plain string rather than an Error
5131
+ // (observed: puppeteer surfacing a serialization failure as "buffer
5132
+ // error") — e.message would silently read as "undefined" then.
5133
+ const reason = (e && e.message) ? e.message : String(e);
5134
+ console.error('[WARN] drawio render failed:', reason);
5135
+ // A silent blank is the most expensive failure this project has learned
5136
+ // from (v3.3.0) — leave a visible placeholder naming the error instead.
5137
+ const msg = ('drawio 圖無法渲染:' + reason)
5138
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
5139
+ // The callback form, NOT a replacement string: `msg` is generated
5140
+ // content (a renderer error message), and String.replace() interprets
5141
+ // $$ / $& / $` / $' inside a replacement STRING even when the pattern
5142
+ // is a plain string. A callback's return value is never re-scanned.
5143
+ // See the same note on the success path below.
5144
+ htmlStr = htmlStr.replace(job.m,
5145
+ () => `<div class="drawio drawio-failed"><p>${msg}</p></div>`);
5146
+ continue;
5147
+ }
5148
+ const inner = svgs.map((svg, i) =>
5149
+ `<div class="drawio-page"${i === job.pageIdx ? '' : ' hidden'}>${svg}</div>`).join('');
5150
+ // Page names may contain any character (commas and quotes included), so
5151
+ // they travel as base64-encoded JSON rather than a delimited string — a
5152
+ // delimiter breaks on both "two names glued together" and "a name that
5153
+ // happens to contain the delimiter", and a literal control character
5154
+ // living in the document is invisible to boot.
5155
+ const attr = Buffer.from(JSON.stringify(names), 'utf8').toString('base64');
5156
+ // v3.4.0 batch2 final review R5 — the author already wrote a name in
5157
+ // `![Architecture overview](arch.drawio)`; give the baked box that name.
5158
+ // The role differs by page count on purpose: a multi-page box has the
5159
+ // sheet bar's <button>s appended INSIDE it at runtime, and role="img"
5160
+ // would take them off the accessibility tree entirely, so those boxes get
5161
+ // role="group" — also nameable, children still exposed. No alt text means
5162
+ // no attributes at all: an empty accessible name is worse than none.
5163
+ const named = job.alt
5164
+ ? ` role="${names.length >= 2 ? 'group' : 'img'}" aria-label="${job.alt}"`
5165
+ : '';
5166
+ // v3.4.0 batch2 final review B2 — the callback form is load-bearing.
5167
+ // `inner` is the baked SVG, i.e. arbitrary diagram text, and a
5168
+ // replacement STRING has $$ / $& / $` / $' interpreted in it even when
5169
+ // the pattern is a plain string: a `$$` label silently collapsed to a
5170
+ // single `$`, and a `$&` label (the byte sequence any label with `$`
5171
+ // before `&` produces once the SVG escapes the `&`) spliced this very
5172
+ // placeholder — the whole .drawio file's base64 — into the middle of a
5173
+ // <text> element. bakeGraphviz() above was always a callback; this is the
5174
+ // same rule. Keep it a callback.
5175
+ htmlStr = htmlStr.replace(job.m,
5176
+ () => `<div class="drawio" data-drawio-pages="${attr}"${named}>${inner}</div>`);
5177
+ }
5178
+ return htmlStr;
5179
+ }
5180
+
4518
5181
  // Pre-render mermaid/wavedrom to inert SVG using headless Chromium, then strip
4519
5182
  // the diagram-engine runtime scripts so the output HTML carries no diagram JS.
4520
5183
  async function bakeDiagrams(htmlStr, dstPath) {