@helping-ai-workflow/md2doc 2.3.1 → 2.4.1

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 (3) hide show
  1. package/README.md +23 -0
  2. package/lib/md2doc.js +333 -14
  3. package/package.json +4 -2
package/README.md CHANGED
@@ -75,6 +75,29 @@ also pass `--open`.
75
75
  | `--version`, `-v` | Print version. |
76
76
  | `--help`, `-h` | Print help. |
77
77
 
78
+ ### Images
79
+
80
+ Local images referenced from the markdown are resolved against the **markdown file's own
81
+ directory** and inlined into the output as base64 `data:` URIs, so the rendered HTML / PDF
82
+ stays self-contained wherever it is written (the OS temp dir by default) and however it is
83
+ later copied or mailed.
84
+
85
+ ```markdown
86
+ ![block diagram](assets/block.png) <!-- inlined -->
87
+ <img src="assets/block.png" width="400"> <!-- inlined, attributes preserved -->
88
+ ![remote](https://example.com/x.png) <!-- left as a remote URL -->
89
+ ```
90
+
91
+ `srcset` and `<source>` inside `<picture>` are inlined too. Only known image
92
+ extensions are inlined, so `![x](../../id_rsa)` is left alone rather than
93
+ base64'd into a document you may be about to share.
94
+
95
+ A reference with no file on disk keeps its original `src` and prints
96
+ `[WARN] image not found, left as-is: ...` on stderr; the render still succeeds.
97
+
98
+ Each reference carries its own copy of the payload, so re-using one large diagram
99
+ in several places grows the HTML accordingly. PDF output is unaffected.
100
+
78
101
  ### Migration from md2html / md2pdf (v1.x → v2.0.0)
79
102
 
80
103
  | Old | New |
package/lib/md2doc.js CHANGED
@@ -109,12 +109,195 @@ 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
+ }
131
+
132
+ // ── Local image assets ───────────────────────────────────────────────────────
133
+ // Image srcs in the markdown are relative to the SOURCE markdown, but the HTML
134
+ // is normally written somewhere else entirely (the OS temp dir by default), so
135
+ // a relative src resolves against the wrong directory and the image silently
136
+ // never loads. Resolve every local reference against the markdown's own
137
+ // directory and inline it as a data: URI — the same self-contained principle
138
+ // the embedded CSS / KaTeX fonts already follow, and the only form that also
139
+ // survives the puppeteer PDF path (which renders from its own temp HTML).
140
+ const SRC_DIR = path.dirname(path.resolve(src));
141
+ const LARGE_ASSET_WARN_BYTES = 4 * 1024 * 1024;
142
+
143
+ // Doubles as the allowlist: a reference whose extension is not an image is left
144
+ // alone. Without that gate, `![x](../../../.ssh/id_rsa)` would happily base64
145
+ // the file into a document meant to be shared.
146
+ const IMAGE_MIME_BY_EXT = {
147
+ '.png': 'image/png',
148
+ '.jpg': 'image/jpeg',
149
+ '.jpeg': 'image/jpeg',
150
+ '.jfif': 'image/jpeg',
151
+ '.gif': 'image/gif',
152
+ '.svg': 'image/svg+xml',
153
+ '.webp': 'image/webp',
154
+ '.avif': 'image/avif',
155
+ '.bmp': 'image/bmp',
156
+ '.ico': 'image/x-icon',
157
+ '.apng': 'image/apng',
158
+ '.tif': 'image/tiff',
159
+ '.tiff': 'image/tiff',
160
+ };
161
+
162
+ const inlinedAssetCache = new Map();
163
+ const skippedAssetWarned = new Set();
164
+
165
+ // Only a scheme we actually know is treated as remote — a bare `letter+colon`
166
+ // test would misread both a Windows drive (`C:/img.png`) and a filename that
167
+ // merely contains a colon. `//host/x.png` is protocol-relative.
168
+ const EXTERNAL_REF_RE = /^(?:(?:https?|data|file|blob|ftps?|mailto|tel|about|chrome|chrome-extension|moz-extension):|\/\/)/i;
169
+
170
+ function isExternalRef(href) {
171
+ return EXTERNAL_REF_RE.test(String(href).trim());
172
+ }
173
+
174
+ function warnOnce(href, message) {
175
+ if (skippedAssetWarned.has(href)) return;
176
+ skippedAssetWarned.add(href);
177
+ console.error(message);
178
+ }
179
+
180
+ // Splits `assets/pic.svg?v=2#gear` into its path and its fragment. The query is
181
+ // dropped (it is cache-busting for a fetch that no longer happens); the
182
+ // fragment is kept and re-attached to the data URI, since it selects a view /
183
+ // symbol inside an SVG rather than addressing the file.
184
+ function splitAssetRef(href) {
185
+ const raw = String(href).trim();
186
+ const hash = raw.indexOf('#');
187
+ const withoutHash = hash === -1 ? raw : raw.slice(0, hash);
188
+ const fragment = hash === -1 ? '' : raw.slice(hash);
189
+ return { filePart: withoutHash.replace(/\?.*$/, ''), fragment };
190
+ }
191
+
192
+ function resolveAssetPath(filePart) {
193
+ if (!filePart) return null;
194
+ let decoded = filePart;
195
+ try {
196
+ decoded = decodeURIComponent(filePart);
197
+ } catch (_) {
198
+ // Malformed percent-escapes: probe the raw form only.
199
+ }
200
+ const candidates = [];
201
+ for (const c of [decoded, filePart]) {
202
+ candidates.push(path.isAbsolute(c) ? c : path.resolve(SRC_DIR, c));
203
+ }
204
+ return firstExistingPath(candidates);
205
+ }
206
+
207
+ // Returns a data: URI, or null when the reference must be left untouched
208
+ // (remote URL, already a data: URI, non-image extension, or no such file).
209
+ function inlineImageSrc(href) {
210
+ if (!href || isExternalRef(href)) return null;
211
+ const { filePart, fragment } = splitAssetRef(href);
212
+ if (!filePart) return null;
213
+
214
+ const mime = IMAGE_MIME_BY_EXT[path.extname(filePart).toLowerCase()];
215
+ if (!mime) {
216
+ warnOnce(href, `[WARN] not a known image extension, left as-is: ${href}`);
217
+ return null;
218
+ }
219
+
220
+ const abs = resolveAssetPath(filePart);
221
+ if (!abs) {
222
+ warnOnce(href, `[WARN] image not found, left as-is: ${href} (resolved against ${SRC_DIR})`);
223
+ return null;
224
+ }
225
+
226
+ let uri = inlinedAssetCache.get(abs);
227
+ if (uri === undefined) {
228
+ uri = null;
229
+ try {
230
+ const stat = fs.statSync(abs);
231
+ if (!stat.isFile()) throw new Error('not a regular file');
232
+ if (stat.size > LARGE_ASSET_WARN_BYTES) {
233
+ console.error(`[WARN] inlining large image (${(stat.size / 1048576).toFixed(1)} MB): ${href}`);
234
+ }
235
+ uri = `data:${mime};base64,${fs.readFileSync(abs).toString('base64')}`;
236
+ } catch (e) {
237
+ console.error(`[WARN] could not inline image ${href}: ${e.message}`);
238
+ uri = null;
239
+ }
240
+ inlinedAssetCache.set(abs, uri);
241
+ }
242
+ return uri ? uri + fragment : null;
243
+ }
244
+
245
+ // A srcset value is a comma-separated list of `<url> <descriptor>` candidates.
246
+ function inlineSrcsetValue(value) {
247
+ const parts = String(value).split(',');
248
+ let changed = false;
249
+ const rebuilt = parts.map((part) => {
250
+ const m = part.match(/^(\s*)(\S+)(\s*.*)$/);
251
+ if (!m) return part;
252
+ const uri = inlineImageSrc(m[2]);
253
+ if (!uri) return part;
254
+ changed = true;
255
+ return m[1] + uri + m[3];
256
+ });
257
+ return changed ? rebuilt.join(',') : null;
258
+ }
259
+
260
+ // Author-written <img> / <source> tags (specs use them for width= and for
261
+ // <picture>) need the same rewrite. Scoped to markdown-authored HTML chunks
262
+ // only — never run over the whole page, whose bundled mermaid / katex JS also
263
+ // contains '<img' literals.
264
+ // The tag matcher is attribute-aware: a quoted value may legally contain '>',
265
+ // and a plain [^>]* would truncate the tag and miss a src that sits after it.
266
+ const ASSET_TAG_RE = /<(?:img|source)\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
267
+ const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
268
+ const SRC_ATTR_RE = /(\s(?:src|srcset)\s*=\s*)(?:(["'])([^"']*)\2|([^\s"'=<>`]+))/gi;
269
+
270
+ function inlineImagesInTag(tag) {
271
+ return tag.replace(SRC_ATTR_RE, (match, lead, quote, quoted, bare) => {
272
+ const raw = (quoted !== undefined ? quoted : bare) || '';
273
+ const decoded = raw.replace(/&amp;/g, '&');
274
+ const isSrcset = /srcset\s*=\s*$/i.test(lead);
275
+ const value = isSrcset ? inlineSrcsetValue(decoded) : inlineImageSrc(decoded);
276
+ return value ? `${lead}"${value}"` : match;
277
+ });
278
+ }
279
+
280
+ function inlineImagesInHtmlChunk(chunk) {
281
+ if (!chunk || !/<(?:img|source)\b/i.test(chunk)) return chunk;
282
+ // Step over comments untouched — a commented-out <img> is not displayed, so
283
+ // inlining it would only bloat the output (or smuggle a file into it).
284
+ let out = '';
285
+ let last = 0;
286
+ HTML_COMMENT_RE.lastIndex = 0;
287
+ let m;
288
+ while ((m = HTML_COMMENT_RE.exec(chunk)) !== null) {
289
+ out += chunk.slice(last, m.index).replace(ASSET_TAG_RE, inlineImagesInTag) + m[0];
290
+ last = m.index + m[0].length;
291
+ }
292
+ return out + chunk.slice(last).replace(ASSET_TAG_RE, inlineImagesInTag);
293
+ }
112
294
 
113
295
  let bodyHtml;
114
296
  let tocHtml = '';
115
297
  let serializedSections = '[]';
116
298
  try {
117
299
  const { marked, Renderer } = require('marked');
300
+ const katex = require('katex');
118
301
 
119
302
  const renderer = new Renderer();
120
303
  const tocItems = [];
@@ -178,7 +361,7 @@ try {
178
361
 
179
362
  const itemsHtml = nodes
180
363
  .map((node) => {
181
- const linkHtml = `<a href="#${node.id}">${escapeHtml(node.text)}</a>`;
364
+ const linkHtml = `<a href="#${node.id}" title="${escapeHtml(node.text)}">${escapeHtml(node.text)}</a>`;
182
365
  const hasChildren = node.children && node.children.length > 0;
183
366
 
184
367
  if (!hasChildren) {
@@ -305,6 +488,29 @@ ${itemsHtml}
305
488
  .replace(/'/g, '&#39;');
306
489
  }
307
490
 
491
+ // marked's tokenizer already escapes alt / title (its default image renderer
492
+ // interpolates both raw), so this must NOT escape them again. Only the src is
493
+ // ours; everything else — including cleanUrl() on a src we leave alone — stays
494
+ // marked's job, so non-inlined images render exactly as before.
495
+ const baseImage = renderer.image.bind(renderer);
496
+ renderer.image = function(token) {
497
+ const isObj = (token !== null && typeof token === 'object');
498
+ const href = isObj ? (token.href || '') : (token || '');
499
+ const title = isObj ? (token.title || '') : (arguments[1] || '');
500
+ const text = isObj ? (token.text || '') : (arguments[2] || '');
501
+ const uri = inlineImageSrc(href);
502
+ if (!uri) return baseImage.apply(this, arguments);
503
+ // A data: URI is base64 (or an already-encoded payload): no quote, no '<'.
504
+ let out = `<img src="${uri}" alt="${text}"`;
505
+ if (title) out += ` title="${title}"`;
506
+ return out + '>';
507
+ };
508
+
509
+ const baseHtml = renderer.html.bind(renderer);
510
+ renderer.html = function(token) {
511
+ return inlineImagesInHtmlChunk(baseHtml.apply(this, arguments));
512
+ };
513
+
308
514
  renderer.code = function(token) {
309
515
  // token is either a string (old API) or {text, lang} object (new API)
310
516
  const lang = (typeof token === 'object') ? (token.lang || '') : (arguments[1] || '');
@@ -322,6 +528,17 @@ ${itemsHtml}
322
528
  // away — diverging from GitHub's escaped-code-block semantics.
323
529
  return `\n<div class="mermaid">\n${escapeHtml(code)}\n</div>\n`;
324
530
  }
531
+ if (lang === 'math') {
532
+ // KaTeX renders synchronously to static HTML (class="katex"); no client
533
+ // runtime, no async post-pass. throwOnError:false degrades a bad formula
534
+ // to red error text instead of crashing the whole render.
535
+ try {
536
+ return `\n${katex.renderToString(code, { displayMode: true, throwOnError: false })}\n`;
537
+ } catch (e) {
538
+ const esc = code.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
539
+ return `<pre><code class="language-math">${esc}</code></pre>\n`;
540
+ }
541
+ }
325
542
  if (lang === 'dot' || lang === 'graphviz') {
326
543
  // Defer rendering to the async bakeGraphviz() post-pass so the
327
544
  // synchronous marked() pass stays sync. The dot source is carried as
@@ -454,6 +671,12 @@ ${itemsHtml}
454
671
  ],
455
672
  });
456
673
 
674
+ // $$…$$ (display) and $…$ (inline) math via KaTeX. marked tokenizes code
675
+ // spans/fences first, so $ inside code stays literal; the extension's
676
+ // default no-space-adjacency rules keep prose currency ($5 to $10) unrendered.
677
+ const markedKatex = require('marked-katex-extension');
678
+ marked.use(markedKatex({ throwOnError: false }));
679
+
457
680
  // Pre-process the remaining non-standard inline syntax before marked parses
458
681
  const escAttr = (s) => String(s)
459
682
  .replace(/&/g, '&amp;')
@@ -468,6 +691,9 @@ ${itemsHtml}
468
691
  });
469
692
 
470
693
  bodyHtml = marked.parse(mdPre);
694
+ // Both the ```math fence and the $/$$ extension emit class="katex" — a single
695
+ // post-parse scan is the source of truth for conditional CSS injection.
696
+ usesMath = /class="katex/.test(bodyHtml);
471
697
  serializedSections = JSON.stringify(
472
698
  sections.map((section) => ({
473
699
  id: section.id,
@@ -498,10 +724,12 @@ ${itemsHtml}
498
724
  </section>
499
725
  <nav class="toc" aria-label="Table of contents" data-reader-toc>
500
726
  <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>
727
+ <div class="toc-header-actions">
728
+ <button id="toc-expand-all" type="button" aria-label="Expand all">⊞</button>
729
+ <button id="toc-collapse-all" type="button" aria-label="Collapse all">⊟</button>
730
+ <button id="toc-collapse-toggle" type="button" aria-label="Collapse table of contents" title="Collapse / expand sidebar">◀</button>
731
+ </div>
732
+ <div class="toc-breadcrumb" data-toc-breadcrumb aria-label="Current location"></div>
505
733
  </div>
506
734
  ${renderTocNodes(tocTree)}
507
735
  </nav>
@@ -566,7 +794,7 @@ const html = `<!DOCTYPE html>
566
794
  body[data-toc-collapsed] .reader-tools,
567
795
  body[data-toc-collapsed] .search-results,
568
796
  body[data-toc-collapsed] .toc > .toc-list,
569
- body[data-toc-collapsed] .toc-title {
797
+ body[data-toc-collapsed] .toc-breadcrumb {
570
798
  display: none;
571
799
  }
572
800
  body[data-toc-collapsed] #toc-collapse-toggle {
@@ -586,6 +814,9 @@ const html = `<!DOCTYPE html>
586
814
  margin-bottom: 0;
587
815
  justify-content: center;
588
816
  }
817
+ body[data-toc-collapsed] .toc-header-actions {
818
+ justify-content: center;
819
+ }
589
820
  #toc-collapse-toggle {
590
821
  margin-left: 0;
591
822
  padding: 2px 8px;
@@ -675,10 +906,45 @@ const html = `<!DOCTYPE html>
675
906
  .search-results-header,
676
907
  .toc-header {
677
908
  display: flex;
678
- align-items: center;
679
- gap: 6px;
909
+ flex-direction: column;
910
+ align-items: stretch;
911
+ gap: 4px;
680
912
  margin-bottom: 8px;
681
913
  }
914
+ .toc-header-actions {
915
+ flex: 0 0 auto;
916
+ display: flex;
917
+ gap: 6px;
918
+ justify-content: flex-end;
919
+ }
920
+ /* Sticky breadcrumb: stacked ancestor chain of the current scroll position.
921
+ Each row is single-line + ellipsis; full text on hover via title=. */
922
+ .toc-breadcrumb {
923
+ flex: 1 1 auto;
924
+ min-width: 0;
925
+ display: flex;
926
+ flex-direction: column;
927
+ gap: 1px;
928
+ }
929
+ .toc-breadcrumb a {
930
+ display: block;
931
+ max-width: 100%;
932
+ white-space: nowrap;
933
+ overflow: hidden;
934
+ text-overflow: ellipsis;
935
+ color: #57606a;
936
+ text-decoration: none;
937
+ font-size: 0.82em;
938
+ line-height: 1.55;
939
+ box-sizing: border-box;
940
+ }
941
+ .toc-breadcrumb a:hover {
942
+ color: #0969da;
943
+ }
944
+ .toc-breadcrumb a.breadcrumb-current {
945
+ color: #0b57d0;
946
+ font-weight: 700;
947
+ }
682
948
  .search-results-title,
683
949
  .toc-title {
684
950
  font-size: 0.78rem;
@@ -804,18 +1070,26 @@ const html = `<!DOCTYPE html>
804
1070
  margin-top: 2px;
805
1071
  }
806
1072
  .toc-item {
807
- margin: 1px 0;
1073
+ margin: 0;
808
1074
  }
809
1075
  .toc a {
810
- display: inline-block;
1076
+ display: block;
811
1077
  max-width: 100%;
812
1078
  color: #57606a;
813
1079
  text-decoration: none;
814
- padding: 4px 0 4px 0;
815
- overflow-wrap: anywhere;
816
- word-break: break-word;
1080
+ padding: 2px 0;
1081
+ line-height: 1.4;
1082
+ white-space: nowrap;
1083
+ overflow: hidden;
1084
+ text-overflow: ellipsis;
817
1085
  box-sizing: border-box;
818
1086
  }
1087
+ /* Parent rows put the anchor inside a flex <summary>; default flex min-width
1088
+ is auto, which would block ellipsis. Allow the anchor to shrink + clip. */
1089
+ .toc summary > a {
1090
+ min-width: 0;
1091
+ flex: 0 1 auto;
1092
+ }
819
1093
  .toc summary {
820
1094
  min-width: 0;
821
1095
  }
@@ -845,7 +1119,7 @@ const html = `<!DOCTYPE html>
845
1119
  display: flex;
846
1120
  align-items: flex-start;
847
1121
  gap: 6px;
848
- padding: 2px 0;
1122
+ padding: 0;
849
1123
  }
850
1124
  .toc summary::-webkit-details-marker {
851
1125
  display: none;
@@ -1056,6 +1330,7 @@ const html = `<!DOCTYPE html>
1056
1330
  a[href]:after { content: none; }
1057
1331
  }
1058
1332
  </style>
1333
+ ${usesMath ? buildKatexStyleTag() : ''}
1059
1334
  </head>
1060
1335
  <body>
1061
1336
  <button class="sidebar-toggle" id="sidebar-toggle" type="button" aria-label="Toggle sidebar" aria-expanded="false">☰</button>
@@ -1120,6 +1395,43 @@ ${mermaidInitTag}` : ''}
1120
1395
  return String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
1121
1396
  }
1122
1397
 
1398
+ const breadcrumbEl = document.querySelector('[data-toc-breadcrumb]');
1399
+ const sectionIndexById = new Map(sections.map((section, i) => [section.id, i]));
1400
+
1401
+ // Sticky breadcrumb: the ancestor chain (shallow→deep) of the active section.
1402
+ // For each decreasing depth, take the nearest preceding section, then append
1403
+ // the active section itself. Rendered as stacked, indented, clickable rows.
1404
+ function renderBreadcrumb(sectionId) {
1405
+ if (!breadcrumbEl) return;
1406
+ if (!sectionIndexById.has(sectionId)) {
1407
+ breadcrumbEl.innerHTML = '';
1408
+ return;
1409
+ }
1410
+ const idx = sectionIndexById.get(sectionId);
1411
+ const chain = [];
1412
+ let need = sections[idx].depth;
1413
+ for (let i = idx; i >= 0 && need >= 1; i--) {
1414
+ if (sections[i].depth <= need) {
1415
+ chain.unshift(sections[i]);
1416
+ need = sections[i].depth - 1;
1417
+ }
1418
+ }
1419
+ if (!chain.length) {
1420
+ breadcrumbEl.innerHTML = '';
1421
+ return;
1422
+ }
1423
+ const minDepth = chain[0].depth;
1424
+ breadcrumbEl.innerHTML = chain
1425
+ .map((section, i) => {
1426
+ const indent = (section.depth - minDepth) * 10;
1427
+ const cls = i === chain.length - 1 ? ' class="breadcrumb-current"' : '';
1428
+ const title = escapeHtml(section.title);
1429
+ return '<a href="#' + section.id + '"' + cls + ' title="' + title +
1430
+ '" style="padding-left:' + indent + 'px">' + title + '</a>';
1431
+ })
1432
+ .join('');
1433
+ }
1434
+
1123
1435
  function expandTocAncestors(link) {
1124
1436
  let node = link && link.closest('details');
1125
1437
  while (node) {
@@ -1153,6 +1465,7 @@ ${mermaidInitTag}` : ''}
1153
1465
  previous.classList.remove('is-active');
1154
1466
  }
1155
1467
  readerState.activeSectionId = sectionId;
1468
+ renderBreadcrumb(sectionId);
1156
1469
  const next = tocLinks.get(sectionId);
1157
1470
  if (next) {
1158
1471
  next.classList.add('is-active');
@@ -1178,6 +1491,12 @@ ${mermaidInitTag}` : ''}
1178
1491
  headingNodes.forEach((node) => observer.observe(node));
1179
1492
  }
1180
1493
 
1494
+ // Paint the breadcrumb immediately so the header is populated before the
1495
+ // first IntersectionObserver callback fires.
1496
+ if (sections[0]) {
1497
+ renderBreadcrumb(sections[0].id);
1498
+ }
1499
+
1181
1500
  const allTocDetails = () => Array.from(document.querySelectorAll('.toc details'));
1182
1501
  const expandAllBtn = document.getElementById('toc-expand-all');
1183
1502
  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.1",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, and Graphviz support",
5
5
  "keywords": [
6
6
  "markdown",
@@ -27,14 +27,16 @@
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"
34
36
  },
35
37
  "scripts": {
36
38
  "preinstall": "node scripts/preinstall.js",
37
- "test": "node test/md2doc.test.js && node test/cli.test.js && node test/code-operator.test.js"
39
+ "test": "node test/md2doc.test.js && node test/images.test.js && node test/cli.test.js && node test/code-operator.test.js"
38
40
  },
39
41
  "repository": {
40
42
  "type": "git",