@helping-ai-workflow/md2doc 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/lib/md2doc.js +161 -14
  2. package/package.json +3 -1
package/lib/md2doc.js CHANGED
@@ -109,12 +109,32 @@ const mermaidInitTag = `<script type="text/javascript" data-md2doc-diagram-engin
109
109
 
110
110
  let usesMermaid = false;
111
111
  let usesWaveDrom = false;
112
+ let usesMath = false;
113
+
114
+ // Self-contained KaTeX stylesheet: inline katex.min.css with each woff2 @font-face
115
+ // rewritten to a base64 data: URI and the woff/ttf alternates stripped, so a
116
+ // math-bearing HTML prints offline (and in puppeteer PDF) with no font fetch.
117
+ // Built lazily — only when a document actually contains math.
118
+ function buildKatexStyleTag() {
119
+ const cssPath = require.resolve('katex/dist/katex.min.css');
120
+ const fontDir = path.join(path.dirname(cssPath), 'fonts');
121
+ let css = fs.readFileSync(cssPath, 'utf8');
122
+ css = css.replace(/url\(fonts\/(KaTeX_[\w-]+)\.woff2\)/g, (_, name) => {
123
+ const b64 = fs.readFileSync(path.join(fontDir, `${name}.woff2`)).toString('base64');
124
+ return `url(data:font/woff2;base64,${b64})`;
125
+ });
126
+ // Drop the now-redundant woff/ttf src alternates (woff2 is universal in modern
127
+ // browsers + puppeteer Chromium), so nothing references the on-disk font files.
128
+ css = css.replace(/,url\(fonts\/[\w-]+\.(?:woff|ttf)\) format\("(?:woff|truetype)"\)/g, '');
129
+ return `<style data-md2doc-math>${css}</style>`;
130
+ }
112
131
 
113
132
  let bodyHtml;
114
133
  let tocHtml = '';
115
134
  let serializedSections = '[]';
116
135
  try {
117
136
  const { marked, Renderer } = require('marked');
137
+ const katex = require('katex');
118
138
 
119
139
  const renderer = new Renderer();
120
140
  const tocItems = [];
@@ -178,7 +198,7 @@ try {
178
198
 
179
199
  const itemsHtml = nodes
180
200
  .map((node) => {
181
- const linkHtml = `<a href="#${node.id}">${escapeHtml(node.text)}</a>`;
201
+ const linkHtml = `<a href="#${node.id}" title="${escapeHtml(node.text)}">${escapeHtml(node.text)}</a>`;
182
202
  const hasChildren = node.children && node.children.length > 0;
183
203
 
184
204
  if (!hasChildren) {
@@ -322,6 +342,17 @@ ${itemsHtml}
322
342
  // away — diverging from GitHub's escaped-code-block semantics.
323
343
  return `\n<div class="mermaid">\n${escapeHtml(code)}\n</div>\n`;
324
344
  }
345
+ if (lang === 'math') {
346
+ // KaTeX renders synchronously to static HTML (class="katex"); no client
347
+ // runtime, no async post-pass. throwOnError:false degrades a bad formula
348
+ // to red error text instead of crashing the whole render.
349
+ try {
350
+ return `\n${katex.renderToString(code, { displayMode: true, throwOnError: false })}\n`;
351
+ } catch (e) {
352
+ const esc = code.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
353
+ return `<pre><code class="language-math">${esc}</code></pre>\n`;
354
+ }
355
+ }
325
356
  if (lang === 'dot' || lang === 'graphviz') {
326
357
  // Defer rendering to the async bakeGraphviz() post-pass so the
327
358
  // synchronous marked() pass stays sync. The dot source is carried as
@@ -454,6 +485,12 @@ ${itemsHtml}
454
485
  ],
455
486
  });
456
487
 
488
+ // $$…$$ (display) and $…$ (inline) math via KaTeX. marked tokenizes code
489
+ // spans/fences first, so $ inside code stays literal; the extension's
490
+ // default no-space-adjacency rules keep prose currency ($5 to $10) unrendered.
491
+ const markedKatex = require('marked-katex-extension');
492
+ marked.use(markedKatex({ throwOnError: false }));
493
+
457
494
  // Pre-process the remaining non-standard inline syntax before marked parses
458
495
  const escAttr = (s) => String(s)
459
496
  .replace(/&/g, '&amp;')
@@ -468,6 +505,9 @@ ${itemsHtml}
468
505
  });
469
506
 
470
507
  bodyHtml = marked.parse(mdPre);
508
+ // Both the ```math fence and the $/$$ extension emit class="katex" — a single
509
+ // post-parse scan is the source of truth for conditional CSS injection.
510
+ usesMath = /class="katex/.test(bodyHtml);
471
511
  serializedSections = JSON.stringify(
472
512
  sections.map((section) => ({
473
513
  id: section.id,
@@ -498,10 +538,12 @@ ${itemsHtml}
498
538
  </section>
499
539
  <nav class="toc" aria-label="Table of contents" data-reader-toc>
500
540
  <div class="toc-header">
501
- <span class="toc-title">Contents</span>
502
- <button id="toc-expand-all" type="button" aria-label="Expand all">⊞</button>
503
- <button id="toc-collapse-all" type="button" aria-label="Collapse all">⊟</button>
504
- <button id="toc-collapse-toggle" type="button" aria-label="Collapse table of contents" title="Collapse / expand sidebar">◀</button>
541
+ <div class="toc-header-actions">
542
+ <button id="toc-expand-all" type="button" aria-label="Expand all">⊞</button>
543
+ <button id="toc-collapse-all" type="button" aria-label="Collapse all">⊟</button>
544
+ <button id="toc-collapse-toggle" type="button" aria-label="Collapse table of contents" title="Collapse / expand sidebar">◀</button>
545
+ </div>
546
+ <div class="toc-breadcrumb" data-toc-breadcrumb aria-label="Current location"></div>
505
547
  </div>
506
548
  ${renderTocNodes(tocTree)}
507
549
  </nav>
@@ -566,12 +608,29 @@ const html = `<!DOCTYPE html>
566
608
  body[data-toc-collapsed] .reader-tools,
567
609
  body[data-toc-collapsed] .search-results,
568
610
  body[data-toc-collapsed] .toc > .toc-list,
569
- body[data-toc-collapsed] .toc-title {
611
+ body[data-toc-collapsed] .toc-breadcrumb {
570
612
  display: none;
571
613
  }
572
614
  body[data-toc-collapsed] #toc-collapse-toggle {
573
615
  transform: rotate(180deg);
574
616
  }
617
+ /* In the 36px collapsed rail only the restore toggle remains: hide the
618
+ TOC-list buttons (no list to act on) and shrink padding so the toggle
619
+ fits inside the rail instead of being clipped by overflow:hidden. */
620
+ body[data-toc-collapsed] #toc-expand-all,
621
+ body[data-toc-collapsed] #toc-collapse-all {
622
+ display: none;
623
+ }
624
+ body[data-toc-collapsed] .toc {
625
+ padding: 12px 3px;
626
+ }
627
+ body[data-toc-collapsed] .toc-header {
628
+ margin-bottom: 0;
629
+ justify-content: center;
630
+ }
631
+ body[data-toc-collapsed] .toc-header-actions {
632
+ justify-content: center;
633
+ }
575
634
  #toc-collapse-toggle {
576
635
  margin-left: 0;
577
636
  padding: 2px 8px;
@@ -661,10 +720,45 @@ const html = `<!DOCTYPE html>
661
720
  .search-results-header,
662
721
  .toc-header {
663
722
  display: flex;
664
- align-items: center;
665
- gap: 6px;
723
+ flex-direction: column;
724
+ align-items: stretch;
725
+ gap: 4px;
666
726
  margin-bottom: 8px;
667
727
  }
728
+ .toc-header-actions {
729
+ flex: 0 0 auto;
730
+ display: flex;
731
+ gap: 6px;
732
+ justify-content: flex-end;
733
+ }
734
+ /* Sticky breadcrumb: stacked ancestor chain of the current scroll position.
735
+ Each row is single-line + ellipsis; full text on hover via title=. */
736
+ .toc-breadcrumb {
737
+ flex: 1 1 auto;
738
+ min-width: 0;
739
+ display: flex;
740
+ flex-direction: column;
741
+ gap: 1px;
742
+ }
743
+ .toc-breadcrumb a {
744
+ display: block;
745
+ max-width: 100%;
746
+ white-space: nowrap;
747
+ overflow: hidden;
748
+ text-overflow: ellipsis;
749
+ color: #57606a;
750
+ text-decoration: none;
751
+ font-size: 0.82em;
752
+ line-height: 1.55;
753
+ box-sizing: border-box;
754
+ }
755
+ .toc-breadcrumb a:hover {
756
+ color: #0969da;
757
+ }
758
+ .toc-breadcrumb a.breadcrumb-current {
759
+ color: #0b57d0;
760
+ font-weight: 700;
761
+ }
668
762
  .search-results-title,
669
763
  .toc-title {
670
764
  font-size: 0.78rem;
@@ -790,18 +884,26 @@ const html = `<!DOCTYPE html>
790
884
  margin-top: 2px;
791
885
  }
792
886
  .toc-item {
793
- margin: 1px 0;
887
+ margin: 0;
794
888
  }
795
889
  .toc a {
796
- display: inline-block;
890
+ display: block;
797
891
  max-width: 100%;
798
892
  color: #57606a;
799
893
  text-decoration: none;
800
- padding: 4px 0 4px 0;
801
- overflow-wrap: anywhere;
802
- word-break: break-word;
894
+ padding: 2px 0;
895
+ line-height: 1.4;
896
+ white-space: nowrap;
897
+ overflow: hidden;
898
+ text-overflow: ellipsis;
803
899
  box-sizing: border-box;
804
900
  }
901
+ /* Parent rows put the anchor inside a flex <summary>; default flex min-width
902
+ is auto, which would block ellipsis. Allow the anchor to shrink + clip. */
903
+ .toc summary > a {
904
+ min-width: 0;
905
+ flex: 0 1 auto;
906
+ }
805
907
  .toc summary {
806
908
  min-width: 0;
807
909
  }
@@ -831,7 +933,7 @@ const html = `<!DOCTYPE html>
831
933
  display: flex;
832
934
  align-items: flex-start;
833
935
  gap: 6px;
834
- padding: 2px 0;
936
+ padding: 0;
835
937
  }
836
938
  .toc summary::-webkit-details-marker {
837
939
  display: none;
@@ -1042,6 +1144,7 @@ const html = `<!DOCTYPE html>
1042
1144
  a[href]:after { content: none; }
1043
1145
  }
1044
1146
  </style>
1147
+ ${usesMath ? buildKatexStyleTag() : ''}
1045
1148
  </head>
1046
1149
  <body>
1047
1150
  <button class="sidebar-toggle" id="sidebar-toggle" type="button" aria-label="Toggle sidebar" aria-expanded="false">☰</button>
@@ -1106,6 +1209,43 @@ ${mermaidInitTag}` : ''}
1106
1209
  return String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
1107
1210
  }
1108
1211
 
1212
+ const breadcrumbEl = document.querySelector('[data-toc-breadcrumb]');
1213
+ const sectionIndexById = new Map(sections.map((section, i) => [section.id, i]));
1214
+
1215
+ // Sticky breadcrumb: the ancestor chain (shallow→deep) of the active section.
1216
+ // For each decreasing depth, take the nearest preceding section, then append
1217
+ // the active section itself. Rendered as stacked, indented, clickable rows.
1218
+ function renderBreadcrumb(sectionId) {
1219
+ if (!breadcrumbEl) return;
1220
+ if (!sectionIndexById.has(sectionId)) {
1221
+ breadcrumbEl.innerHTML = '';
1222
+ return;
1223
+ }
1224
+ const idx = sectionIndexById.get(sectionId);
1225
+ const chain = [];
1226
+ let need = sections[idx].depth;
1227
+ for (let i = idx; i >= 0 && need >= 1; i--) {
1228
+ if (sections[i].depth <= need) {
1229
+ chain.unshift(sections[i]);
1230
+ need = sections[i].depth - 1;
1231
+ }
1232
+ }
1233
+ if (!chain.length) {
1234
+ breadcrumbEl.innerHTML = '';
1235
+ return;
1236
+ }
1237
+ const minDepth = chain[0].depth;
1238
+ breadcrumbEl.innerHTML = chain
1239
+ .map((section, i) => {
1240
+ const indent = (section.depth - minDepth) * 10;
1241
+ const cls = i === chain.length - 1 ? ' class="breadcrumb-current"' : '';
1242
+ const title = escapeHtml(section.title);
1243
+ return '<a href="#' + section.id + '"' + cls + ' title="' + title +
1244
+ '" style="padding-left:' + indent + 'px">' + title + '</a>';
1245
+ })
1246
+ .join('');
1247
+ }
1248
+
1109
1249
  function expandTocAncestors(link) {
1110
1250
  let node = link && link.closest('details');
1111
1251
  while (node) {
@@ -1139,6 +1279,7 @@ ${mermaidInitTag}` : ''}
1139
1279
  previous.classList.remove('is-active');
1140
1280
  }
1141
1281
  readerState.activeSectionId = sectionId;
1282
+ renderBreadcrumb(sectionId);
1142
1283
  const next = tocLinks.get(sectionId);
1143
1284
  if (next) {
1144
1285
  next.classList.add('is-active');
@@ -1164,6 +1305,12 @@ ${mermaidInitTag}` : ''}
1164
1305
  headingNodes.forEach((node) => observer.observe(node));
1165
1306
  }
1166
1307
 
1308
+ // Paint the breadcrumb immediately so the header is populated before the
1309
+ // first IntersectionObserver callback fires.
1310
+ if (sections[0]) {
1311
+ renderBreadcrumb(sections[0].id);
1312
+ }
1313
+
1167
1314
  const allTocDetails = () => Array.from(document.querySelectorAll('.toc details'));
1168
1315
  const expandAllBtn = document.getElementById('toc-expand-all');
1169
1316
  if (expandAllBtn) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, and Graphviz support",
5
5
  "keywords": [
6
6
  "markdown",
@@ -27,7 +27,9 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@hpcc-js/wasm-graphviz": "1.22.0",
30
+ "katex": "^0.16.47",
30
31
  "marked": "^14.1.0",
32
+ "marked-katex-extension": "^5.1.10",
31
33
  "mermaid": "11.15.0",
32
34
  "puppeteer": "^24.15.0",
33
35
  "wavedrom": "3.5.0"