@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.
- package/README.md +23 -0
- package/lib/md2doc.js +333 -14
- 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
|
+
 <!-- inlined -->
|
|
87
|
+
<img src="assets/block.png" width="400"> <!-- inlined, attributes preserved -->
|
|
88
|
+
 <!-- left as a remote URL -->
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`srcset` and `<source>` inside `<picture>` are inlined too. Only known image
|
|
92
|
+
extensions are inlined, so `` 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, `` 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(/&/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, ''');
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
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, '&')
|
|
@@ -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
|
-
<
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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-
|
|
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
|
-
|
|
679
|
-
|
|
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:
|
|
1073
|
+
margin: 0;
|
|
808
1074
|
}
|
|
809
1075
|
.toc a {
|
|
810
|
-
display:
|
|
1076
|
+
display: block;
|
|
811
1077
|
max-width: 100%;
|
|
812
1078
|
color: #57606a;
|
|
813
1079
|
text-decoration: none;
|
|
814
|
-
padding:
|
|
815
|
-
|
|
816
|
-
|
|
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:
|
|
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
|
+
"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",
|