@helping-ai-workflow/md2doc 3.7.0 → 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/README.md CHANGED
@@ -74,6 +74,7 @@ also pass `--open`.
74
74
  | `--no-open` | Skip the viewer launch. |
75
75
  | `--quiet` | Suppress per-file progress messages. |
76
76
  | `--bake-svg` | Pre-render Mermaid / WaveDrom to inert SVG at generation time (HTML output only; needs Chromium). The output then contains no diagram JavaScript. |
77
+ | `--lang <tag>` | Set `<html lang>` (for example `en`, `zh-Hant`). Default: detected — `zh-Hant` when Chinese characters are at least 10% of the prose (code excluded), `ja` / `ko` when kana / Hangul make up at least 20% of the CJK characters, otherwise `en`. |
77
78
  | `--version`, `-v` | Print version. |
78
79
  | `--help`, `-h` | Print help. |
79
80
 
@@ -42,3 +42,24 @@ turndown, `@hpcc-js/wasm-graphviz`) are ordinary npm packages: they are
42
42
  resolved into your `node_modules` by your own installer, carry their own
43
43
  licence files there, and are not redistributed inside md2doc's tarball.
44
44
  They are out of scope for this file.
45
+
46
+ ## Lucide (icon path data)
47
+
48
+ The sidebar icons in `lib/md2doc.js` (`READER_ICONS`) use path data from
49
+ Lucide, https://lucide.dev — ISC License.
50
+
51
+ Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part
52
+ of Feather (MIT). All other copyright (c) for Lucide are held by Lucide
53
+ Contributors 2022.
54
+
55
+ Permission to use, copy, modify, and/or distribute this software for any
56
+ purpose with or without fee is hereby granted, provided that the above
57
+ copyright notice and this permission notice appear in all copies.
58
+
59
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
60
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
61
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
62
+ SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
63
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
64
+ OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
65
+ CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/bin/md2doc.js CHANGED
@@ -32,6 +32,7 @@ function printHelp() {
32
32
  ' --open Launch the platform viewer after render (default when --out is absent).',
33
33
  ' --no-open Skip the viewer launch.',
34
34
  ' --quiet Suppress per-file progress messages.',
35
+ ' --lang <tag> Set <html lang> (default: detected — zh-Hant for Chinese-heavy text, ja / ko for Japanese / Korean, else en).',
35
36
  ' --bake-svg Pre-render mermaid/wavedrom to inert SVG (HTML output only; needs Chromium).',
36
37
  ' --edit Serve listed .md files in the browser editor (localhost).',
37
38
  ' --port <n> Pin the editor server port (only with --edit).',
@@ -51,6 +52,7 @@ function parseArgs(argv) {
51
52
  let bakeSvg = false;
52
53
  let edit = false;
53
54
  let port = null;
55
+ let lang = null;
54
56
 
55
57
  for (let i = 0; i < argv.length; i++) {
56
58
  const a = argv[i];
@@ -96,6 +98,19 @@ function parseArgs(argv) {
96
98
  out = argv[i];
97
99
  continue;
98
100
  }
101
+ if (a === '--lang') {
102
+ i++;
103
+ if (i >= argv.length) {
104
+ process.stderr.write('error: --lang requires a value\n');
105
+ process.exit(2);
106
+ }
107
+ if (!/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(argv[i])) {
108
+ process.stderr.write('error: --lang must be a language tag such as en or zh-Hant\n');
109
+ process.exit(2);
110
+ }
111
+ lang = argv[i];
112
+ continue;
113
+ }
99
114
  if (a.startsWith('-')) {
100
115
  process.stderr.write('error: unknown flag ' + a + '\n');
101
116
  process.exit(2);
@@ -124,7 +139,7 @@ function parseArgs(argv) {
124
139
  open = (out === null);
125
140
  }
126
141
 
127
- return { inputs, formats, formatsExplicit, out, open, quiet, bakeSvg, edit, port };
142
+ return { inputs, formats, formatsExplicit, out, open, quiet, bakeSvg, edit, port, lang };
128
143
  }
129
144
 
130
145
  function shortHash(absPath) {
@@ -232,8 +247,8 @@ function main() {
232
247
  }
233
248
 
234
249
  if (args.edit) {
235
- if (args.formatsExplicit || args.out !== null || args.bakeSvg) {
236
- process.stderr.write('error: --edit cannot be combined with --html/--pdf/--out/--bake-svg\n');
250
+ if (args.formatsExplicit || args.out !== null || args.bakeSvg || args.lang !== null) {
251
+ process.stderr.write('error: --edit cannot be combined with --html/--pdf/--out/--bake-svg/--lang\n');
237
252
  process.exit(2);
238
253
  }
239
254
  for (const input of args.inputs) {
@@ -262,6 +277,7 @@ function main() {
262
277
  const stdio = args.quiet ? ['inherit', 'ignore', 'inherit'] : 'inherit';
263
278
  const childArgs = [LIB, input, output];
264
279
  if (args.bakeSvg) childArgs.push('--bake-svg');
280
+ if (args.lang) childArgs.push('--lang=' + args.lang);
265
281
  const r = spawnSync(process.execPath, childArgs, { stdio });
266
282
  if (r.status !== 0) {
267
283
  process.stderr.write('error: render failed for ' + input + ' (exit ' + r.status + ')\n');
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++) {
@@ -1609,6 +1700,10 @@ ${itemsHtml}
1609
1700
  reader-mode output never includes it, so the mobile drawer toggle there
1610
1701
  is unaffected. */
1611
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; }
1612
1707
  /* T11-2 step 5: enterSourceMode() (lib/editor/client.js) sets
1613
1708
  contentEl.hidden = true and mounts a plain textarea in its place, and
1614
1709
  setDocMode() writes docMode onto body as data-ed-mode. Measured on that
@@ -2137,15 +2232,24 @@ ${itemsHtml}
2137
2232
  overflow-x: auto; font-family: monospace; font-size: 12px;
2138
2233
  }`;
2139
2234
 
2140
- const html = `<!DOCTYPE html>
2141
- <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}">
2142
2246
  <head>
2143
2247
  <meta charset="UTF-8">
2144
2248
  <meta name="viewport" content="width=device-width, initial-scale=1">
2145
2249
  <title>${title}</title>
2146
2250
  <style>
2147
2251
  body {
2148
- 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;
2149
2253
  margin: 0;
2150
2254
  padding: 0;
2151
2255
  line-height: 1.65;
@@ -2174,7 +2278,9 @@ ${itemsHtml}
2174
2278
  min-width: 0;
2175
2279
  height: calc(100vh - 48px);
2176
2280
  overflow: hidden;
2177
- padding-right: 8px;
2281
+ padding: 12px 8px 12px 12px;
2282
+ background: #f3f5f7;
2283
+ border-radius: 10px;
2178
2284
  box-sizing: border-box;
2179
2285
  display: flex;
2180
2286
  flex-direction: column;
@@ -2184,7 +2290,7 @@ ${itemsHtml}
2184
2290
  flex-basis: 36px;
2185
2291
  width: 36px;
2186
2292
  min-width: 36px;
2187
- padding-right: 0;
2293
+ padding: 12px 4px;
2188
2294
  }
2189
2295
  body[data-toc-collapsed] .reader-tools,
2190
2296
  body[data-toc-collapsed] .search-results,
@@ -2240,26 +2346,13 @@ ${itemsHtml}
2240
2346
  display: none;
2241
2347
  }
2242
2348
  #toc-collapse-toggle {
2243
- margin-left: 0;
2244
- padding: 2px 8px;
2245
- font: inherit;
2246
- font-size: 0.9em;
2247
- line-height: 1;
2248
- border: 1px solid #d0d7de;
2249
- border-radius: 6px;
2250
- background: #ffffff;
2251
- color: #57606a;
2252
- cursor: pointer;
2253
2349
  transition: transform 0.15s ease;
2254
2350
  }
2255
- #toc-collapse-toggle:hover {
2256
- background: #eef2f6;
2257
- color: #24292e;
2258
- }
2259
2351
  @media (max-width: 1080px) {
2260
2352
  #toc-collapse-toggle { display: none; }
2261
2353
  }
2262
2354
  .reader-tools { flex: 0 0 auto; }
2355
+ .mobile-bar { display: none; }
2263
2356
  .sidebar-toggle {
2264
2357
  display: none;
2265
2358
  position: fixed;
@@ -2304,65 +2397,91 @@ ${itemsHtml}
2304
2397
  .reader-tools,
2305
2398
  .search-results,
2306
2399
  .toc {
2307
- border: 1px solid #d0d7de;
2308
- border-radius: 10px;
2309
- background: #f8fafc;
2310
- padding: 12px 14px;
2400
+ border: none;
2401
+ background: transparent;
2402
+ padding: 2px 0;
2311
2403
  }
2312
- .reader-search-label {
2313
- display: block;
2314
- font-size: 0.78rem;
2315
- font-weight: 700;
2316
- letter-spacing: 0.08em;
2317
- text-transform: uppercase;
2318
- color: #57606a;
2319
- 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;
2320
2415
  }
2321
2416
  .reader-search-row {
2417
+ position: relative;
2322
2418
  display: flex;
2323
- gap: 6px;
2324
- margin-bottom: 8px;
2419
+ margin-bottom: 4px;
2325
2420
  }
2326
2421
  .reader-search-row input[type="search"] {
2327
2422
  flex: 1 1 auto;
2328
2423
  min-width: 0;
2329
- padding: 6px 8px;
2424
+ padding: 7px 30px 7px 10px;
2330
2425
  border: 1px solid #d0d7de;
2331
2426
  border-radius: 6px;
2427
+ background: #ffffff;
2332
2428
  font: inherit;
2333
2429
  }
2334
- .reader-search-row button {
2335
- padding: 4px 10px;
2336
- font: inherit;
2337
- font-size: 0.85em;
2338
- border: 1px solid #d0d7de;
2339
- border-radius: 6px;
2340
- 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;
2341
2448
  cursor: pointer;
2342
2449
  }
2343
- .reader-search-row button:hover {
2344
- 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;
2345
2456
  }
2346
2457
  .search-results-header,
2347
2458
  .toc-header {
2348
2459
  display: flex;
2349
- flex-direction: column;
2350
- align-items: stretch;
2351
- gap: 4px;
2460
+ flex-direction: row;
2461
+ align-items: center;
2462
+ gap: 6px;
2352
2463
  margin-bottom: 8px;
2353
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
+ }
2354
2473
  .toc-header-actions {
2355
2474
  flex: 0 0 auto;
2356
2475
  display: flex;
2357
- gap: 6px;
2476
+ gap: 2px;
2358
2477
  justify-content: flex-end;
2359
2478
  }
2360
- /* Sticky breadcrumb: stacked ancestor chain of the current scroll position.
2361
- 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. */
2362
2481
  .toc-breadcrumb {
2363
2482
  flex: 1 1 auto;
2364
2483
  min-width: 0;
2365
- display: flex;
2484
+ display: none;
2366
2485
  flex-direction: column;
2367
2486
  gap: 1px;
2368
2487
  }
@@ -2387,13 +2506,14 @@ ${itemsHtml}
2387
2506
  }
2388
2507
  .search-results-title,
2389
2508
  .toc-title {
2390
- font-size: 0.78rem;
2391
- font-weight: 700;
2392
- letter-spacing: 0.08em;
2393
- text-transform: uppercase;
2394
- color: #57606a;
2509
+ font-size: 0.86rem;
2510
+ font-weight: 600;
2511
+ color: #1f2328;
2395
2512
  flex: 0 0 auto;
2396
2513
  }
2514
+ body[data-toc-collapsed] .toc-title {
2515
+ display: none;
2516
+ }
2397
2517
  .reader-status {
2398
2518
  flex: 1 1 auto;
2399
2519
  font-size: 0.82em;
@@ -2401,20 +2521,21 @@ ${itemsHtml}
2401
2521
  }
2402
2522
  .search-results-header button,
2403
2523
  .toc-header button {
2404
- padding: 2px 8px;
2524
+ display: inline-grid;
2525
+ place-items: center;
2526
+ padding: 4px;
2405
2527
  font: inherit;
2406
- font-size: 0.9em;
2407
- line-height: 1;
2408
- border: 1px solid #d0d7de;
2528
+ line-height: 0;
2529
+ border: none;
2409
2530
  border-radius: 6px;
2410
- background: #ffffff;
2411
- color: #57606a;
2531
+ background: transparent;
2532
+ color: #59636e;
2412
2533
  cursor: pointer;
2413
2534
  }
2414
2535
  .search-results-header button:hover,
2415
2536
  .toc-header button:hover {
2416
- background: #eef2f6;
2417
- color: #24292e;
2537
+ background: #e3e8ee;
2538
+ color: #1f2328;
2418
2539
  }
2419
2540
  .search-results-header button:disabled {
2420
2541
  opacity: 0.45;
@@ -2460,8 +2581,8 @@ ${itemsHtml}
2460
2581
  background: #eef2f6;
2461
2582
  }
2462
2583
  .search-result-item.is-active {
2463
- background: #dbeafe;
2464
- box-shadow: inset 0 0 0 1px #93c5fd;
2584
+ background: #dbe6f3;
2585
+ box-shadow: none;
2465
2586
  }
2466
2587
  .search-result-title {
2467
2588
  font-weight: 600;
@@ -2510,6 +2631,8 @@ ${itemsHtml}
2510
2631
  the shift+wheel handler; appears only when a title actually overflows. */
2511
2632
  overflow-x: auto;
2512
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);
2513
2636
  }
2514
2637
  .toc ul {
2515
2638
  list-style: none;
@@ -2534,13 +2657,15 @@ ${itemsHtml}
2534
2657
  min-width: 100%;
2535
2658
  color: #57606a;
2536
2659
  text-decoration: none;
2537
- padding: 2px 0;
2660
+ padding: 2px 8px;
2538
2661
  line-height: 1.4;
2539
2662
  white-space: nowrap;
2540
2663
  box-sizing: border-box;
2664
+ border-radius: 4px;
2541
2665
  }
2542
2666
  .toc summary > a {
2543
- flex: 0 0 auto;
2667
+ flex: 1 0 auto;
2668
+ min-width: 0;
2544
2669
  }
2545
2670
  .toc summary {
2546
2671
  min-width: 0;
@@ -2550,17 +2675,41 @@ ${itemsHtml}
2550
2675
  }
2551
2676
  .toc a:hover {
2552
2677
  color: #0969da;
2678
+ background: #e8edf2;
2553
2679
  }
2554
2680
  .toc a.is-active {
2555
- color: #0b57d0;
2556
- font-weight: 700;
2681
+ color: #0550ae;
2682
+ font-weight: 600;
2683
+ background: #dbe6f3;
2557
2684
  }
2558
2685
  .toc a.is-match {
2559
- color: #355070;
2560
- 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;
2561
2698
  border-radius: 4px;
2562
- padding-left: 4px;
2563
- 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;
2564
2713
  }
2565
2714
  .toc details {
2566
2715
  margin: 0;
@@ -2582,6 +2731,8 @@ ${itemsHtml}
2582
2731
  font-size: 0.78em;
2583
2732
  line-height: 1.8;
2584
2733
  flex: 0 0 auto;
2734
+ width: 12px;
2735
+ text-align: center;
2585
2736
  transform: translateY(1px);
2586
2737
  }
2587
2738
  .toc details[open] > summary::before {
@@ -2593,15 +2744,15 @@ ${itemsHtml}
2593
2744
  border-left: 1px solid #d8dee4;
2594
2745
  }
2595
2746
  .toc-item:not(.toc-parent) > a {
2596
- padding-left: 18px;
2747
+ padding-left: 26px;
2597
2748
  }
2598
2749
  .toc-list-level-1 > .toc-item > a,
2599
2750
  .toc-list-level-1 > .toc-item > details > summary > a {
2600
- font-weight: 600;
2751
+ font-size: 0.95em;
2601
2752
  }
2602
2753
  .toc-list-level-2 > .toc-item > a,
2603
2754
  .toc-list-level-2 > .toc-item > details > summary > a {
2604
- font-size: 0.95em;
2755
+ font-size: 0.9em;
2605
2756
  }
2606
2757
  .toc-list-level-3 > .toc-item > a,
2607
2758
  .toc-list-level-3 > .toc-item > details > summary > a,
@@ -2633,6 +2784,19 @@ ${itemsHtml}
2633
2784
  }
2634
2785
  .content > * { max-width: 100%; }
2635
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; }
2636
2800
  .heading-with-anchor {
2637
2801
  position: relative;
2638
2802
  }
@@ -2653,14 +2817,15 @@ ${itemsHtml}
2653
2817
  color: #0969da;
2654
2818
  }
2655
2819
  h1 { font-size: 2em; border-bottom: 2px solid #e1e4e8; padding-bottom: 10px; margin-top: 1.5em; }
2656
- h2 { font-size: 1.5em; border-bottom: 1px solid #e1e4e8; padding-bottom: 6px; margin-top: 1.4em; }
2657
- h3 { font-size: 1.2em; margin-top: 1.3em; }
2658
- 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; }
2659
2824
  code {
2660
2825
  background: #f6f8fa;
2661
2826
  padding: 2px 5px;
2662
2827
  border-radius: 3px;
2663
- font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
2828
+ font-family: "Cascadia Mono", Consolas, "SFMono-Regular", "Liberation Mono", Menlo, "Microsoft JhengHei", monospace;
2664
2829
  font-size: 0.875em;
2665
2830
  }
2666
2831
  pre {
@@ -2671,6 +2836,13 @@ ${itemsHtml}
2671
2836
  line-height: 1.45;
2672
2837
  }
2673
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
+ }
2674
2846
  table {
2675
2847
  display: block;
2676
2848
  border-collapse: collapse;
@@ -2680,9 +2852,8 @@ ${itemsHtml}
2680
2852
  font-size: 0.9em;
2681
2853
  overflow-x: auto;
2682
2854
  }
2683
- th, td { border: 1px solid #dfe2e5; padding: 7px 14px; text-align: left; }
2684
- th { background: #f6f8fa; font-weight: 600; }
2685
- 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; }
2686
2857
  .content table th,
2687
2858
  .content table td {
2688
2859
  overflow-wrap: normal;
@@ -2695,7 +2866,7 @@ ${itemsHtml}
2695
2866
  .content table th code {
2696
2867
  white-space: nowrap;
2697
2868
  }
2698
- .content table { table-layout: auto; }
2869
+ .content table { table-layout: auto; font-variant-numeric: tabular-nums; }
2699
2870
  .content table col.col-narrow { width: 1%; }
2700
2871
  .content table col.col-prose { width: auto; }
2701
2872
  .content table th.cell-narrow,
@@ -2708,10 +2879,7 @@ ${itemsHtml}
2708
2879
  background: #ffffff;
2709
2880
  }
2710
2881
  .content table thead th:first-child {
2711
- background: #f6f8fa;
2712
- }
2713
- .content table tbody tr:nth-child(even) td:first-child {
2714
- background: #fafbfc;
2882
+ background: #ffffff;
2715
2883
  }
2716
2884
  blockquote {
2717
2885
  border-left: 4px solid #dfe2e5;
@@ -2925,21 +3093,59 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
2925
3093
  html[data-lightbox-open] { overflow: hidden; }
2926
3094
  @media print { .lightbox { display: none !important; } }
2927
3095
  @media (max-width: 1080px) {
3096
+ .content table td.cell-prose,
3097
+ .content table th.cell-prose { min-width: 15em; }
2928
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
+ }
2929
3135
  .page-layout {
2930
3136
  display: block;
2931
3137
  max-width: 100%;
2932
- padding-top: 60px;
2933
3138
  }
2934
3139
  .reader-sidebar {
2935
3140
  position: fixed;
2936
- top: 0;
3141
+ top: 44px;
2937
3142
  left: 0;
2938
3143
  bottom: 0;
2939
3144
  width: 85%;
2940
3145
  max-width: 360px;
2941
- height: 100vh;
2942
- background: #ffffff;
3146
+ height: calc(100vh - 44px);
3147
+ background: #f3f5f7;
3148
+ border-radius: 0;
2943
3149
  z-index: 99;
2944
3150
  transform: translateX(-100%);
2945
3151
  transition: transform 0.2s ease;
@@ -2949,10 +3155,9 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
2949
3155
  display: flex;
2950
3156
  flex-direction: column;
2951
3157
  gap: 12px;
2952
- box-shadow: 2px 0 12px rgba(0,0,0,0.15);
2953
3158
  flex: initial;
2954
3159
  }
2955
- 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); }
2956
3161
  /* Scroll lock for the drawer, same two-rule shape the lightbox uses a
2957
3162
  few lines above (body[data-lightbox-open] / html[data-lightbox-open])
2958
3163
  and for the identical reason: 'html, body { overflow-x: clip }' keeps
@@ -2969,7 +3174,6 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
2969
3174
  body[data-sidebar-open] { overflow: hidden; }
2970
3175
  html[data-sidebar-open] { overflow: hidden; }
2971
3176
  .sidebar-splitter { display: none; }
2972
- .heading-anchor { opacity: 1; }
2973
3177
  }
2974
3178
  @media print {
2975
3179
  body { font-size: 11pt; }
@@ -2994,10 +3198,19 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
2994
3198
  padding: 0 10px;
2995
3199
  }
2996
3200
  .reader-sidebar,
3201
+ .mobile-bar,
2997
3202
  .sidebar-toggle,
2998
3203
  .sidebar-splitter,
2999
3204
  .sidebar-scrim { display: none !important; }
3000
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; }
3001
3214
  .heading-anchor { display: none; }
3002
3215
  pre { font-size: 9pt; }
3003
3216
  a[href]:after { content: none; }
@@ -3015,18 +3228,17 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
3015
3228
  .ed-te-hl).
3016
3229
 
3017
3230
  The descendant rules and their !important are not decoration, they are
3018
- the whole problem: <pre> (#f6f8fa), <th> (#f6f8fa), the zebra stripe
3019
- tr:nth-child(even) (#fafbfc) and the sticky first column (#ffffff) all
3231
+ the whole problem: <pre> (#f6f8fa) and the sticky first column (#ffffff)
3020
3232
  carry OPAQUE backgrounds, and the sticky column additionally paints at
3021
- z-index: 1 over anything drawn beneath it. Two of those beat a plain
3022
- descendant selector on specificity alone — counted:
3023
- .content table tbody tr:nth-child(even) td:first-child is (0,3,4)
3024
- against this rule's (0,3,2), and .content table tbody td:first-child is
3025
- (0,2,3) against the same (0,3,2) — so without !important the tint is
3026
- invisible on exactly the blocks a user is most likely to select. .ed-te-hl
3027
- needed the identical treatment for the identical reason. This ADDS rules
3028
- rather than reordering the sticky/zebra/header set, which CLAUDE.md says
3029
- 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.
3030
3242
  (No backticks in this comment: it lives inside a JS template literal.)
3031
3243
 
3032
3244
  :focus outline is suppressed because the roving tabindex below focuses a
@@ -3723,8 +3935,7 @@ ${opts.editMode ? editModeLayoutCss : ''}
3723
3935
  ${usesMath ? buildKatexStyleTag() : ''}
3724
3936
  </head>
3725
3937
  <body>
3726
- <button class="sidebar-toggle" id="sidebar-toggle" type="button" aria-label="Toggle sidebar" aria-expanded="false">☰</button>
3727
- <div class="sidebar-scrim" id="sidebar-scrim"></div>
3938
+ ${mobileBarHtml}<div class="sidebar-scrim" id="sidebar-scrim"></div>
3728
3939
  <div class="page-layout">
3729
3940
  ${tocHtml}
3730
3941
  <main class="content">
@@ -3812,11 +4023,15 @@ ${mermaidInitTag}`)
3812
4023
  }
3813
4024
 
3814
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;
3815
4028
  const sectionIndexById = new Map(sections.map((section, i) => [section.id, i]));
3816
4029
 
3817
4030
  // Sticky breadcrumb: the ancestor chain (shallow→deep) of the active section.
3818
4031
  // For each decreasing depth, take the nearest preceding section, then append
3819
- // 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.
3820
4035
  function renderBreadcrumb(sectionId) {
3821
4036
  if (!breadcrumbEl) return;
3822
4037
  if (!sectionIndexById.has(sectionId)) {
@@ -3837,6 +4052,7 @@ ${mermaidInitTag}`)
3837
4052
  return;
3838
4053
  }
3839
4054
  const minDepth = chain[0].depth;
4055
+ if (mobileBarTitle) mobileBarTitle.textContent = chain[chain.length - 1].title;
3840
4056
  breadcrumbEl.innerHTML = chain
3841
4057
  .map((section, i) => {
3842
4058
  const indent = (section.depth - minDepth) * 10;
@@ -4416,18 +4632,30 @@ ${mermaidInitTag}`)
4416
4632
  var hit = document.elementFromPoint(rect.left + rect.width / 2, ANCHOR_LINE);
4417
4633
  var node = (hit && hit.closest) ? hit.closest('main.content > *') : null;
4418
4634
  if (!node) {
4419
- // Between blocks, or over a gap: fall back to the last heading above the
4420
- // anchor line. Heading offsets are monotonic in document order, so this
4421
- // 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;
4422
4641
  var lo = 0;
4423
- var hi = headingNodes.length - 1;
4642
+ var hi = kids.length - 1;
4424
4643
  while (lo <= hi) {
4425
4644
  var mid = (lo + hi) >> 1;
4426
- if (headingNodes[mid].getBoundingClientRect().top <= ANCHOR_LINE) {
4427
- node = headingNodes[mid];
4428
- lo = mid + 1;
4429
- } 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) {
4430
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;
4431
4659
  }
4432
4660
  }
4433
4661
  }
@@ -5656,6 +5884,42 @@ ${usesDrawioMultiPage ? ` // v3.4.0 batch2 Task 6: wired through a NAMED, reusa
5656
5884
  }
5657
5885
  module.exports = { renderMarkdown };
5658
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
+
5659
5923
  // Render every deferred dot/graphviz placeholder to inline SVG using the
5660
5924
  // in-process WASM engine. Loads the WASM module only when at least one dot
5661
5925
  // block exists, so text-only docs pay nothing.
@@ -5672,11 +5936,35 @@ async function bakeGraphviz(htmlStr) {
5672
5936
  return htmlStr.replace(GRAPHVIZ_PLACEHOLDER.re, (m, b64) => {
5673
5937
  const dotSrc = Buffer.from(b64, 'base64').toString('utf8');
5674
5938
  try {
5675
- 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
5676
5958
  .replace(/<\?xml[^>]*\?>/g, '')
5677
5959
  .replace(/<!DOCTYPE[\s\S]*?>/g, '')
5678
5960
  // drop graphviz's "Generated by graphviz" banner comment so the div and <svg> are adjacent
5679
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"')
5680
5968
  .trim();
5681
5969
  return `<div class="graphviz">${svg}</div>`;
5682
5970
  } catch (e) {
@@ -5838,6 +6126,7 @@ async function bakeDiagrams(htmlStr, dstPath) {
5838
6126
  if (require.main === module) {
5839
6127
  const [,, src, dst] = process.argv;
5840
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;
5841
6130
  if (!src || !dst) {
5842
6131
  console.error('Usage: node md2doc.js <input.md> <output.html|pdf>');
5843
6132
  process.exit(1);
@@ -5847,7 +6136,7 @@ if (require.main === module) {
5847
6136
 
5848
6137
  (async () => {
5849
6138
  const mdText = fs.readFileSync(src, 'utf8');
5850
- const { html } = await renderMarkdown(mdText, path.resolve(src), {});
6139
+ const { html } = await renderMarkdown(mdText, path.resolve(src), { lang: LANG_ARG });
5851
6140
  let finalHtml = html;
5852
6141
 
5853
6142
  if (ext === '.html') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "3.7.0",
3
+ "version": "3.8.0",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, Graphviz, and draw.io support",
5
5
  "keywords": [
6
6
  "markdown",
@@ -45,7 +45,8 @@
45
45
  },
46
46
  "scripts": {
47
47
  "preinstall": "node scripts/preinstall.js",
48
- "test": "node test/md2doc.test.js && node test/blockmap.test.js && node test/drawio.test.js && node test/images.test.js && node test/scroll-anchor.test.js && node test/lightbox.test.js && node test/lightbox-anno.test.js && node test/lightbox-anno-style.test.js && node test/reader-panels.test.js && node test/cli.test.js && node test/code-operator.test.js && node test/convert-md.test.js && node test/render-api.test.js && node test/lineops.test.js && node test/indent-clamp.test.js && node test/selection.test.js && node test/cell-range.test.js && node test/editmode-render.test.js && node test/editmode-wavedrom-reinit.test.js && node test/editor-server.test.js && node test/cli-edit.test.js && node test/open-viewer.test.js && node test/editor-client.test.js && node test/editor-client-runtime.test.js && node test/roundtrip.test.js && node test/byte-stability.test.js && node test/editor-server-throw.test.js && node test/editor-reader-rebind.test.js && node test/inline-md.test.js && node test/table-md.test.js && node test/table-anchor-recovery.test.js && node test/gate-compat.test.js && node test/history.test.js && node test/list-md.test.js && node test/table-width.test.js && node test/toolbar-model.test.js && node test/docsource.test.js && node test/asset.test.js && node test/paste-md.test.js && node test/patchmap.test.js && node test/wave-codec.test.js && node test/wave-draw.test.js && node test/wave-geometry.test.js && node test/wave-store.test.js && node test/wave-rail-clearance.test.js && node test/editor-journey.test.js && node test/detach-census.test.js && node test/editor-arrow-nav.test.js && node test/editor-cell-range.test.js"
48
+ "test": "node test/md2doc.test.js && node test/blockmap.test.js && node test/drawio.test.js && node test/images.test.js && node test/scroll-anchor.test.js && node test/lightbox.test.js && node test/lightbox-anno.test.js && node test/lightbox-anno-style.test.js && node test/reader-panels.test.js && node test/cli.test.js && node test/code-operator.test.js && node test/convert-md.test.js && node test/render-api.test.js && node test/lineops.test.js && node test/indent-clamp.test.js && node test/selection.test.js && node test/cell-range.test.js && node test/editmode-render.test.js && node test/editmode-wavedrom-reinit.test.js && node test/editor-server.test.js && node test/cli-edit.test.js && node test/open-viewer.test.js && node test/editor-client.test.js && node test/editor-client-runtime.test.js && node test/roundtrip.test.js && node test/byte-stability.test.js && node test/editor-server-throw.test.js && node test/editor-reader-rebind.test.js && node test/inline-md.test.js && node test/table-md.test.js && node test/table-anchor-recovery.test.js && node test/gate-compat.test.js && node test/history.test.js && node test/list-md.test.js && node test/table-width.test.js && node test/toolbar-model.test.js && node test/docsource.test.js && node test/asset.test.js && node test/paste-md.test.js && node test/patchmap.test.js && node test/wave-codec.test.js && node test/wave-draw.test.js && node test/wave-geometry.test.js && node test/wave-store.test.js && node test/wave-rail-clearance.test.js && node test/editor-journey.test.js && node test/detach-census.test.js && node test/editor-arrow-nav.test.js && node test/editor-cell-range.test.js && node test/reader-design.test.js",
49
+ "test:browser": "node test/reader-click.test.js"
49
50
  },
50
51
  "repository": {
51
52
  "type": "git",
@@ -57,5 +58,8 @@
57
58
  "license": "MIT AND Apache-2.0",
58
59
  "publishConfig": {
59
60
  "access": "public"
61
+ },
62
+ "devDependencies": {
63
+ "playwright": "1.63.0"
60
64
  }
61
65
  }