@helping-ai-workflow/md2doc 2.3.1 → 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 +147 -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,7 +608,7 @@ 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 {
@@ -586,6 +628,9 @@ const html = `<!DOCTYPE html>
586
628
  margin-bottom: 0;
587
629
  justify-content: center;
588
630
  }
631
+ body[data-toc-collapsed] .toc-header-actions {
632
+ justify-content: center;
633
+ }
589
634
  #toc-collapse-toggle {
590
635
  margin-left: 0;
591
636
  padding: 2px 8px;
@@ -675,10 +720,45 @@ const html = `<!DOCTYPE html>
675
720
  .search-results-header,
676
721
  .toc-header {
677
722
  display: flex;
678
- align-items: center;
679
- gap: 6px;
723
+ flex-direction: column;
724
+ align-items: stretch;
725
+ gap: 4px;
680
726
  margin-bottom: 8px;
681
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
+ }
682
762
  .search-results-title,
683
763
  .toc-title {
684
764
  font-size: 0.78rem;
@@ -804,18 +884,26 @@ const html = `<!DOCTYPE html>
804
884
  margin-top: 2px;
805
885
  }
806
886
  .toc-item {
807
- margin: 1px 0;
887
+ margin: 0;
808
888
  }
809
889
  .toc a {
810
- display: inline-block;
890
+ display: block;
811
891
  max-width: 100%;
812
892
  color: #57606a;
813
893
  text-decoration: none;
814
- padding: 4px 0 4px 0;
815
- overflow-wrap: anywhere;
816
- word-break: break-word;
894
+ padding: 2px 0;
895
+ line-height: 1.4;
896
+ white-space: nowrap;
897
+ overflow: hidden;
898
+ text-overflow: ellipsis;
817
899
  box-sizing: border-box;
818
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
+ }
819
907
  .toc summary {
820
908
  min-width: 0;
821
909
  }
@@ -845,7 +933,7 @@ const html = `<!DOCTYPE html>
845
933
  display: flex;
846
934
  align-items: flex-start;
847
935
  gap: 6px;
848
- padding: 2px 0;
936
+ padding: 0;
849
937
  }
850
938
  .toc summary::-webkit-details-marker {
851
939
  display: none;
@@ -1056,6 +1144,7 @@ const html = `<!DOCTYPE html>
1056
1144
  a[href]:after { content: none; }
1057
1145
  }
1058
1146
  </style>
1147
+ ${usesMath ? buildKatexStyleTag() : ''}
1059
1148
  </head>
1060
1149
  <body>
1061
1150
  <button class="sidebar-toggle" id="sidebar-toggle" type="button" aria-label="Toggle sidebar" aria-expanded="false">☰</button>
@@ -1120,6 +1209,43 @@ ${mermaidInitTag}` : ''}
1120
1209
  return String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
1121
1210
  }
1122
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
+
1123
1249
  function expandTocAncestors(link) {
1124
1250
  let node = link && link.closest('details');
1125
1251
  while (node) {
@@ -1153,6 +1279,7 @@ ${mermaidInitTag}` : ''}
1153
1279
  previous.classList.remove('is-active');
1154
1280
  }
1155
1281
  readerState.activeSectionId = sectionId;
1282
+ renderBreadcrumb(sectionId);
1156
1283
  const next = tocLinks.get(sectionId);
1157
1284
  if (next) {
1158
1285
  next.classList.add('is-active');
@@ -1178,6 +1305,12 @@ ${mermaidInitTag}` : ''}
1178
1305
  headingNodes.forEach((node) => observer.observe(node));
1179
1306
  }
1180
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
+
1181
1314
  const allTocDetails = () => Array.from(document.querySelectorAll('.toc details'));
1182
1315
  const expandAllBtn = document.getElementById('toc-expand-all');
1183
1316
  if (expandAllBtn) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "2.3.1",
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"