@helping-ai-workflow/md2doc 3.6.1 → 3.8.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
@@ -140,9 +140,14 @@ const waveDromSkinTag = inlineScriptTag(localWaveDromSkin, 'wavedrom');
140
140
  const waveDromTag = inlineScriptTag(localWaveDromJs, 'wavedrom');
141
141
  const mermaidScriptTag = inlineScriptTag(localMermaidJs, 'mermaid');
142
142
 
143
+ // v3.8.0 (design review item 5): mermaid in md2doc's link blue on a light
144
+ // tint, the body font, white edge labels. Shared by the reader init below and
145
+ // the edit-mode init hook so both modes draw the same diagram.
146
+ const MERMAID_CONFIG_JS = "theme: 'base', themeVariables: { fontFamily: '-apple-system, BlinkMacSystemFont, \"Segoe UI\", Arial, \"Microsoft JhengHei\", sans-serif', primaryColor: '#eaf2fd', primaryBorderColor: '#0969da', primaryTextColor: '#1f2328', lineColor: '#57606a', secondaryColor: '#f6f8fa', tertiaryColor: '#ffffff', edgeLabelBackground: '#ffffff' }";
147
+
143
148
  const mermaidInitTag = `<script type="text/javascript" data-md2doc-diagram-engine="mermaid">
144
149
  if (typeof mermaid !== 'undefined') {
145
- mermaid.initialize({ startOnLoad: true, theme: 'default' });
150
+ mermaid.initialize({ startOnLoad: true, ${MERMAID_CONFIG_JS} });
146
151
  }
147
152
  </script>`;
148
153
 
@@ -370,9 +375,76 @@ function renderEditModeList(listToken, blocks, biRef, out) {
370
375
  return parts;
371
376
  }
372
377
 
378
+ // <html lang> for a rendered document (v3.8.0, design review item 2).
379
+ // Tagged zh-Hant when Han characters are at least 10% of (CJK characters +
380
+ // Latin words), counted outside code; when kana is at least 20% of
381
+ // (Han + kana) it is ja, and when Hangul is at least 20% of (Han + Hangul)
382
+ // it is ko (both still need CJK to be 10% of the prose). Measured 2026-10-01: English docs
383
+ // (README, CLAUDE.md) <= 0.013, mixed zh/en specs in mac_top/docs >= 0.237,
384
+ // so 0.10 leaves a wide margin on both sides. Traditional vs Simplified is
385
+ // not detected: every Chinese document this tool renders today is zh-Hant.
386
+ const LANG_TAG_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
387
+ function detectDocLang(mdText) {
388
+ const prose = String(mdText)
389
+ // Fences are anchored to line starts (up to 3 spaces of indent) and the
390
+ // closing fence must be at least as long as the opening one, so an inline
391
+ // mention of the marker, or a shorter marker line inside a longer fence,
392
+ // cannot swallow the prose around it. Backticks are written as x60 escapes.
393
+ .replace(/^ {0,3}(\x60{3,})[^\n\x60]*\n[\s\S]*?^ {0,3}\1\x60*[ \t\r]*$/gm, '')
394
+ .replace(/^ {0,3}(~{3,})[^\n]*\n[\s\S]*?^ {0,3}\1~*[ \t\r]*$/gm, '')
395
+ .replace(/\x60[^\x60\n]*\x60/g, '');
396
+ const han = (prose.match(/[\u3400-\u9fff]/g) || []).length;
397
+ const kana = (prose.match(/[\u3040-\u30ff]/g) || []).length;
398
+ const hangul = (prose.match(/[\uac00-\ud7af\u1100-\u11ff\u3130-\u318f]/g) || []).length;
399
+ const words = (prose.match(/[A-Za-z]+/g) || []).length;
400
+ // Kana or Hangul only count as Japanese or Korean when CJK text is the bulk
401
+ // of the document at all (same 10% floor as zh-Hant); one stray の in an
402
+ // English or Chinese document must not flip its language.
403
+ const cjk = han + kana + hangul;
404
+ if (cjk + words === 0) return 'en';
405
+ if (cjk / (cjk + words) >= 0.10) {
406
+ if (kana / (han + kana) >= 0.2) return 'ja';
407
+ if (hangul / (han + hangul) >= 0.2) return 'ko';
408
+ }
409
+ return (han + kana) / (han + kana + words) >= 0.10 ? 'zh-Hant' : 'en';
410
+ }
411
+
412
+ // v3.8.0 (design review item 12): the paragraph immediately after the
413
+ // document's FIRST </h1>, when EVERY line of it is "<strong>Key:</strong>
414
+ // value", is the spec's metadata block — rendered as a two-column <dl>.
415
+ // Allow-list rule: one non-matching line and the paragraph is left alone.
416
+ function renderDocMetaBlock(bodyHtml) {
417
+ const at = bodyHtml.indexOf('</h1>');
418
+ if (at < 0) return bodyHtml;
419
+ const head = bodyHtml.slice(0, at + 5);
420
+ const tail = bodyHtml.slice(at + 5);
421
+ const m = tail.match(/^(\s*)<p>([\s\S]*?)<\/p>/);
422
+ if (!m) return bodyHtml;
423
+ const lines = m[2].split('\n').filter((l) => l.trim());
424
+ const rows = lines.map((l) => l.match(/^\s*<strong>([^<]+):<\/strong>\s*([\s\S]*)$/));
425
+ if (rows.length < 2 || rows.some((r) => !r)) return bodyHtml;
426
+ const items = rows.map((r) => '<dt>' + r[1] + '</dt><dd>' + r[2] + '</dd>').join('');
427
+ return head + m[1] + '<dl class="doc-meta">' + items + '</dl>' + tail.slice(m[0].length);
428
+ }
429
+
430
+ // v3.8.0 sidebar icons. Path data from Lucide (ISC licence, see
431
+ // THIRD-PARTY-NOTICES.md), inlined so the single-file output works offline.
432
+ const ICON_SVG_OPEN = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">';
433
+ const READER_ICONS = {
434
+ unfold: ICON_SVG_OPEN + '<path d="M12 22v-6"/><path d="M12 8V2"/><path d="M4 12H2"/><path d="M10 12H8"/><path d="M16 12h-2"/><path d="M22 12h-2"/><path d="m15 19-3 3-3-3"/><path d="m15 5-3-3-3 3"/></svg>',
435
+ fold: ICON_SVG_OPEN + '<path d="M12 22v-6"/><path d="M12 8V2"/><path d="M4 12H2"/><path d="M10 12H8"/><path d="M16 12h-2"/><path d="M22 12h-2"/><path d="m15 19-3-3-3 3"/><path d="m15 5-3 3-3-3"/></svg>',
436
+ panel: ICON_SVG_OPEN + '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/><path d="m16 15-3-3 3-3"/></svg>',
437
+ up: ICON_SVG_OPEN + '<path d="m18 15-6-6-6 6"/></svg>',
438
+ down: ICON_SVG_OPEN + '<path d="m6 9 6 6 6-6"/></svg>',
439
+ x: ICON_SVG_OPEN + '<path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>',
440
+ };
441
+
373
442
  async function renderMarkdown(mdText, srcPath, opts = {}) {
374
443
  const md = mdText;
375
444
  const src = srcPath;
445
+ const docLang = (typeof opts.lang === 'string' && LANG_TAG_RE.test(opts.lang))
446
+ ? opts.lang
447
+ : detectDocLang(md);
376
448
  let usesMermaid = false;
377
449
  let usesWaveDrom = false;
378
450
  let usesDrawio = false;
@@ -699,6 +771,16 @@ async function renderMarkdown(mdText, srcPath, opts = {}) {
699
771
  return root;
700
772
  }
701
773
 
774
+ // v3.8.0 (design review item 7+8): when the document's only H1 is its
775
+ // title and it has sections, list those sections at the TOC's top level —
776
+ // the title row indented every entry one level and carried no information.
777
+ // Several H1s are chapters and are kept as they are.
778
+ function flattenSingleRoot(tree) {
779
+ return tree.length === 1 && tree[0].depth === 1 && tree[0].children.length
780
+ ? tree[0].children
781
+ : tree;
782
+ }
783
+
702
784
  function renderTocNodes(nodes, level = 1) {
703
785
  if (!nodes.length) {
704
786
  return '';
@@ -959,7 +1041,14 @@ ${itemsHtml}
959
1041
  const depth = Number(token.depth || 1);
960
1042
  const headingText = stripHtmlTags(flattenTokenText(token.tokens) || token.text || '');
961
1043
  const headingId = slugifyHeading(headingText);
962
- const headingHtml = this.parser.parseInline(token.tokens);
1044
+ const inlineHtml = this.parser.parseInline(token.tokens);
1045
+ // v3.8.0 (design review item 4): a leading section number ("4.1 ",
1046
+ // "12. ") gets its own span so CSS can widen the gap after it. The
1047
+ // space stays in the markup, so textContent is unchanged. Read mode
1048
+ // only — the editor turns heading DOM back into Markdown.
1049
+ const headingHtml = opts.editMode
1050
+ ? inlineHtml
1051
+ : inlineHtml.replace(/^(\d+(?:\.\d+)*\.?)(\s)/, '<span class="sec">$1</span>$2');
963
1052
 
964
1053
  tocItems.push({
965
1054
  depth,
@@ -1126,6 +1215,7 @@ ${itemsHtml}
1126
1215
  }
1127
1216
  // Both the ```math fence and the $/$$ extension emit class="katex" — a single
1128
1217
  // post-parse scan is the source of truth for conditional CSS injection.
1218
+ if (!opts.editMode) bodyHtml = renderDocMetaBlock(bodyHtml);
1129
1219
  usesMath = /class="katex/.test(bodyHtml);
1130
1220
  serializedSections = JSON.stringify(
1131
1221
  sections.map((section) => ({
@@ -1141,30 +1231,31 @@ ${itemsHtml}
1141
1231
  <section class="reader-tools">
1142
1232
  <label class="reader-search-label" for="doc-search-input">Search</label>
1143
1233
  <div class="reader-search-row">
1144
- <input type="search" id="doc-search-input" placeholder="Enter keyword and press Enter">
1145
- <button id="doc-search-submit" type="button">Search</button>
1146
- <button id="doc-search-clear" type="button">Clear</button>
1234
+ <input type="search" id="doc-search-input" placeholder="Search this document">
1235
+ <button id="doc-search-submit" type="button" tabindex="-1">Search</button>
1236
+ <button id="doc-search-clear" type="button" aria-label="Clear search" title="Clear search">${READER_ICONS.x}</button>
1147
1237
  </div>
1148
1238
  </section>
1149
1239
  <section class="search-results" id="search-results" hidden>
1150
1240
  <div class="search-results-header">
1151
1241
  <span class="search-results-title">Results</span>
1152
1242
  <span id="search-result-count" class="reader-status">0</span>
1153
- <button id="search-prev" type="button" disabled aria-label="Previous match">◀</button>
1154
- <button id="search-next" type="button" disabled aria-label="Next match">▶</button>
1243
+ <button id="search-prev" type="button" disabled aria-label="Previous match" title="Previous match">${READER_ICONS.up}</button>
1244
+ <button id="search-next" type="button" disabled aria-label="Next match" title="Next match">${READER_ICONS.down}</button>
1155
1245
  </div>
1156
1246
  <div id="search-results-list"></div>
1157
1247
  </section>
1158
1248
  <nav class="toc" aria-label="Table of contents" data-reader-toc>
1159
1249
  <div class="toc-header">
1250
+ <span class="toc-title">Contents</span>
1160
1251
  <div class="toc-header-actions">
1161
- <button id="toc-expand-all" type="button" aria-label="Expand all">⊞</button>
1162
- <button id="toc-collapse-all" type="button" aria-label="Collapse all">⊟</button>
1163
- <button id="toc-collapse-toggle" type="button" aria-label="Collapse table of contents" title="Collapse / expand sidebar">◀</button>
1252
+ <button id="toc-expand-all" type="button" aria-label="Expand all" title="Expand all">${READER_ICONS.unfold}</button>
1253
+ <button id="toc-collapse-all" type="button" aria-label="Collapse all" title="Collapse all">${READER_ICONS.fold}</button>
1254
+ <button id="toc-collapse-toggle" type="button" aria-label="Collapse table of contents" title="Collapse / expand sidebar">${READER_ICONS.panel}</button>
1164
1255
  </div>
1165
1256
  <div class="toc-breadcrumb" data-toc-breadcrumb aria-label="Current location"></div>
1166
1257
  </div>
1167
- ${renderTocNodes(tocTree)}
1258
+ ${renderTocNodes(flattenSingleRoot(tocTree))}
1168
1259
  </nav>
1169
1260
  </aside>
1170
1261
  <div class="sidebar-splitter" id="sidebar-splitter" role="separator" aria-orientation="vertical" aria-label="Resize sidebar"></div>`;
@@ -1214,7 +1305,7 @@ ${itemsHtml}
1214
1305
  window.__md2docInitDiagrams = function (rootEl) {
1215
1306
  rootEl = rootEl || document;
1216
1307
  if (typeof mermaid !== 'undefined') {
1217
- mermaid.initialize({ startOnLoad: false, theme: 'default' });
1308
+ mermaid.initialize({ startOnLoad: false, ${MERMAID_CONFIG_JS} });
1218
1309
  var mermaidNodes = rootEl.querySelectorAll ? rootEl.querySelectorAll('.mermaid') : [];
1219
1310
  var pending = [];
1220
1311
  for (var i = 0; i < mermaidNodes.length; i++) {
@@ -1423,6 +1514,14 @@ ${itemsHtml}
1423
1514
  /* min-width: 0 so an unbreakable token inside an item cannot widen the row
1424
1515
  past its siblings — that would break the equal-width guarantee above. */
1425
1516
  .ed-block[data-block-type="li"] > .ed-li-text { flex: 1 1 auto; min-width: 0; }
1517
+ /* The MD 原始碼 raw editor replaces the item's children with one .ed-editing
1518
+ wrap, which is then this flex row's only child. Without a grow it shrank
1519
+ to the textarea's intrinsic size and .ed-raw's width: 100% resolved
1520
+ against that: 189px at every viewport width (measured), where every other
1521
+ block's raw editor spans the column. The marker, and with it the indent,
1522
+ is gone while the textarea is open, so it spans the full column at every
1523
+ depth; the source line's own leading spaces show the nesting. */
1524
+ .ed-block[data-block-type="li"] > .ed-editing { flex: 1 1 auto; min-width: 0; }
1426
1525
  /* column-gap already provides the marker/checkbox separation. */
1427
1526
  .ed-block[data-block-type="li"] > .ed-li-check { flex: 0 0 auto; margin-right: 0; }
1428
1527
  .ed-block[data-list-type="ul"] > .ed-li-marker::before { content: "\\2022"; }${edOlCounterCss}
@@ -1601,6 +1700,10 @@ ${itemsHtml}
1601
1700
  reader-mode output never includes it, so the mobile drawer toggle there
1602
1701
  is unaffected. */
1603
1702
  .sidebar-toggle { display: none; }
1703
+ .mobile-bar { display: none; }
1704
+ /* The reader bar is gone in edit mode, so its 52px anchor offset goes too
1705
+ (it moved scrollIntoView landings under the edit toolbar). */
1706
+ .content [id] { scroll-margin-top: 0; }
1604
1707
  /* T11-2 step 5: enterSourceMode() (lib/editor/client.js) sets
1605
1708
  contentEl.hidden = true and mounts a plain textarea in its place, and
1606
1709
  setDocMode() writes docMode onto body as data-ed-mode. Measured on that
@@ -2129,15 +2232,24 @@ ${itemsHtml}
2129
2232
  overflow-x: auto; font-family: monospace; font-size: 12px;
2130
2233
  }`;
2131
2234
 
2132
- const html = `<!DOCTYPE html>
2133
- <html lang="en">
2235
+ // The phone bar only exists when there is a TOC for its button to open: a
2236
+ // document with no headings has no sidebar, and an empty bar would carry a
2237
+ // button that opens an empty drawer.
2238
+ const mobileBarHtml = tocHtml
2239
+ ? '<header class="mobile-bar" id="mobile-bar">\n' +
2240
+ '<button class="sidebar-toggle" id="sidebar-toggle" type="button" aria-label="Toggle sidebar" aria-expanded="false">☰</button>\n' +
2241
+ '<span class="mobile-bar-title" id="mobile-bar-title"></span>\n' +
2242
+ '</header>\n'
2243
+ : '';
2244
+ const html = `<!DOCTYPE html>
2245
+ <html lang="${docLang}">
2134
2246
  <head>
2135
2247
  <meta charset="UTF-8">
2136
2248
  <meta name="viewport" content="width=device-width, initial-scale=1">
2137
2249
  <title>${title}</title>
2138
2250
  <style>
2139
2251
  body {
2140
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
2252
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, "PingFang TC", "Microsoft JhengHei", "Noto Sans CJK TC", "Noto Sans TC", sans-serif;
2141
2253
  margin: 0;
2142
2254
  padding: 0;
2143
2255
  line-height: 1.65;
@@ -2166,7 +2278,9 @@ ${itemsHtml}
2166
2278
  min-width: 0;
2167
2279
  height: calc(100vh - 48px);
2168
2280
  overflow: hidden;
2169
- padding-right: 8px;
2281
+ padding: 12px 8px 12px 12px;
2282
+ background: #f3f5f7;
2283
+ border-radius: 10px;
2170
2284
  box-sizing: border-box;
2171
2285
  display: flex;
2172
2286
  flex-direction: column;
@@ -2176,7 +2290,7 @@ ${itemsHtml}
2176
2290
  flex-basis: 36px;
2177
2291
  width: 36px;
2178
2292
  min-width: 36px;
2179
- padding-right: 0;
2293
+ padding: 12px 4px;
2180
2294
  }
2181
2295
  body[data-toc-collapsed] .reader-tools,
2182
2296
  body[data-toc-collapsed] .search-results,
@@ -2232,26 +2346,13 @@ ${itemsHtml}
2232
2346
  display: none;
2233
2347
  }
2234
2348
  #toc-collapse-toggle {
2235
- margin-left: 0;
2236
- padding: 2px 8px;
2237
- font: inherit;
2238
- font-size: 0.9em;
2239
- line-height: 1;
2240
- border: 1px solid #d0d7de;
2241
- border-radius: 6px;
2242
- background: #ffffff;
2243
- color: #57606a;
2244
- cursor: pointer;
2245
2349
  transition: transform 0.15s ease;
2246
2350
  }
2247
- #toc-collapse-toggle:hover {
2248
- background: #eef2f6;
2249
- color: #24292e;
2250
- }
2251
2351
  @media (max-width: 1080px) {
2252
2352
  #toc-collapse-toggle { display: none; }
2253
2353
  }
2254
2354
  .reader-tools { flex: 0 0 auto; }
2355
+ .mobile-bar { display: none; }
2255
2356
  .sidebar-toggle {
2256
2357
  display: none;
2257
2358
  position: fixed;
@@ -2296,65 +2397,91 @@ ${itemsHtml}
2296
2397
  .reader-tools,
2297
2398
  .search-results,
2298
2399
  .toc {
2299
- border: 1px solid #d0d7de;
2300
- border-radius: 10px;
2301
- background: #f8fafc;
2302
- padding: 12px 14px;
2400
+ border: none;
2401
+ background: transparent;
2402
+ padding: 2px 0;
2303
2403
  }
2304
- .reader-search-label {
2305
- display: block;
2306
- font-size: 0.78rem;
2307
- font-weight: 700;
2308
- letter-spacing: 0.08em;
2309
- text-transform: uppercase;
2310
- color: #57606a;
2311
- margin-bottom: 6px;
2404
+ .reader-search-label,
2405
+ #doc-search-submit {
2406
+ position: absolute;
2407
+ width: 1px;
2408
+ height: 1px;
2409
+ padding: 0;
2410
+ margin: -1px;
2411
+ overflow: hidden;
2412
+ clip: rect(0, 0, 0, 0);
2413
+ white-space: nowrap;
2414
+ border: 0;
2312
2415
  }
2313
2416
  .reader-search-row {
2417
+ position: relative;
2314
2418
  display: flex;
2315
- gap: 6px;
2316
- margin-bottom: 8px;
2419
+ margin-bottom: 4px;
2317
2420
  }
2318
2421
  .reader-search-row input[type="search"] {
2319
2422
  flex: 1 1 auto;
2320
2423
  min-width: 0;
2321
- padding: 6px 8px;
2424
+ padding: 7px 30px 7px 10px;
2322
2425
  border: 1px solid #d0d7de;
2323
2426
  border-radius: 6px;
2427
+ background: #ffffff;
2324
2428
  font: inherit;
2325
2429
  }
2326
- .reader-search-row button {
2327
- padding: 4px 10px;
2328
- font: inherit;
2329
- font-size: 0.85em;
2330
- border: 1px solid #d0d7de;
2331
- border-radius: 6px;
2332
- background: #ffffff;
2430
+ .reader-search-row input[type="search"]::-webkit-search-cancel-button {
2431
+ -webkit-appearance: none;
2432
+ appearance: none;
2433
+ display: none;
2434
+ }
2435
+ #doc-search-clear {
2436
+ position: absolute;
2437
+ right: 4px;
2438
+ top: 50%;
2439
+ transform: translateY(-50%);
2440
+ display: inline-grid;
2441
+ place-items: center;
2442
+ padding: 4px;
2443
+ line-height: 0;
2444
+ border: none;
2445
+ border-radius: 4px;
2446
+ background: transparent;
2447
+ color: #59636e;
2333
2448
  cursor: pointer;
2334
2449
  }
2335
- .reader-search-row button:hover {
2336
- background: #eef2f6;
2450
+ #doc-search-clear:hover {
2451
+ background: #e3e8ee;
2452
+ color: #1f2328;
2453
+ }
2454
+ #doc-search-input:placeholder-shown ~ #doc-search-clear {
2455
+ display: none;
2337
2456
  }
2338
2457
  .search-results-header,
2339
2458
  .toc-header {
2340
2459
  display: flex;
2341
- flex-direction: column;
2342
- align-items: stretch;
2343
- gap: 4px;
2460
+ flex-direction: row;
2461
+ align-items: center;
2462
+ gap: 6px;
2344
2463
  margin-bottom: 8px;
2345
2464
  }
2465
+ .toc-header {
2466
+ justify-content: space-between;
2467
+ border-bottom: 1px solid #d8dee4;
2468
+ padding-bottom: 6px;
2469
+ }
2470
+ body[data-toc-collapsed] .toc-header {
2471
+ border-bottom: none;
2472
+ }
2346
2473
  .toc-header-actions {
2347
2474
  flex: 0 0 auto;
2348
2475
  display: flex;
2349
- gap: 6px;
2476
+ gap: 2px;
2350
2477
  justify-content: flex-end;
2351
2478
  }
2352
- /* Sticky breadcrumb: stacked ancestor chain of the current scroll position.
2353
- Each row is single-line + ellipsis; full text on hover via title=. */
2479
+ /* Breadcrumb: kept in the DOM (the runtime and editor-reader-rebind read it)
2480
+ but hidden since v3.8.0 — the TOC highlights the whole active path instead. */
2354
2481
  .toc-breadcrumb {
2355
2482
  flex: 1 1 auto;
2356
2483
  min-width: 0;
2357
- display: flex;
2484
+ display: none;
2358
2485
  flex-direction: column;
2359
2486
  gap: 1px;
2360
2487
  }
@@ -2379,13 +2506,14 @@ ${itemsHtml}
2379
2506
  }
2380
2507
  .search-results-title,
2381
2508
  .toc-title {
2382
- font-size: 0.78rem;
2383
- font-weight: 700;
2384
- letter-spacing: 0.08em;
2385
- text-transform: uppercase;
2386
- color: #57606a;
2509
+ font-size: 0.86rem;
2510
+ font-weight: 600;
2511
+ color: #1f2328;
2387
2512
  flex: 0 0 auto;
2388
2513
  }
2514
+ body[data-toc-collapsed] .toc-title {
2515
+ display: none;
2516
+ }
2389
2517
  .reader-status {
2390
2518
  flex: 1 1 auto;
2391
2519
  font-size: 0.82em;
@@ -2393,20 +2521,21 @@ ${itemsHtml}
2393
2521
  }
2394
2522
  .search-results-header button,
2395
2523
  .toc-header button {
2396
- padding: 2px 8px;
2524
+ display: inline-grid;
2525
+ place-items: center;
2526
+ padding: 4px;
2397
2527
  font: inherit;
2398
- font-size: 0.9em;
2399
- line-height: 1;
2400
- border: 1px solid #d0d7de;
2528
+ line-height: 0;
2529
+ border: none;
2401
2530
  border-radius: 6px;
2402
- background: #ffffff;
2403
- color: #57606a;
2531
+ background: transparent;
2532
+ color: #59636e;
2404
2533
  cursor: pointer;
2405
2534
  }
2406
2535
  .search-results-header button:hover,
2407
2536
  .toc-header button:hover {
2408
- background: #eef2f6;
2409
- color: #24292e;
2537
+ background: #e3e8ee;
2538
+ color: #1f2328;
2410
2539
  }
2411
2540
  .search-results-header button:disabled {
2412
2541
  opacity: 0.45;
@@ -2452,8 +2581,8 @@ ${itemsHtml}
2452
2581
  background: #eef2f6;
2453
2582
  }
2454
2583
  .search-result-item.is-active {
2455
- background: #dbeafe;
2456
- box-shadow: inset 0 0 0 1px #93c5fd;
2584
+ background: #dbe6f3;
2585
+ box-shadow: none;
2457
2586
  }
2458
2587
  .search-result-title {
2459
2588
  font-weight: 600;
@@ -2502,6 +2631,8 @@ ${itemsHtml}
2502
2631
  the shift+wheel handler; appears only when a title actually overflows. */
2503
2632
  overflow-x: auto;
2504
2633
  scrollbar-width: thin;
2634
+ -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 26px), transparent);
2635
+ mask-image: linear-gradient(90deg, #000 calc(100% - 26px), transparent);
2505
2636
  }
2506
2637
  .toc ul {
2507
2638
  list-style: none;
@@ -2526,13 +2657,15 @@ ${itemsHtml}
2526
2657
  min-width: 100%;
2527
2658
  color: #57606a;
2528
2659
  text-decoration: none;
2529
- padding: 2px 0;
2660
+ padding: 2px 8px;
2530
2661
  line-height: 1.4;
2531
2662
  white-space: nowrap;
2532
2663
  box-sizing: border-box;
2664
+ border-radius: 4px;
2533
2665
  }
2534
2666
  .toc summary > a {
2535
- flex: 0 0 auto;
2667
+ flex: 1 0 auto;
2668
+ min-width: 0;
2536
2669
  }
2537
2670
  .toc summary {
2538
2671
  min-width: 0;
@@ -2542,17 +2675,41 @@ ${itemsHtml}
2542
2675
  }
2543
2676
  .toc a:hover {
2544
2677
  color: #0969da;
2678
+ background: #e8edf2;
2545
2679
  }
2546
2680
  .toc a.is-active {
2547
- color: #0b57d0;
2548
- font-weight: 700;
2681
+ color: #0550ae;
2682
+ font-weight: 600;
2683
+ background: #dbe6f3;
2549
2684
  }
2550
2685
  .toc a.is-match {
2551
- color: #355070;
2552
- background: #eaf2ff;
2686
+ color: inherit;
2687
+ background: #fff5c2;
2688
+ }
2689
+ .toc li:has(a.is-active) > details > summary > a:not(.is-active) {
2690
+ color: #1f2328;
2691
+ font-weight: 600;
2692
+ }
2693
+ .reader-sidebar :is(a, button, input, summary):focus-visible,
2694
+ .mobile-bar button:focus-visible,
2695
+ .content a:focus-visible {
2696
+ outline: 2px solid #0969da;
2697
+ outline-offset: 2px;
2553
2698
  border-radius: 4px;
2554
- padding-left: 4px;
2555
- padding-right: 4px;
2699
+ }
2700
+ .toc :is(a, summary):focus-visible {
2701
+ outline-offset: -2px;
2702
+ }
2703
+ /* The list's right-edge fade is a mask, and a mask clips the ring of a
2704
+ focused row along with the text. Lift it while a row holds focus. */
2705
+ .toc > .toc-list:has(:focus-visible) {
2706
+ -webkit-mask-image: none;
2707
+ mask-image: none;
2708
+ }
2709
+ .toc-header button:focus-visible,
2710
+ .search-results-header button:focus-visible,
2711
+ .reader-sidebar .search-result-item:focus-visible {
2712
+ outline-offset: -2px;
2556
2713
  }
2557
2714
  .toc details {
2558
2715
  margin: 0;
@@ -2574,6 +2731,8 @@ ${itemsHtml}
2574
2731
  font-size: 0.78em;
2575
2732
  line-height: 1.8;
2576
2733
  flex: 0 0 auto;
2734
+ width: 12px;
2735
+ text-align: center;
2577
2736
  transform: translateY(1px);
2578
2737
  }
2579
2738
  .toc details[open] > summary::before {
@@ -2585,15 +2744,15 @@ ${itemsHtml}
2585
2744
  border-left: 1px solid #d8dee4;
2586
2745
  }
2587
2746
  .toc-item:not(.toc-parent) > a {
2588
- padding-left: 18px;
2747
+ padding-left: 26px;
2589
2748
  }
2590
2749
  .toc-list-level-1 > .toc-item > a,
2591
2750
  .toc-list-level-1 > .toc-item > details > summary > a {
2592
- font-weight: 600;
2751
+ font-size: 0.95em;
2593
2752
  }
2594
2753
  .toc-list-level-2 > .toc-item > a,
2595
2754
  .toc-list-level-2 > .toc-item > details > summary > a {
2596
- font-size: 0.95em;
2755
+ font-size: 0.9em;
2597
2756
  }
2598
2757
  .toc-list-level-3 > .toc-item > a,
2599
2758
  .toc-list-level-3 > .toc-item > details > summary > a,
@@ -2625,6 +2784,19 @@ ${itemsHtml}
2625
2784
  }
2626
2785
  .content > * { max-width: 100%; }
2627
2786
  .content iframe, .content video, .content canvas { max-width: 100%; height: auto; }
2787
+ .doc-meta {
2788
+ display: grid;
2789
+ grid-template-columns: max-content 1fr;
2790
+ gap: 4px 20px;
2791
+ background: #f6f8fa;
2792
+ border-radius: 6px;
2793
+ padding: 12px 16px;
2794
+ margin: 16px 0 28px;
2795
+ font-size: 0.92em;
2796
+ line-height: 1.55;
2797
+ }
2798
+ .doc-meta dt { color: #57606a; }
2799
+ .doc-meta dd { margin: 0; }
2628
2800
  .heading-with-anchor {
2629
2801
  position: relative;
2630
2802
  }
@@ -2645,14 +2817,15 @@ ${itemsHtml}
2645
2817
  color: #0969da;
2646
2818
  }
2647
2819
  h1 { font-size: 2em; border-bottom: 2px solid #e1e4e8; padding-bottom: 10px; margin-top: 1.5em; }
2648
- h2 { font-size: 1.5em; border-bottom: 1px solid #e1e4e8; padding-bottom: 6px; margin-top: 1.4em; }
2649
- h3 { font-size: 1.2em; margin-top: 1.3em; }
2650
- h4 { font-size: 1.05em; margin-top: 1.2em; }
2820
+ h2 { font-size: 1.5em; border-bottom: 1px solid #e1e4e8; padding-bottom: 6px; margin-top: 2.2em; margin-bottom: 0.7em; }
2821
+ h3 { font-size: 1.3em; margin-top: 2em; margin-bottom: 0.5em; }
2822
+ h4 { font-size: 1.1em; margin-top: 1.8em; margin-bottom: 0.4em; }
2823
+ .sec { margin-right: 0.4em; font-variant-numeric: tabular-nums; }
2651
2824
  code {
2652
2825
  background: #f6f8fa;
2653
2826
  padding: 2px 5px;
2654
2827
  border-radius: 3px;
2655
- font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
2828
+ font-family: "Cascadia Mono", Consolas, "SFMono-Regular", "Liberation Mono", Menlo, "Microsoft JhengHei", monospace;
2656
2829
  font-size: 0.875em;
2657
2830
  }
2658
2831
  pre {
@@ -2663,6 +2836,13 @@ ${itemsHtml}
2663
2836
  line-height: 1.45;
2664
2837
  }
2665
2838
  pre code { background: none; padding: 0; font-size: 0.875em; }
2839
+ .content :not(pre) > code {
2840
+ background: none;
2841
+ padding: 0;
2842
+ border-radius: 0;
2843
+ color: #b93a0c;
2844
+ font-size: 0.92em;
2845
+ }
2666
2846
  table {
2667
2847
  display: block;
2668
2848
  border-collapse: collapse;
@@ -2672,9 +2852,8 @@ ${itemsHtml}
2672
2852
  font-size: 0.9em;
2673
2853
  overflow-x: auto;
2674
2854
  }
2675
- th, td { border: 1px solid #dfe2e5; padding: 7px 14px; text-align: left; }
2676
- th { background: #f6f8fa; font-weight: 600; }
2677
- tr:nth-child(even) { background: #fafbfc; }
2855
+ th, td { border: none; border-bottom: 1px solid #e1e4e8; padding: 7px 14px; text-align: left; vertical-align: top; }
2856
+ th { font-weight: 600; border-bottom: 2px solid #c9ced4; }
2678
2857
  .content table th,
2679
2858
  .content table td {
2680
2859
  overflow-wrap: normal;
@@ -2687,7 +2866,7 @@ ${itemsHtml}
2687
2866
  .content table th code {
2688
2867
  white-space: nowrap;
2689
2868
  }
2690
- .content table { table-layout: auto; }
2869
+ .content table { table-layout: auto; font-variant-numeric: tabular-nums; }
2691
2870
  .content table col.col-narrow { width: 1%; }
2692
2871
  .content table col.col-prose { width: auto; }
2693
2872
  .content table th.cell-narrow,
@@ -2700,10 +2879,7 @@ ${itemsHtml}
2700
2879
  background: #ffffff;
2701
2880
  }
2702
2881
  .content table thead th:first-child {
2703
- background: #f6f8fa;
2704
- }
2705
- .content table tbody tr:nth-child(even) td:first-child {
2706
- background: #fafbfc;
2882
+ background: #ffffff;
2707
2883
  }
2708
2884
  blockquote {
2709
2885
  border-left: 4px solid #dfe2e5;
@@ -2917,21 +3093,59 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
2917
3093
  html[data-lightbox-open] { overflow: hidden; }
2918
3094
  @media print { .lightbox { display: none !important; } }
2919
3095
  @media (max-width: 1080px) {
3096
+ .content table td.cell-prose,
3097
+ .content table th.cell-prose { min-width: 15em; }
2920
3098
  .sidebar-toggle { display: inline-flex; align-items: center; }
3099
+ .mobile-bar {
3100
+ display: flex;
3101
+ align-items: center;
3102
+ gap: 10px;
3103
+ position: fixed;
3104
+ top: 0;
3105
+ left: 0;
3106
+ right: 0;
3107
+ height: 44px;
3108
+ padding: 0 12px;
3109
+ box-sizing: border-box;
3110
+ background: #ffffff;
3111
+ border-bottom: 1px solid #d8dee4;
3112
+ z-index: 100;
3113
+ }
3114
+ .mobile-bar .sidebar-toggle {
3115
+ position: static;
3116
+ box-shadow: none;
3117
+ }
3118
+ .mobile-bar-title {
3119
+ min-width: 0;
3120
+ flex: 1 1 auto;
3121
+ font-size: 14px;
3122
+ font-weight: 600;
3123
+ color: #24292e;
3124
+ white-space: nowrap;
3125
+ overflow: hidden;
3126
+ text-overflow: ellipsis;
3127
+ }
3128
+ .content [id] { scroll-margin-top: 52px; }
3129
+ /* The 60px clears the fixed bar, so it only applies when the bar exists
3130
+ (a document with no headings has none). :where() keeps the selector at
3131
+ the specificity of a bare class, so the edit-mode override still wins. */
3132
+ .page-layout:where(.mobile-bar ~ *) {
3133
+ padding-top: 60px;
3134
+ }
2921
3135
  .page-layout {
2922
3136
  display: block;
2923
3137
  max-width: 100%;
2924
- padding-top: 60px;
2925
3138
  }
2926
3139
  .reader-sidebar {
2927
3140
  position: fixed;
2928
- top: 0;
3141
+ top: 44px;
2929
3142
  left: 0;
2930
3143
  bottom: 0;
2931
3144
  width: 85%;
2932
3145
  max-width: 360px;
2933
- height: 100vh;
2934
- background: #ffffff;
3146
+ height: calc(100vh - 44px);
3147
+ background: #f3f5f7;
3148
+ border-radius: 0;
2935
3149
  z-index: 99;
2936
3150
  transform: translateX(-100%);
2937
3151
  transition: transform 0.2s ease;
@@ -2941,10 +3155,9 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
2941
3155
  display: flex;
2942
3156
  flex-direction: column;
2943
3157
  gap: 12px;
2944
- box-shadow: 2px 0 12px rgba(0,0,0,0.15);
2945
3158
  flex: initial;
2946
3159
  }
2947
- body[data-sidebar-open] .reader-sidebar { transform: translateX(0); }
3160
+ body[data-sidebar-open] .reader-sidebar { transform: translateX(0); box-shadow: 2px 0 12px rgba(0,0,0,0.15); }
2948
3161
  /* Scroll lock for the drawer, same two-rule shape the lightbox uses a
2949
3162
  few lines above (body[data-lightbox-open] / html[data-lightbox-open])
2950
3163
  and for the identical reason: 'html, body { overflow-x: clip }' keeps
@@ -2961,7 +3174,6 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
2961
3174
  body[data-sidebar-open] { overflow: hidden; }
2962
3175
  html[data-sidebar-open] { overflow: hidden; }
2963
3176
  .sidebar-splitter { display: none; }
2964
- .heading-anchor { opacity: 1; }
2965
3177
  }
2966
3178
  @media print {
2967
3179
  body { font-size: 11pt; }
@@ -2986,10 +3198,19 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
2986
3198
  padding: 0 10px;
2987
3199
  }
2988
3200
  .reader-sidebar,
3201
+ .mobile-bar,
2989
3202
  .sidebar-toggle,
2990
3203
  .sidebar-splitter,
2991
3204
  .sidebar-scrim { display: none !important; }
2992
3205
  .content { max-width: 100%; }
3206
+ /* A4 lays out at about 680px, inside the max-width: 1080px block above,
3207
+ so its 15em prose floor applied to print; the table scrolls
3208
+ horizontally on screen and a PDF clips what overflows. Drop the floor.
3209
+ The sticky first column goes static so its bottom border prints. */
3210
+ .content table td.cell-prose,
3211
+ .content table th.cell-prose { min-width: 0; }
3212
+ .content table tbody td:first-child,
3213
+ .content table thead th:first-child { position: static; }
2993
3214
  .heading-anchor { display: none; }
2994
3215
  pre { font-size: 9pt; }
2995
3216
  a[href]:after { content: none; }
@@ -3007,18 +3228,17 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
3007
3228
  .ed-te-hl).
3008
3229
 
3009
3230
  The descendant rules and their !important are not decoration, they are
3010
- the whole problem: <pre> (#f6f8fa), <th> (#f6f8fa), the zebra stripe
3011
- tr:nth-child(even) (#fafbfc) and the sticky first column (#ffffff) all
3231
+ the whole problem: <pre> (#f6f8fa) and the sticky first column (#ffffff)
3012
3232
  carry OPAQUE backgrounds, and the sticky column additionally paints at
3013
- z-index: 1 over anything drawn beneath it. Two of those beat a plain
3014
- descendant selector on specificity alone — counted:
3015
- .content table tbody tr:nth-child(even) td:first-child is (0,3,4)
3016
- against this rule's (0,3,2), and .content table tbody td:first-child is
3017
- (0,2,3) against the same (0,3,2) — so without !important the tint is
3018
- invisible on exactly the blocks a user is most likely to select. .ed-te-hl
3019
- needed the identical treatment for the identical reason. This ADDS rules
3020
- rather than reordering the sticky/zebra/header set, which CLAUDE.md says
3021
- wins by source order and must not be moved.
3233
+ z-index: 1 over anything drawn beneath it. Until v3.8.0 the grey <th>
3234
+ (#f6f8fa) and the zebra stripe tr:nth-child(even) (#fafbfc, whose sticky
3235
+ override .content table tbody tr:nth-child(even) td:first-child was
3236
+ (0,3,4) against this rule's (0,3,2)) did too; v3.8.0 removed both, and
3237
+ the th / tr:nth-child(even) selectors below stay so a later restyle
3238
+ cannot quietly reopen the hole. Without !important the tint is invisible
3239
+ on exactly the blocks a user is most likely to select. .ed-te-hl needed
3240
+ the identical treatment for the identical reason. This ADDS rules rather
3241
+ than reordering the sticky-column rules, which win by source order.
3022
3242
  (No backticks in this comment: it lives inside a JS template literal.)
3023
3243
 
3024
3244
  :focus outline is suppressed because the roving tabindex below focuses a
@@ -3267,6 +3487,11 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
3267
3487
  indicator' is the singleton line shown while dragging a row.
3268
3488
  '.ed-te-row-dragging' dims the row actually being dragged. */
3269
3489
  .ed-te-hl { background: rgba(59, 130, 246, 0.15) !important; }
3490
+ /* Table cell-range selection: the painted rectangle, and the table that
3491
+ holds one (its caret and native selection are hidden while it stands). */
3492
+ .ed-cell-range { background: rgba(59, 130, 246, 0.18) !important; }
3493
+ .ed-cell-range-active { caret-color: transparent; }
3494
+ .ed-cell-range-active ::selection { background: transparent; }
3270
3495
  .ed-te-menu {
3271
3496
  position: fixed; z-index: 12;
3272
3497
  display: flex; align-items: center; gap: 4px;
@@ -3710,8 +3935,7 @@ ${opts.editMode ? editModeLayoutCss : ''}
3710
3935
  ${usesMath ? buildKatexStyleTag() : ''}
3711
3936
  </head>
3712
3937
  <body>
3713
- <button class="sidebar-toggle" id="sidebar-toggle" type="button" aria-label="Toggle sidebar" aria-expanded="false">☰</button>
3714
- <div class="sidebar-scrim" id="sidebar-scrim"></div>
3938
+ ${mobileBarHtml}<div class="sidebar-scrim" id="sidebar-scrim"></div>
3715
3939
  <div class="page-layout">
3716
3940
  ${tocHtml}
3717
3941
  <main class="content">
@@ -3799,11 +4023,15 @@ ${mermaidInitTag}`)
3799
4023
  }
3800
4024
 
3801
4025
  const breadcrumbEl = document.querySelector('[data-toc-breadcrumb]');
4026
+ const mobileBarTitle = document.getElementById('mobile-bar-title');
4027
+ if (mobileBarTitle && sections.length) mobileBarTitle.textContent = sections[0].title;
3802
4028
  const sectionIndexById = new Map(sections.map((section, i) => [section.id, i]));
3803
4029
 
3804
4030
  // Sticky breadcrumb: the ancestor chain (shallow→deep) of the active section.
3805
4031
  // For each decreasing depth, take the nearest preceding section, then append
3806
- // the active section itself. Rendered as stacked, indented, clickable rows.
4032
+ // the active section itself. The rows are still written into the breadcrumb
4033
+ // element, but CSS hides it (the TOC highlights the path); the useful output
4034
+ // today is the last link's title, which names the mobile bar.
3807
4035
  function renderBreadcrumb(sectionId) {
3808
4036
  if (!breadcrumbEl) return;
3809
4037
  if (!sectionIndexById.has(sectionId)) {
@@ -3824,6 +4052,7 @@ ${mermaidInitTag}`)
3824
4052
  return;
3825
4053
  }
3826
4054
  const minDepth = chain[0].depth;
4055
+ if (mobileBarTitle) mobileBarTitle.textContent = chain[chain.length - 1].title;
3827
4056
  breadcrumbEl.innerHTML = chain
3828
4057
  .map((section, i) => {
3829
4058
  const indent = (section.depth - minDepth) * 10;
@@ -4403,18 +4632,30 @@ ${mermaidInitTag}`)
4403
4632
  var hit = document.elementFromPoint(rect.left + rect.width / 2, ANCHOR_LINE);
4404
4633
  var node = (hit && hit.closest) ? hit.closest('main.content > *') : null;
4405
4634
  if (!node) {
4406
- // Between blocks, or over a gap: fall back to the last heading above the
4407
- // anchor line. Heading offsets are monotonic in document order, so this
4408
- // is a binary search rather than a scan of every heading each frame.
4635
+ // Over a gap (a heading's top margin, the space between blocks): pin the
4636
+ // first block that starts below the anchor line, which is what the reader
4637
+ // sees next to it. Falling back to the last heading ABOVE the line would
4638
+ // pin something up to a whole section away. Block tops are monotonic in
4639
+ // document order, so this is a binary search over the column's children.
4640
+ var kids = contentEl.children;
4409
4641
  var lo = 0;
4410
- var hi = headingNodes.length - 1;
4642
+ var hi = kids.length - 1;
4411
4643
  while (lo <= hi) {
4412
4644
  var mid = (lo + hi) >> 1;
4413
- if (headingNodes[mid].getBoundingClientRect().top <= ANCHOR_LINE) {
4414
- node = headingNodes[mid];
4415
- lo = mid + 1;
4416
- } else {
4645
+ // A child with no layout boxes (every WaveDrom fence leaves a script
4646
+ // element) reports an all-zero rect, which would break the
4647
+ // monotonic order the search depends on. Compare the nearest
4648
+ // following child that is rendered; none left before hi means the
4649
+ // answer is in the lower half.
4650
+ var probe = mid;
4651
+ while (probe <= hi && !kids[probe].getClientRects().length) probe++;
4652
+ if (probe > hi) {
4417
4653
  hi = mid - 1;
4654
+ } else if (kids[probe].getBoundingClientRect().top > ANCHOR_LINE) {
4655
+ node = kids[probe];
4656
+ hi = mid - 1;
4657
+ } else {
4658
+ lo = probe + 1;
4418
4659
  }
4419
4660
  }
4420
4661
  }
@@ -5643,6 +5884,42 @@ ${usesDrawioMultiPage ? ` // v3.4.0 batch2 Task 6: wired through a NAMED, reusa
5643
5884
  }
5644
5885
  module.exports = { renderMarkdown };
5645
5886
 
5887
+ // Index of the first "{" in dot source that opens the graph body: not inside a
5888
+ // double-quoted string, a // or /* */ comment, a line starting with # (dot
5889
+ // treats it as preprocessor output), or an HTML-like <...> id. -1 if none.
5890
+ function firstDotBrace(src) {
5891
+ let i = 0;
5892
+ const n = src.length;
5893
+ while (i < n) {
5894
+ const c = src[i];
5895
+ if (c === '"') {
5896
+ i++;
5897
+ while (i < n && src[i] !== '"') i += src[i] === '\\' ? 2 : 1;
5898
+ i++;
5899
+ } else if (c === '/' && src[i + 1] === '/') {
5900
+ while (i < n && src[i] !== '\n') i++;
5901
+ } else if (c === '/' && src[i + 1] === '*') {
5902
+ const end = src.indexOf('*/', i + 2);
5903
+ i = end < 0 ? n : end + 2;
5904
+ } else if (c === '#' && (i === 0 || src[i - 1] === '\n')) {
5905
+ while (i < n && src[i] !== '\n') i++;
5906
+ } else if (c === '<') {
5907
+ let depth = 0;
5908
+ while (i < n) {
5909
+ if (src[i] === '<') depth++;
5910
+ else if (src[i] === '>' && --depth === 0) break;
5911
+ i++;
5912
+ }
5913
+ i++;
5914
+ } else if (c === '{') {
5915
+ return i;
5916
+ } else {
5917
+ i++;
5918
+ }
5919
+ }
5920
+ return -1;
5921
+ }
5922
+
5646
5923
  // Render every deferred dot/graphviz placeholder to inline SVG using the
5647
5924
  // in-process WASM engine. Loads the WASM module only when at least one dot
5648
5925
  // block exists, so text-only docs pay nothing.
@@ -5659,11 +5936,35 @@ async function bakeGraphviz(htmlStr) {
5659
5936
  return htmlStr.replace(GRAPHVIZ_PLACEHOLDER.re, (m, b64) => {
5660
5937
  const dotSrc = Buffer.from(b64, 'base64').toString('utf8');
5661
5938
  try {
5662
- const svg = gv.dot(dotSrc)
5939
+ // Graphviz measures label widths with the font it lays out in, and with
5940
+ // no fontname that is Times, whose metrics are narrower than the sans
5941
+ // the SVG is drawn in -- so labels overflowed their boxes. Declare
5942
+ // Helvetica BEFORE layout, right after the first brace; a fontname the
5943
+ // author writes later in the source still wins. Exactly "Helvetica":
5944
+ // a "Helvetica,sans-Serif" family list falls back to Times widths.
5945
+ const at = firstDotBrace(dotSrc);
5946
+ const laidOut = at < 0 ? dotSrc : dotSrc.slice(0, at + 1) +
5947
+ ' graph [fontname="Helvetica"]; node [fontname="Helvetica"]; edge [fontname="Helvetica"]; ' + dotSrc.slice(at + 1);
5948
+ let laid;
5949
+ try {
5950
+ laid = gv.dot(laidOut);
5951
+ } catch (injectErr) {
5952
+ // Injection must never turn a valid graph into an error: lay the
5953
+ // original source out as the author wrote it.
5954
+ if (laidOut === dotSrc) throw injectErr;
5955
+ laid = gv.dot(dotSrc);
5956
+ }
5957
+ const svg = laid
5663
5958
  .replace(/<\?xml[^>]*\?>/g, '')
5664
5959
  .replace(/<!DOCTYPE[\s\S]*?>/g, '')
5665
5960
  // drop graphviz's "Generated by graphviz" banner comment so the div and <svg> are adjacent
5666
5961
  .replace(/<!--[\s\S]*?-->/g, '')
5962
+ // v3.8.0: graphviz falls back to Times when no fontname is set. The
5963
+ // injection above makes Helvetica the layout font, so this rewrite
5964
+ // now only touches an author's explicit Times-Roman, or a figure
5965
+ // whose injection fell back to the original source; other explicit
5966
+ // fontnames are untouched.
5967
+ .replace(/font-family="Times,serif"/g, 'font-family="Helvetica,sans-Serif"')
5667
5968
  .trim();
5668
5969
  return `<div class="graphviz">${svg}</div>`;
5669
5970
  } catch (e) {
@@ -5825,6 +6126,7 @@ async function bakeDiagrams(htmlStr, dstPath) {
5825
6126
  if (require.main === module) {
5826
6127
  const [,, src, dst] = process.argv;
5827
6128
  const BAKE_SVG = process.argv.slice(4).includes('--bake-svg');
6129
+ const LANG_ARG = (process.argv.slice(4).find((a) => a.startsWith('--lang=')) || '').slice('--lang='.length) || undefined;
5828
6130
  if (!src || !dst) {
5829
6131
  console.error('Usage: node md2doc.js <input.md> <output.html|pdf>');
5830
6132
  process.exit(1);
@@ -5834,7 +6136,7 @@ if (require.main === module) {
5834
6136
 
5835
6137
  (async () => {
5836
6138
  const mdText = fs.readFileSync(src, 'utf8');
5837
- const { html } = await renderMarkdown(mdText, path.resolve(src), {});
6139
+ const { html } = await renderMarkdown(mdText, path.resolve(src), { lang: LANG_ARG });
5838
6140
  let finalHtml = html;
5839
6141
 
5840
6142
  if (ext === '.html') {