@helping-ai-workflow/md2doc 2.8.1 → 2.10.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/lib/md2doc.js CHANGED
@@ -21,19 +21,9 @@
21
21
  const fs = require('fs');
22
22
  const path = require('path');
23
23
 
24
- const [,, src, dst] = process.argv;
25
- const BAKE_SVG = process.argv.slice(4).includes('--bake-svg');
26
-
27
24
  // How long to let client-side WaveDrom / Mermaid scripts render before we
28
25
  // snapshot the DOM (PDF print, or --bake-svg inert-SVG bake).
29
26
  const DIAGRAM_RENDER_WAIT_MS = 2500;
30
- if (!src || !dst) {
31
- console.error('Usage: node md2doc.js <input.md> <output.html|pdf>');
32
- process.exit(1);
33
- }
34
-
35
- const ext = path.extname(dst).toLowerCase();
36
- const md = fs.readFileSync(src, 'utf8');
37
27
 
38
28
  function firstExistingPath(candidates) {
39
29
  for (const candidate of candidates) {
@@ -106,11 +96,6 @@ const mermaidInitTag = `<script type="text/javascript" data-md2doc-diagram-engin
106
96
  // Use a custom renderer to intercept fenced code blocks before marked escapes
107
97
  // their content. This is the correct approach — pre-processing the raw markdown
108
98
  // string causes marked to re-parse the injected HTML and mangle indented lines.
109
-
110
- let usesMermaid = false;
111
- let usesWaveDrom = false;
112
- let usesMath = false;
113
-
114
99
  // Self-contained KaTeX stylesheet: inline katex.min.css with each woff2 @font-face
115
100
  // rewritten to a base64 data: URI and the woff/ttf alternates stripped, so a
116
101
  // math-bearing HTML prints offline (and in puppeteer PDF) with no font fetch.
@@ -129,582 +114,698 @@ function buildKatexStyleTag() {
129
114
  return `<style data-md2doc-math>${css}</style>`;
130
115
  }
131
116
 
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
- }
117
+ // ── marked setup (installed once at require time) ───────────────────────────
118
+ // marked.use() has no de-dup: it unshifts a fresh copy of these tokenizers
119
+ // into marked's shared/global extension registry on every call. These two
120
+ // extensions don't depend on any per-document state (mdText/srcPath), so they
121
+ // are installed exactly once here rather than inside renderMarkdown() — doing
122
+ // it per-call would grow the registry unboundedly in a long-lived process
123
+ // (the editor server calls renderMarkdown() once per edit).
124
+ let marked, Renderer;
125
+ try {
126
+ ({ marked, Renderer } = require('marked'));
173
127
 
174
- function warnOnce(href, message) {
175
- if (skippedAssetWarned.has(href)) return;
176
- skippedAssetWarned.add(href);
177
- console.error(message);
178
- }
128
+ // Code-aware subscript / superscript as marked inline extensions.
129
+ // The old raw-text pre-pass (mdPre.replace(/~([^~]+)~/...)) ran BEFORE marked
130
+ // tokenised, so it rewrote ~NOT / ^XOR operators inside fenced, indented and
131
+ // inline code into <sub>/<sup> (96 such mangles in one RTL spec). As inline
132
+ // extensions marked tokenises code first, so these never fire inside code.
133
+ // The tokenizer also requires a single whitespace-free token (~x~ / ^x^), so
134
+ // spaced operator expressions (~a & ~b, a ^ b) and lone operators (2^24, ~rst)
135
+ // stay literal even in prose — only a genuine subscript/superscript converts.
136
+ marked.use({
137
+ extensions: [
138
+ {
139
+ name: 'subscript',
140
+ level: 'inline',
141
+ start(src) { const i = src.indexOf('~'); return i < 0 ? undefined : i; },
142
+ tokenizer(src) {
143
+ const m = /^~(?=\S)([^~\s\n]+)~/.exec(src);
144
+ if (m) {
145
+ return { type: 'subscript', raw: m[0], text: m[1],
146
+ tokens: this.lexer.inlineTokens(m[1]) };
147
+ }
148
+ },
149
+ renderer(token) { return `<sub>${this.parser.parseInline(token.tokens)}</sub>`; },
150
+ },
151
+ {
152
+ name: 'superscript',
153
+ level: 'inline',
154
+ start(src) { const i = src.indexOf('^'); return i < 0 ? undefined : i; },
155
+ tokenizer(src) {
156
+ const m = /^\^(?=\S)([^^\s\n]+)\^/.exec(src);
157
+ if (m) {
158
+ return { type: 'superscript', raw: m[0], text: m[1],
159
+ tokens: this.lexer.inlineTokens(m[1]) };
160
+ }
161
+ },
162
+ renderer(token) { return `<sup>${this.parser.parseInline(token.tokens)}</sup>`; },
163
+ },
164
+ ],
165
+ });
179
166
 
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 };
167
+ // $$…$$ (display) and $…$ (inline) math via KaTeX. marked tokenizes code
168
+ // spans/fences first, so $ inside code stays literal; the extension's
169
+ // default no-space-adjacency rules keep prose currency ($5 to $10) unrendered.
170
+ const markedKatex = require('marked-katex-extension');
171
+ marked.use(markedKatex({ throwOnError: false }));
172
+ } catch (e) {
173
+ console.error('[ERROR] marked not found — install with: npm install marked');
174
+ console.error(e.message);
175
+ process.exit(1);
190
176
  }
191
177
 
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);
178
+ // Render one list token into per-li ed-block HTML.
179
+ // biRef is a shared box { v: <int> } that advances through blocks[] in the
180
+ // same DFS order as blockmap.js pushListItemBlocks:
181
+ // 1. Push the item's own block.
182
+ // 2. Recurse into that item's nested child lists, left-to-right.
183
+ // 3. Move on to the next sibling item.
184
+ // RULING F-L: use marked.Parser.parseInline ONLY when the item is tight
185
+ // (!item.loose) AND its own non-list tokens are exactly one 'text' token.
186
+ // In every other case emit marked.parser(ownTokens) so that loose items keep
187
+ // their <p> wrapper list-md.js detects loose items by the <p> and flags them
188
+ // as unsupported per-li (spec §8). Stripping the <p> would cause silent
189
+ // data-shape corruption on the first edit.
190
+ function renderEditModeList(listToken, blocks, biRef) {
191
+ const tag = listToken.ordered ? 'ol' : 'ul';
192
+ const startAttr = (listToken.ordered && listToken.start && listToken.start !== 1)
193
+ ? ` start="${listToken.start}"` : '';
194
+ const items = listToken.items.map((item) => {
195
+ const b = blocks[biRef.v++];
196
+ const ownTokens = item.tokens.filter((tk) => tk.type !== 'list');
197
+ let inner;
198
+ if (!item.loose && ownTokens.length === 1 && ownTokens[0].type === 'text') {
199
+ // Tight item with a single text token: use static parseInline to get
200
+ // clean inline HTML without a <p> wrapper.
201
+ inner = marked.Parser.parseInline(ownTokens[0].tokens);
202
+ } else {
203
+ // Loose item or non-standard own-token shape: use marked.parser, which
204
+ // emits block-level HTML (including <p> for loose items). The <p>
205
+ // presence is load-bearing for list-md.js's loose-item detection.
206
+ inner = marked.parser(ownTokens);
207
+ }
208
+ const check = b.listType === 'task'
209
+ ? `<span class="ed-li-check" data-checked="${b.checked ? 1 : 0}" role="checkbox" aria-checked="${b.checked}"></span>`
210
+ : '';
211
+ // Recurse into nested child lists AFTER this item's block (matches
212
+ // pushListItemBlocks order: own block → nested lists → next sibling).
213
+ const children = item.tokens.filter((tk) => tk.type === 'list')
214
+ .map((ct) => renderEditModeList(ct, blocks, biRef)).join('\n');
215
+ return `<li class="ed-block" data-block-id="${b.id}" data-block-type="li"` +
216
+ ` data-list-type="${b.listType}" data-indent="${b.indent}">` +
217
+ check + `<div class="ed-li-text">${inner}</div>` + children + '</li>';
218
+ });
219
+ return `<${tag}${startAttr}>\n${items.join('\n')}\n</${tag}>`;
205
220
  }
206
221
 
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;
222
+ async function renderMarkdown(mdText, srcPath, opts = {}) {
223
+ const md = mdText;
224
+ const src = srcPath;
225
+ let usesMermaid = false;
226
+ let usesWaveDrom = false;
227
+ let usesMath = false;
228
+ let blocks = null;
229
+ // ── Local image assets ───────────────────────────────────────────────────────
230
+ // Image srcs in the markdown are relative to the SOURCE markdown, but the HTML
231
+ // is normally written somewhere else entirely (the OS temp dir by default), so
232
+ // a relative src resolves against the wrong directory and the image silently
233
+ // never loads. Resolve every local reference against the markdown's own
234
+ // directory and inline it as a data: URI — the same self-contained principle
235
+ // the embedded CSS / KaTeX fonts already follow, and the only form that also
236
+ // survives the puppeteer PDF path (which renders from its own temp HTML).
237
+ const SRC_DIR = path.dirname(path.resolve(src));
238
+ const LARGE_ASSET_WARN_BYTES = 4 * 1024 * 1024;
239
+
240
+ // Doubles as the allowlist: a reference whose extension is not an image is left
241
+ // alone. Without that gate, `![x](../../../.ssh/id_rsa)` would happily base64
242
+ // the file into a document meant to be shared.
243
+ const IMAGE_MIME_BY_EXT = {
244
+ '.png': 'image/png',
245
+ '.jpg': 'image/jpeg',
246
+ '.jpeg': 'image/jpeg',
247
+ '.jfif': 'image/jpeg',
248
+ '.gif': 'image/gif',
249
+ '.svg': 'image/svg+xml',
250
+ '.webp': 'image/webp',
251
+ '.avif': 'image/avif',
252
+ '.bmp': 'image/bmp',
253
+ '.ico': 'image/x-icon',
254
+ '.apng': 'image/apng',
255
+ '.tif': 'image/tiff',
256
+ '.tiff': 'image/tiff',
257
+ };
213
258
 
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;
259
+ const inlinedAssetCache = new Map();
260
+ const skippedAssetWarned = new Set();
261
+
262
+ // Only a scheme we actually know is treated as remote — a bare `letter+colon`
263
+ // test would misread both a Windows drive (`C:/img.png`) and a filename that
264
+ // merely contains a colon. `//host/x.png` is protocol-relative.
265
+ const EXTERNAL_REF_RE = /^(?:(?:https?|data|file|blob|ftps?|mailto|tel|about|chrome|chrome-extension|moz-extension):|\/\/)/i;
266
+
267
+ function isExternalRef(href) {
268
+ return EXTERNAL_REF_RE.test(String(href).trim());
218
269
  }
219
270
 
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;
271
+ function warnOnce(href, message) {
272
+ if (skippedAssetWarned.has(href)) return;
273
+ skippedAssetWarned.add(href);
274
+ console.error(message);
275
+ }
276
+
277
+ // Splits `assets/pic.svg?v=2#gear` into its path and its fragment. The query is
278
+ // dropped (it is cache-busting for a fetch that no longer happens); the
279
+ // fragment is kept and re-attached to the data URI, since it selects a view /
280
+ // symbol inside an SVG rather than addressing the file.
281
+ function splitAssetRef(href) {
282
+ const raw = String(href).trim();
283
+ const hash = raw.indexOf('#');
284
+ const withoutHash = hash === -1 ? raw : raw.slice(0, hash);
285
+ const fragment = hash === -1 ? '' : raw.slice(hash);
286
+ return { filePart: withoutHash.replace(/\?.*$/, ''), fragment };
224
287
  }
225
288
 
226
- let uri = inlinedAssetCache.get(abs);
227
- if (uri === undefined) {
228
- uri = null;
289
+ function resolveAssetPath(filePart) {
290
+ if (!filePart) return null;
291
+ let decoded = filePart;
229
292
  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;
293
+ decoded = decodeURIComponent(filePart);
294
+ } catch (_) {
295
+ // Malformed percent-escapes: probe the raw form only.
239
296
  }
240
- inlinedAssetCache.set(abs, uri);
297
+ const candidates = [];
298
+ for (const c of [decoded, filePart]) {
299
+ candidates.push(path.isAbsolute(c) ? c : path.resolve(SRC_DIR, c));
300
+ }
301
+ return firstExistingPath(candidates);
241
302
  }
242
- return uri ? uri + fragment : null;
243
- }
244
303
 
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
- }
304
+ // Returns a data: URI, or null when the reference must be left untouched
305
+ // (remote URL, already a data: URI, non-image extension, or no such file).
306
+ function inlineImageSrc(href) {
307
+ if (!href || isExternalRef(href)) return null;
308
+ const { filePart, fragment } = splitAssetRef(href);
309
+ if (!filePart) return null;
259
310
 
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
- }
311
+ const mime = IMAGE_MIME_BY_EXT[path.extname(filePart).toLowerCase()];
312
+ if (!mime) {
313
+ warnOnce(href, `[WARN] not a known image extension, left as-is: ${href}`);
314
+ return null;
315
+ }
279
316
 
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
- }
317
+ const abs = resolveAssetPath(filePart);
318
+ if (!abs) {
319
+ warnOnce(href, `[WARN] image not found, left as-is: ${href} (resolved against ${SRC_DIR})`);
320
+ return null;
321
+ }
294
322
 
295
- let bodyHtml;
296
- let tocHtml = '';
297
- let serializedSections = '[]';
298
- try {
299
- const { marked, Renderer } = require('marked');
300
- const katex = require('katex');
301
-
302
- const renderer = new Renderer();
303
- const tocItems = [];
304
- const slugCounts = new Map();
305
- const sections = [];
306
- let currentSection = null;
307
-
308
- function startSection({ id, depth, text }) {
309
- currentSection = {
310
- id,
311
- depth,
312
- title: text,
313
- searchTextParts: [text],
314
- };
315
- sections.push(currentSection);
323
+ let uri = inlinedAssetCache.get(abs);
324
+ if (uri === undefined) {
325
+ uri = null;
326
+ try {
327
+ const stat = fs.statSync(abs);
328
+ if (!stat.isFile()) throw new Error('not a regular file');
329
+ if (stat.size > LARGE_ASSET_WARN_BYTES) {
330
+ console.error(`[WARN] inlining large image (${(stat.size / 1048576).toFixed(1)} MB): ${href}`);
331
+ }
332
+ uri = `data:${mime};base64,${fs.readFileSync(abs).toString('base64')}`;
333
+ } catch (e) {
334
+ console.error(`[WARN] could not inline image ${href}: ${e.message}`);
335
+ uri = null;
336
+ }
337
+ inlinedAssetCache.set(abs, uri);
338
+ }
339
+ return uri ? uri + fragment : null;
340
+ }
341
+
342
+ // A srcset value is a comma-separated list of `<url> <descriptor>` candidates.
343
+ function inlineSrcsetValue(value) {
344
+ const parts = String(value).split(',');
345
+ let changed = false;
346
+ const rebuilt = parts.map((part) => {
347
+ const m = part.match(/^(\s*)(\S+)(\s*.*)$/);
348
+ if (!m) return part;
349
+ const uri = inlineImageSrc(m[2]);
350
+ if (!uri) return part;
351
+ changed = true;
352
+ return m[1] + uri + m[3];
353
+ });
354
+ return changed ? rebuilt.join(',') : null;
355
+ }
356
+
357
+ // Author-written <img> / <source> tags (specs use them for width= and for
358
+ // <picture>) need the same rewrite. Scoped to markdown-authored HTML chunks
359
+ // only — never run over the whole page, whose bundled mermaid / katex JS also
360
+ // contains '<img' literals.
361
+ // The tag matcher is attribute-aware: a quoted value may legally contain '>',
362
+ // and a plain [^>]* would truncate the tag and miss a src that sits after it.
363
+ const ASSET_TAG_RE = /<(?:img|source)\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
364
+ const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
365
+ const SRC_ATTR_RE = /(\s(?:src|srcset)\s*=\s*)(?:(["'])([^"']*)\2|([^\s"'=<>`]+))/gi;
366
+
367
+ function inlineImagesInTag(tag) {
368
+ return tag.replace(SRC_ATTR_RE, (match, lead, quote, quoted, bare) => {
369
+ const raw = (quoted !== undefined ? quoted : bare) || '';
370
+ const decoded = raw.replace(/&amp;/g, '&');
371
+ const isSrcset = /srcset\s*=\s*$/i.test(lead);
372
+ const value = isSrcset ? inlineSrcsetValue(decoded) : inlineImageSrc(decoded);
373
+ return value ? `${lead}"${value}"` : match;
374
+ });
316
375
  }
317
376
 
318
- function appendSectionText(value) {
319
- if (!currentSection || !value) {
320
- return;
321
- }
322
- const clean = stripHtmlTags(value).replace(/\s+/g, ' ').trim();
323
- if (clean) {
324
- currentSection.searchTextParts.push(clean);
377
+ function inlineImagesInHtmlChunk(chunk) {
378
+ if (!chunk || !/<(?:img|source)\b/i.test(chunk)) return chunk;
379
+ // Step over comments untouched — a commented-out <img> is not displayed, so
380
+ // inlining it would only bloat the output (or smuggle a file into it).
381
+ let out = '';
382
+ let last = 0;
383
+ HTML_COMMENT_RE.lastIndex = 0;
384
+ let m;
385
+ while ((m = HTML_COMMENT_RE.exec(chunk)) !== null) {
386
+ out += chunk.slice(last, m.index).replace(ASSET_TAG_RE, inlineImagesInTag) + m[0];
387
+ last = m.index + m[0].length;
388
+ }
389
+ return out + chunk.slice(last).replace(ASSET_TAG_RE, inlineImagesInTag);
390
+ }
391
+
392
+ let bodyHtml;
393
+ let tocHtml = '';
394
+ let serializedSections = '[]';
395
+ // NOTE: intentionally no try/catch here. A throw from marked/katex parsing
396
+ // (pathological markdown, a malformed extension token, etc.) must propagate
397
+ // to the caller: the CLI's outer `(async () => {...})().catch(...)` exits 1,
398
+ // and the long-lived editor server's route wraps renderMarkdown in its own
399
+ // try/catch to return a 500 and keep serving. Swallowing it here with
400
+ // process.exit(1) (the old behavior) used to kill the whole editor server on
401
+ // any render-time throw — see lib/editor/server.js's /api/render handler.
402
+ // The *module-level* require('marked') failure above (~line 172) still has
403
+ // its own catch, since that one really is unrecoverable at load time.
404
+ {
405
+ const katex = require('katex');
406
+
407
+ const renderer = new Renderer();
408
+ const tocItems = [];
409
+ const slugCounts = new Map();
410
+ const sections = [];
411
+ let currentSection = null;
412
+
413
+ function startSection({ id, depth, text }) {
414
+ currentSection = {
415
+ id,
416
+ depth,
417
+ title: text,
418
+ searchTextParts: [text],
419
+ };
420
+ sections.push(currentSection);
325
421
  }
326
- }
327
422
 
328
- function collectCellText(cells) {
329
- if (!Array.isArray(cells)) {
330
- return;
331
- }
332
- for (const cell of cells) {
333
- if (cell && Array.isArray(cell.tokens)) {
334
- appendSectionText(flattenTokenText(cell.tokens));
335
- } else if (cell && typeof cell.text === 'string') {
336
- appendSectionText(cell.text);
423
+ function appendSectionText(value) {
424
+ if (!currentSection || !value) {
425
+ return;
426
+ }
427
+ const clean = stripHtmlTags(value).replace(/\s+/g, ' ').trim();
428
+ if (clean) {
429
+ currentSection.searchTextParts.push(clean);
337
430
  }
338
431
  }
339
- }
340
432
 
341
- function buildTocTree(items) {
342
- const root = [];
343
- const stack = [{ depth: 0, children: root }];
344
-
345
- for (const item of items) {
346
- const node = { ...item, children: [] };
347
- while (stack.length > 1 && item.depth <= stack[stack.length - 1].depth) {
348
- stack.pop();
433
+ function collectCellText(cells) {
434
+ if (!Array.isArray(cells)) {
435
+ return;
436
+ }
437
+ for (const cell of cells) {
438
+ if (cell && Array.isArray(cell.tokens)) {
439
+ appendSectionText(flattenTokenText(cell.tokens));
440
+ } else if (cell && typeof cell.text === 'string') {
441
+ appendSectionText(cell.text);
442
+ }
349
443
  }
350
- stack[stack.length - 1].children.push(node);
351
- stack.push(node);
352
444
  }
353
445
 
354
- return root;
355
- }
446
+ function buildTocTree(items) {
447
+ const root = [];
448
+ const stack = [{ depth: 0, children: root }];
356
449
 
357
- function renderTocNodes(nodes, level = 1) {
358
- if (!nodes.length) {
359
- return '';
450
+ for (const item of items) {
451
+ const node = { ...item, children: [] };
452
+ while (stack.length > 1 && item.depth <= stack[stack.length - 1].depth) {
453
+ stack.pop();
454
+ }
455
+ stack[stack.length - 1].children.push(node);
456
+ stack.push(node);
457
+ }
458
+
459
+ return root;
360
460
  }
361
461
 
362
- const itemsHtml = nodes
363
- .map((node) => {
364
- const linkHtml = `<a href="#${node.id}" title="${escapeHtml(node.text)}">${escapeHtml(node.text)}</a>`;
365
- const hasChildren = node.children && node.children.length > 0;
462
+ function renderTocNodes(nodes, level = 1) {
463
+ if (!nodes.length) {
464
+ return '';
465
+ }
366
466
 
367
- if (!hasChildren) {
368
- return `<li class="toc-item toc-level-${level}">${linkHtml}</li>`;
369
- }
467
+ const itemsHtml = nodes
468
+ .map((node) => {
469
+ const linkHtml = `<a href="#${node.id}" title="${escapeHtml(node.text)}">${escapeHtml(node.text)}</a>`;
470
+ const hasChildren = node.children && node.children.length > 0;
370
471
 
371
- return `<li class="toc-item toc-level-${level} toc-parent">
472
+ if (!hasChildren) {
473
+ return `<li class="toc-item toc-level-${level}">${linkHtml}</li>`;
474
+ }
475
+
476
+ return `<li class="toc-item toc-level-${level} toc-parent">
372
477
  <details>
373
478
  <summary>${linkHtml}</summary>
374
479
  ${renderTocNodes(node.children, level + 1)}
375
480
  </details>
376
481
  </li>`;
377
- })
378
- .join('\n');
482
+ })
483
+ .join('\n');
379
484
 
380
- return `<ul class="toc-list toc-list-level-${level}">
485
+ return `<ul class="toc-list toc-list-level-${level}">
381
486
  ${itemsHtml}
382
487
  </ul>`;
383
- }
384
-
385
- function flattenTokenText(tokens) {
386
- if (!Array.isArray(tokens)) {
387
- return '';
388
488
  }
389
- return tokens
390
- .map((item) => {
391
- if (item.type === 'link' || item.type === 'em' || item.type === 'strong' || item.type === 'del') {
392
- return flattenTokenText(item.tokens);
393
- }
394
- if (item.type === 'codespan') {
489
+
490
+ function flattenTokenText(tokens) {
491
+ if (!Array.isArray(tokens)) {
492
+ return '';
493
+ }
494
+ return tokens
495
+ .map((item) => {
496
+ if (item.type === 'link' || item.type === 'em' || item.type === 'strong' || item.type === 'del') {
497
+ return flattenTokenText(item.tokens);
498
+ }
499
+ if (item.type === 'codespan') {
500
+ return item.text || '';
501
+ }
502
+ if (item.tokens) {
503
+ return flattenTokenText(item.tokens);
504
+ }
395
505
  return item.text || '';
506
+ })
507
+ .join('');
508
+ }
509
+
510
+ function unbreakableRun(s) {
511
+ // Treat `_` as a break point — identifiers like `pmac_tx_*` are unbreakable in CSS but breakable for classification.
512
+ const matches = String(s || '').match(/[A-Za-z0-9\-./@:]+/g);
513
+ if (!matches) return 0;
514
+ let max = 0;
515
+ for (const m of matches) if (m.length > max) max = m.length;
516
+ return max;
517
+ }
518
+
519
+ function cellRawText(cell) {
520
+ if (!cell) return '';
521
+ if (Array.isArray(cell.tokens)) return flattenTokenText(cell.tokens);
522
+ return String(cell.text || '');
523
+ }
524
+
525
+ function classifyColumns(token) {
526
+ const colCount = (token.header || []).length;
527
+ const classes = [];
528
+ for (let i = 0; i < colCount; i++) {
529
+ const allCells = [];
530
+ if (token.header && token.header[i]) allCells.push(token.header[i]);
531
+ const dataCells = [];
532
+ if (Array.isArray(token.rows)) {
533
+ for (const row of token.rows) {
534
+ if (row && row[i]) {
535
+ allCells.push(row[i]);
536
+ dataCells.push(row[i]);
537
+ }
538
+ }
396
539
  }
397
- if (item.tokens) {
398
- return flattenTokenText(item.tokens);
540
+ const allTexts = allCells.map(cellRawText);
541
+ const dataTexts = dataCells.map(cellRawText);
542
+ // Header labels (e.g. "Clock Domain") often contain whitespace not representative of cell content; use data rows for the heuristic and fall back to header only when the column has no data.
543
+ const heuristicTexts = dataTexts.length > 0 ? dataTexts : allTexts;
544
+ let maxTokenLen = 0;
545
+ let totalLen = 0;
546
+ let hasWhitespace = false;
547
+ let hasSentence = false;
548
+ for (const t of heuristicTexts) {
549
+ const r = unbreakableRun(t);
550
+ if (r > maxTokenLen) maxTokenLen = r;
551
+ totalLen += t.length;
552
+ if (/\s/.test(t)) hasWhitespace = true;
553
+ if (/。|\. /.test(t)) hasSentence = true;
399
554
  }
400
- return item.text || '';
401
- })
402
- .join('');
403
- }
555
+ const avgCellLen = heuristicTexts.length ? (totalLen / heuristicTexts.length) : 0;
404
556
 
405
- function unbreakableRun(s) {
406
- // Treat `_` as a break point — identifiers like `pmac_tx_*` are unbreakable in CSS but breakable for classification.
407
- const matches = String(s || '').match(/[A-Za-z0-9\-./@:]+/g);
408
- if (!matches) return 0;
409
- let max = 0;
410
- for (const m of matches) if (m.length > max) max = m.length;
411
- return max;
412
- }
413
-
414
- function cellRawText(cell) {
415
- if (!cell) return '';
416
- if (Array.isArray(cell.tokens)) return flattenTokenText(cell.tokens);
417
- return String(cell.text || '');
418
- }
419
-
420
- function classifyColumns(token) {
421
- const colCount = (token.header || []).length;
422
- const classes = [];
423
- for (let i = 0; i < colCount; i++) {
424
- const allCells = [];
425
- if (token.header && token.header[i]) allCells.push(token.header[i]);
426
- const dataCells = [];
427
- if (Array.isArray(token.rows)) {
428
- for (const row of token.rows) {
429
- if (row && row[i]) {
430
- allCells.push(row[i]);
431
- dataCells.push(row[i]);
432
- }
557
+ // Narrow takes precedence over prose (tie-break: prefer narrow / conservative).
558
+ if (maxTokenLen <= 12 && !hasWhitespace) {
559
+ classes.push('col-narrow');
560
+ } else if (avgCellLen > 40 || hasSentence) {
561
+ classes.push('col-prose');
562
+ } else {
563
+ classes.push('col-default');
433
564
  }
434
565
  }
435
- const allTexts = allCells.map(cellRawText);
436
- const dataTexts = dataCells.map(cellRawText);
437
- // Header labels (e.g. "Clock Domain") often contain whitespace not representative of cell content; use data rows for the heuristic and fall back to header only when the column has no data.
438
- const heuristicTexts = dataTexts.length > 0 ? dataTexts : allTexts;
439
- let maxTokenLen = 0;
440
- let totalLen = 0;
441
- let hasWhitespace = false;
442
- let hasSentence = false;
443
- for (const t of heuristicTexts) {
444
- const r = unbreakableRun(t);
445
- if (r > maxTokenLen) maxTokenLen = r;
446
- totalLen += t.length;
447
- if (/\s/.test(t)) hasWhitespace = true;
448
- if (/。|\. /.test(t)) hasSentence = true;
449
- }
450
- const avgCellLen = heuristicTexts.length ? (totalLen / heuristicTexts.length) : 0;
451
-
452
- // Narrow takes precedence over prose (tie-break: prefer narrow / conservative).
453
- if (maxTokenLen <= 12 && !hasWhitespace) {
454
- classes.push('col-narrow');
455
- } else if (avgCellLen > 40 || hasSentence) {
456
- classes.push('col-prose');
457
- } else {
458
- classes.push('col-default');
459
- }
460
- }
461
- return classes;
462
- }
463
-
464
- function stripHtmlTags(value) {
465
- return String(value || '').replace(/<[^>]*>/g, '');
466
- }
467
-
468
- function slugifyHeading(value) {
469
- const base = stripHtmlTags(value)
470
- .normalize('NFKD')
471
- .replace(/[\u0300-\u036f]/g, '')
472
- .trim()
473
- .toLowerCase()
474
- .replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-')
475
- .replace(/^-+|-+$/g, '')
476
- || 'section';
477
- const count = slugCounts.get(base) || 0;
478
- slugCounts.set(base, count + 1);
479
- return count === 0 ? base : `${base}-${count + 1}`;
480
- }
481
-
482
- function escapeHtml(value) {
483
- return String(value || '')
484
- .replace(/&/g, '&amp;')
485
- .replace(/</g, '&lt;')
486
- .replace(/>/g, '&gt;')
487
- .replace(/"/g, '&quot;')
488
- .replace(/'/g, '&#39;');
489
- }
566
+ return classes;
567
+ }
568
+
569
+ function stripHtmlTags(value) {
570
+ return String(value || '').replace(/<[^>]*>/g, '');
571
+ }
572
+
573
+ function slugifyHeading(value) {
574
+ const base = stripHtmlTags(value)
575
+ .normalize('NFKD')
576
+ .replace(/[\u0300-\u036f]/g, '')
577
+ .trim()
578
+ .toLowerCase()
579
+ .replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-')
580
+ .replace(/^-+|-+$/g, '')
581
+ || 'section';
582
+ const count = slugCounts.get(base) || 0;
583
+ slugCounts.set(base, count + 1);
584
+ return count === 0 ? base : `${base}-${count + 1}`;
585
+ }
586
+
587
+ function escapeHtml(value) {
588
+ return String(value || '')
589
+ .replace(/&/g, '&amp;')
590
+ .replace(/</g, '&lt;')
591
+ .replace(/>/g, '&gt;')
592
+ .replace(/"/g, '&quot;')
593
+ .replace(/'/g, '&#39;');
594
+ }
595
+
596
+ // marked's tokenizer already escapes alt / title (its default image renderer
597
+ // interpolates both raw), so this must NOT escape them again. Only the src is
598
+ // ours; everything else — including cleanUrl() on a src we leave alone — stays
599
+ // marked's job, so non-inlined images render exactly as before.
600
+ const baseImage = renderer.image.bind(renderer);
601
+ renderer.image = function(token) {
602
+ const isObj = (token !== null && typeof token === 'object');
603
+ const href = isObj ? (token.href || '') : (token || '');
604
+ const title = isObj ? (token.title || '') : (arguments[1] || '');
605
+ const text = isObj ? (token.text || '') : (arguments[2] || '');
606
+ const uri = inlineImageSrc(href);
607
+ if (!uri) return baseImage.apply(this, arguments);
608
+ // A data: URI is base64 (or an already-encoded payload): no quote, no '<'.
609
+ let out = `<img src="${uri}" alt="${text}"`;
610
+ if (title) out += ` title="${title}"`;
611
+ return out + '>';
612
+ };
490
613
 
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
- };
614
+ const baseHtml = renderer.html.bind(renderer);
615
+ renderer.html = function(token) {
616
+ return inlineImagesInHtmlChunk(baseHtml.apply(this, arguments));
617
+ };
508
618
 
509
- const baseHtml = renderer.html.bind(renderer);
510
- renderer.html = function(token) {
511
- return inlineImagesInHtmlChunk(baseHtml.apply(this, arguments));
512
- };
619
+ renderer.code = function(token) {
620
+ // token is either a string (old API) or {text, lang} object (new API)
621
+ const lang = (typeof token === 'object') ? (token.lang || '') : (arguments[1] || '');
622
+ const code = (typeof token === 'object') ? token.text : token;
513
623
 
514
- renderer.code = function(token) {
515
- // token is either a string (old API) or {text, lang} object (new API)
516
- const lang = (typeof token === 'object') ? (token.lang || '') : (arguments[1] || '');
517
- const code = (typeof token === 'object') ? token.text : token;
518
-
519
- if (lang === 'wavedrom') {
520
- usesWaveDrom = true;
521
- return `\n<script type="WaveDrom">\n${code}\n</script>\n`;
522
- }
523
- if (lang === 'mermaid') {
524
- usesMermaid = true;
525
- // Escape so the browser delivers the literal source to mermaid. Raw
526
- // injection lets the HTML parser consume entities and tags first —
527
- // an author's &lt;IP&gt; became an <IP> element mermaid sanitized
528
- // away — diverging from GitHub's escaped-code-block semantics.
529
- return `\n<div class="mermaid">\n${escapeHtml(code)}\n</div>\n`;
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`;
624
+ if (lang === 'wavedrom') {
625
+ usesWaveDrom = true;
626
+ return `\n<script type="WaveDrom">\n${code}\n</script>\n`;
540
627
  }
541
- }
542
- if (lang === 'dot' || lang === 'graphviz') {
543
- // Defer rendering to the async bakeGraphviz() post-pass so the
544
- // synchronous marked() pass stays sync. The dot source is carried as
545
- // base64 in a data attribute safe for arbitrary dot syntax (quotes,
546
- // angle brackets, newlines) inside an HTML attribute.
547
- const b64 = Buffer.from(code, 'utf8').toString('base64');
548
- return `\n<div class="graphviz" data-graphviz-src="${b64}"></div>\n`;
549
- }
550
- // Default: syntax-highlighted code block
551
- const escaped = code.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
552
- return `<pre><code class="language-${lang}">${escaped}</code></pre>\n`;
553
- };
628
+ if (lang === 'mermaid') {
629
+ usesMermaid = true;
630
+ // Escape so the browser delivers the literal source to mermaid. Raw
631
+ // injection lets the HTML parser consume entities and tags first
632
+ // an author's &lt;IP&gt; became an <IP> element mermaid sanitized
633
+ // away diverging from GitHub's escaped-code-block semantics.
634
+ return `\n<div class="mermaid">\n${escapeHtml(code)}\n</div>\n`;
635
+ }
636
+ if (lang === 'math') {
637
+ // KaTeX renders synchronously to static HTML (class="katex"); no client
638
+ // runtime, no async post-pass. throwOnError:false degrades a bad formula
639
+ // to red error text instead of crashing the whole render.
640
+ try {
641
+ return `\n${katex.renderToString(code, { displayMode: true, throwOnError: false })}\n`;
642
+ } catch (e) {
643
+ const esc = code.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
644
+ return `<pre><code class="language-math">${esc}</code></pre>\n`;
645
+ }
646
+ }
647
+ if (lang === 'dot' || lang === 'graphviz') {
648
+ // Defer rendering to the async bakeGraphviz() post-pass so the
649
+ // synchronous marked() pass stays sync. The dot source is carried as
650
+ // base64 in a data attribute — safe for arbitrary dot syntax (quotes,
651
+ // angle brackets, newlines) inside an HTML attribute.
652
+ const b64 = Buffer.from(code, 'utf8').toString('base64');
653
+ return `\n<div class="graphviz" data-graphviz-src="${b64}"></div>\n`;
654
+ }
655
+ // Default: syntax-highlighted code block
656
+ const escaped = code.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
657
+ return `<pre><code class="language-${lang}">${escaped}</code></pre>\n`;
658
+ };
554
659
 
555
- renderer.heading = function(token) {
556
- const depth = Number(token.depth || 1);
557
- const headingText = stripHtmlTags(flattenTokenText(token.tokens) || token.text || '');
558
- const headingId = slugifyHeading(headingText);
559
- const headingHtml = this.parser.parseInline(token.tokens);
660
+ renderer.heading = function(token) {
661
+ const depth = Number(token.depth || 1);
662
+ const headingText = stripHtmlTags(flattenTokenText(token.tokens) || token.text || '');
663
+ const headingId = slugifyHeading(headingText);
664
+ const headingHtml = this.parser.parseInline(token.tokens);
560
665
 
561
- tocItems.push({
562
- depth,
563
- id: headingId,
564
- text: headingText,
565
- });
566
- startSection({ depth, id: headingId, text: headingText });
666
+ tocItems.push({
667
+ depth,
668
+ id: headingId,
669
+ text: headingText,
670
+ });
671
+ startSection({ depth, id: headingId, text: headingText });
567
672
 
568
- return `<h${depth} id="${headingId}" class="heading-with-anchor" data-reader-heading data-reader-depth="${depth}">${headingHtml}<a class="heading-anchor" href="#${headingId}" aria-label="Link to this section">#</a></h${depth}>\n`;
569
- };
673
+ return `<h${depth} id="${headingId}" class="heading-with-anchor" data-reader-heading data-reader-depth="${depth}">${headingHtml}<a class="heading-anchor" href="#${headingId}" aria-label="Link to this section">#</a></h${depth}>\n`;
674
+ };
570
675
 
571
- const baseParagraph = renderer.paragraph.bind(renderer);
572
- renderer.paragraph = function(token) {
573
- appendSectionText(flattenTokenText(token.tokens));
574
- return baseParagraph(token);
575
- };
676
+ const baseParagraph = renderer.paragraph.bind(renderer);
677
+ renderer.paragraph = function(token) {
678
+ appendSectionText(flattenTokenText(token.tokens));
679
+ return baseParagraph(token);
680
+ };
576
681
 
577
- const baseListitem = renderer.listitem.bind(renderer);
578
- renderer.listitem = function(token) {
579
- appendSectionText(flattenTokenText(token.tokens));
580
- return baseListitem(token);
581
- };
682
+ const baseListitem = renderer.listitem.bind(renderer);
683
+ renderer.listitem = function(token) {
684
+ appendSectionText(flattenTokenText(token.tokens));
685
+ return baseListitem(token);
686
+ };
582
687
 
583
- const baseBlockquote = renderer.blockquote.bind(renderer);
584
- renderer.blockquote = function(token) {
585
- appendSectionText(flattenTokenText(token.tokens));
586
- return baseBlockquote(token);
587
- };
688
+ const baseBlockquote = renderer.blockquote.bind(renderer);
689
+ renderer.blockquote = function(token) {
690
+ appendSectionText(flattenTokenText(token.tokens));
691
+ return baseBlockquote(token);
692
+ };
588
693
 
589
- renderer.table = function(token) {
590
- collectCellText(token.header);
591
- if (Array.isArray(token.rows)) {
592
- for (const row of token.rows) {
593
- collectCellText(row);
694
+ renderer.table = function(token) {
695
+ collectCellText(token.header);
696
+ if (Array.isArray(token.rows)) {
697
+ for (const row of token.rows) {
698
+ collectCellText(row);
699
+ }
594
700
  }
595
- }
596
-
597
- const classes = classifyColumns(token);
598
- const colHtml = classes
599
- .map((c) => (c === 'col-default' ? '<col>' : `<col class="${c}">`))
600
- .join('');
601
- const cellClassAttr = (i) => {
602
- const c = classes[i];
603
- if (c === 'col-narrow') return ' class="cell-narrow"';
604
- if (c === 'col-prose') return ' class="cell-prose"';
605
- return '';
606
- };
607
- const alignStyle = (cell) => (cell && cell.align)
608
- ? ` style="text-align:${cell.align}"`
609
- : '';
610
701
 
611
- const headerCells = (token.header || []).map((cell, i) => {
612
- const inner = cell && Array.isArray(cell.tokens)
613
- ? this.parser.parseInline(cell.tokens)
702
+ const classes = classifyColumns(token);
703
+ const colHtml = classes
704
+ .map((c) => (c === 'col-default' ? '<col>' : `<col class="${c}">`))
705
+ .join('');
706
+ const cellClassAttr = (i) => {
707
+ const c = classes[i];
708
+ if (c === 'col-narrow') return ' class="cell-narrow"';
709
+ if (c === 'col-prose') return ' class="cell-prose"';
710
+ return '';
711
+ };
712
+ const alignStyle = (cell) => (cell && cell.align)
713
+ ? ` style="text-align:${cell.align}"`
614
714
  : '';
615
- return `<th${cellClassAttr(i)}${alignStyle(cell)}>${inner}</th>`;
616
- }).join('');
617
- const headerHtml = `<thead><tr>${headerCells}</tr></thead>`;
618
715
 
619
- const bodyRows = Array.isArray(token.rows) ? token.rows.map((row) => {
620
- const cells = (row || []).map((cell, i) => {
716
+ const headerCells = (token.header || []).map((cell, i) => {
621
717
  const inner = cell && Array.isArray(cell.tokens)
622
718
  ? this.parser.parseInline(cell.tokens)
623
719
  : '';
624
- return `<td${cellClassAttr(i)}${alignStyle(cell)}>${inner}</td>`;
720
+ return `<th${cellClassAttr(i)}${alignStyle(cell)}>${inner}</th>`;
625
721
  }).join('');
626
- return `<tr>${cells}</tr>`;
627
- }).join('') : '';
628
- const bodyHtmlPart = `<tbody>${bodyRows}</tbody>`;
629
-
630
- return `<table>\n<colgroup>${colHtml}</colgroup>\n${headerHtml}\n${bodyHtmlPart}\n</table>\n`;
631
- };
632
-
633
- marked.setOptions({ gfm: true, breaks: false, renderer });
634
-
635
- // Code-aware subscript / superscript as marked inline extensions.
636
- // The old raw-text pre-pass (mdPre.replace(/~([^~]+)~/...)) ran BEFORE marked
637
- // tokenised, so it rewrote ~NOT / ^XOR operators inside fenced, indented and
638
- // inline code into <sub>/<sup> (96 such mangles in one RTL spec). As inline
639
- // extensions marked tokenises code first, so these never fire inside code.
640
- // The tokenizer also requires a single whitespace-free token (~x~ / ^x^), so
641
- // spaced operator expressions (~a & ~b, a ^ b) and lone operators (2^24, ~rst)
642
- // stay literal even in prose — only a genuine subscript/superscript converts.
643
- marked.use({
644
- extensions: [
645
- {
646
- name: 'subscript',
647
- level: 'inline',
648
- start(src) { const i = src.indexOf('~'); return i < 0 ? undefined : i; },
649
- tokenizer(src) {
650
- const m = /^~(?=\S)([^~\s\n]+)~/.exec(src);
651
- if (m) {
652
- return { type: 'subscript', raw: m[0], text: m[1],
653
- tokens: this.lexer.inlineTokens(m[1]) };
654
- }
655
- },
656
- renderer(token) { return `<sub>${this.parser.parseInline(token.tokens)}</sub>`; },
657
- },
658
- {
659
- name: 'superscript',
660
- level: 'inline',
661
- start(src) { const i = src.indexOf('^'); return i < 0 ? undefined : i; },
662
- tokenizer(src) {
663
- const m = /^\^(?=\S)([^^\s\n]+)\^/.exec(src);
664
- if (m) {
665
- return { type: 'superscript', raw: m[0], text: m[1],
666
- tokens: this.lexer.inlineTokens(m[1]) };
667
- }
668
- },
669
- renderer(token) { return `<sup>${this.parser.parseInline(token.tokens)}</sup>`; },
670
- },
671
- ],
672
- });
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 }));
722
+ const headerHtml = `<thead><tr>${headerCells}</tr></thead>`;
723
+
724
+ const bodyRows = Array.isArray(token.rows) ? token.rows.map((row) => {
725
+ const cells = (row || []).map((cell, i) => {
726
+ const inner = cell && Array.isArray(cell.tokens)
727
+ ? this.parser.parseInline(cell.tokens)
728
+ : '';
729
+ return `<td${cellClassAttr(i)}${alignStyle(cell)}>${inner}</td>`;
730
+ }).join('');
731
+ return `<tr>${cells}</tr>`;
732
+ }).join('') : '';
733
+ const bodyHtmlPart = `<tbody>${bodyRows}</tbody>`;
734
+
735
+ return `<table>\n<colgroup>${colHtml}</colgroup>\n${headerHtml}\n${bodyHtmlPart}\n</table>\n`;
736
+ };
679
737
 
680
- // Pre-process the remaining non-standard inline syntax before marked parses
681
- const escAttr = (s) => String(s)
682
- .replace(/&/g, '&amp;')
683
- .replace(/</g, '&lt;')
684
- .replace(/>/g, '&gt;')
685
- .replace(/"/g, '&quot;');
686
- const mdPre = md
687
- .replace(/\[\[([^\]\n]+)\]\]/g, (_, inner) => { // [[ref-id, §sub]] clickable citation
688
- const body = inner.trim();
689
- const slug = body.split(',', 1)[0].trim();
690
- return `<a href="#${escAttr(slug)}">[${escAttr(body)}]</a>`;
691
- });
738
+ // subscript/superscript and $…$/$$…$$ KaTeX extensions are installed once
739
+ // at module-require time (see the top-level marked setup above) marked's
740
+ // global extension registry is shared, and marked.use() has no de-dup.
741
+ // setOptions() through the marked.parse()/parser()/lexer() calls below
742
+ // must stay synchronous: marked keeps its renderer/options as shared
743
+ // global state, so an await between setOptions and parse would let a
744
+ // concurrent renderMarkdown() call for a DIFFERENT request overwrite
745
+ // this renderer mid-render (the editor server handles requests
746
+ // concurrently, unlike the one-shot CLI).
747
+ marked.setOptions({ gfm: true, breaks: false, renderer });
748
+
749
+ // Pre-process the remaining non-standard inline syntax before marked parses
750
+ const escAttr = (s) => String(s)
751
+ .replace(/&/g, '&amp;')
752
+ .replace(/</g, '&lt;')
753
+ .replace(/>/g, '&gt;')
754
+ .replace(/"/g, '&quot;');
755
+ const mdPre = md
756
+ .replace(/\[\[([^\]\n]+)\]\]/g, (_, inner) => { // [[ref-id, §sub]] → clickable citation
757
+ const body = inner.trim();
758
+ const slug = body.split(',', 1)[0].trim();
759
+ return `<a href="#${escAttr(slug)}">[${escAttr(body)}]</a>`;
760
+ });
692
761
 
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);
697
- serializedSections = JSON.stringify(
698
- sections.map((section) => ({
699
- id: section.id,
700
- depth: section.depth,
701
- title: section.title,
702
- searchText: section.searchTextParts.join(' '),
703
- }))
704
- ).replace(/</g, '\\u003c');
705
- if (tocItems.length > 0) {
706
- const tocTree = buildTocTree(tocItems);
707
- tocHtml = `<aside class="reader-sidebar" data-reader-sidebar>
762
+ if (opts.editMode) {
763
+ // Ranges are computed on mdPre. The [[...]] preprocessing above only
764
+ // replaces text within single lines and never adds/removes newlines,
765
+ // so line ranges stay valid against the ORIGINAL md the client holds
766
+ // (guarded by the '[[...]] must not shift lines' test).
767
+ const { buildBlockMap } = require('./editor/blockmap.js');
768
+ blocks = buildBlockMap(mdPre).blocks;
769
+ // marked.lexer() inline-lexes as part of block lexing (verified against
770
+ // marked 14.1.4: a paragraph token's `.tokens` already holds resolved
771
+ // inline tokens such as `strong`/`link`, not raw text) — so
772
+ // marked.parser([t]) on one already-lexed top-level token reproduces
773
+ // exactly what the whole-document parse would emit for that token,
774
+ // using the same renderer/options set above.
775
+ const tokens = marked.lexer(mdPre);
776
+ const parts = [];
777
+ const biRef = { v: 0 };
778
+ for (const t of tokens) {
779
+ if (t.type === 'space') continue;
780
+ if (t.type === 'list') {
781
+ parts.push(renderEditModeList(t, blocks, biRef));
782
+ continue;
783
+ }
784
+ const b = blocks[biRef.v++];
785
+ const inner = marked.parser([t]);
786
+ parts.push(
787
+ `<div class="ed-block" data-block-id="${b.id}" data-block-type="${b.type}">` +
788
+ inner + '</div>'
789
+ );
790
+ }
791
+ bodyHtml = parts.join('\n');
792
+ } else {
793
+ bodyHtml = marked.parse(mdPre);
794
+ }
795
+ // Both the ```math fence and the $/$$ extension emit class="katex" — a single
796
+ // post-parse scan is the source of truth for conditional CSS injection.
797
+ usesMath = /class="katex/.test(bodyHtml);
798
+ serializedSections = JSON.stringify(
799
+ sections.map((section) => ({
800
+ id: section.id,
801
+ depth: section.depth,
802
+ title: section.title,
803
+ searchText: section.searchTextParts.join(' '),
804
+ }))
805
+ ).replace(/</g, '\\u003c');
806
+ if (tocItems.length > 0) {
807
+ const tocTree = buildTocTree(tocItems);
808
+ tocHtml = `<aside class="reader-sidebar" data-reader-sidebar>
708
809
  <section class="reader-tools">
709
810
  <label class="reader-search-label" for="doc-search-input">Search</label>
710
811
  <div class="reader-search-row">
@@ -735,17 +836,110 @@ ${itemsHtml}
735
836
  </nav>
736
837
  </aside>
737
838
  <div class="sidebar-splitter" id="sidebar-splitter" role="separator" aria-orientation="vertical" aria-label="Resize sidebar"></div>`;
839
+ }
738
840
  }
739
- } catch (e) {
740
- console.error('[ERROR] marked not found — install with: npm install marked');
741
- console.error(e.message);
742
- process.exit(1);
743
- }
744
841
 
745
- // ── HTML template ────────────────────────────────────────────────────────────
746
- const title = path.basename(src, '.md');
842
+ // ── HTML template ────────────────────────────────────────────────────────────
843
+ const title = path.basename(src, '.md');
844
+
845
+ // Edit-mode only: the reader runtime's diagram init becomes a re-invokable,
846
+ // idempotent hook (needed by a later task so re-rendered blocks can trigger
847
+ // diagram init again after a DOM swap, without a full page reload).
848
+ // Mermaid is explicitly initialized with startOnLoad:false here and driven
849
+ // entirely through this hook — running it alongside the non-edit path's
850
+ // `startOnLoad:true` auto-scan would race the same DOMContentLoaded event
851
+ // and risk mermaid processing (or erroring on) the same node twice.
852
+ // WaveDrom is driven entirely through this hook too (the non-edit path's
853
+ // own 4x-retry `renderWaveDrom` script is dropped in edit mode below) —
854
+ // WaveDrom.ProcessAll() (node_modules/wavedrom/lib/process-all.js) always
855
+ // rescans `document.querySelectorAll('*')` for elements whose `.type` is
856
+ // (case-insensitively) 'wavedrom' and unconditionally inserts a fresh
857
+ // WaveDrom_Display_* node for every match; it never marks a source node
858
+ // processed. Calling it more than once therefore re-renders (and
859
+ // duplicates the DOM nodes for) every diagram already rendered, not just
860
+ // new ones — confirmed empirically via puppeteer: 3 consecutive
861
+ // ProcessAll() calls produced WaveDrom_Display_* counts 1 -> 2 -> 3. Worse,
862
+ // ProcessAll() numbers whatever it finds THIS call starting at index 0
863
+ // (WaveDrom_Display_0, WaveDrom_Display_1, ...), and internally resolves
864
+ // both the source JSON (`eva('InputJSON_' + i)`) and the render target
865
+ // (`renderWaveForm` -> `getElementById('WaveDrom_Display_' + i)`) via
866
+ // getElementById — so on a second call, an id reused from an EARLIER call
867
+ // that's still sitting on an old, already-rendered node collides with the
868
+ // new call's id 0, and getElementById resolves to whichever element is
869
+ // FIRST in document order: a stale id can make a brand-new diagram get
870
+ // rendered from an old diagram's source JSON, or into an old diagram's
871
+ // display div (silently overwriting it), leaving the true new div empty —
872
+ // confirmed empirically the same way (adding a fresh unprocessed wavedrom
873
+ // script and re-invoking left the new WaveDrom_Display_* node with no
874
+ // <svg> while the original diagram's div picked up the new content
875
+ // instead). `type="WaveDrom"` / `id="InputJSON_*"` / `id="WaveDrom_Display_*"`
876
+ // are the only signals ProcessAll and its helpers use, so the fix is to
877
+ // reclaim all of those off every already-processed node before each call,
878
+ // so the call's fresh 0-based numbering can never collide with anything
879
+ // still resolvable via getElementById.
880
+ const diagramInitHookScript = `<script type="text/javascript" data-md2doc-diagram-engine="init-hook">
881
+ var __md2docWavedromSeq = 0;
882
+ window.__md2docInitDiagrams = function (rootEl) {
883
+ rootEl = rootEl || document;
884
+ if (typeof mermaid !== 'undefined') {
885
+ mermaid.initialize({ startOnLoad: false, theme: 'default' });
886
+ var mermaidNodes = rootEl.querySelectorAll ? rootEl.querySelectorAll('.mermaid') : [];
887
+ var pending = [];
888
+ for (var i = 0; i < mermaidNodes.length; i++) {
889
+ if (mermaidNodes[i].getAttribute('data-processed') !== 'true') {
890
+ pending.push(mermaidNodes[i]);
891
+ }
892
+ }
893
+ if (pending.length) {
894
+ mermaid.init(undefined, pending);
895
+ }
896
+ }
897
+ if (typeof WaveDrom !== 'undefined') {
898
+ // WaveDrom has no per-root scoping API — ProcessAll() always scans the
899
+ // whole document, so this branch is intentionally NOT scoped to
900
+ // rootEl (known limitation of this hook's "scoped to rootEl" contract
901
+ // for WaveDrom specifically). Only call it when there is at least one
902
+ // still-unprocessed script(type=WaveDrom) source node anywhere in the
903
+ // document; a node is "unprocessed" as long as its type attribute
904
+ // still literally reads "WaveDrom" (case as emitted by the code
905
+ // renderer above).
906
+ var wavePending = document.querySelectorAll('script[type="WaveDrom"]');
907
+ if (wavePending.length) {
908
+ // Reclaim every id ProcessAll's getElementById lookups could
909
+ // otherwise re-resolve to a stale, already-rendered node from an
910
+ // earlier call (see the comment above this script for why).
911
+ var stale = document.querySelectorAll(
912
+ '[id^="WaveDrom_Display_"], script[id^="InputJSON_"]'
913
+ );
914
+ for (var s = 0; s < stale.length; s++) {
915
+ stale[s].id = 'md2doc-wavedrom-done-' + (__md2docWavedromSeq++);
916
+ }
917
+ WaveDrom.ProcessAll();
918
+ for (var j = 0; j < wavePending.length; j++) {
919
+ // ProcessAll() just inserted the display div as the immediately
920
+ // preceding sibling of its source script node (see process-all.js:
921
+ // parentNode.insertBefore(node0, points.item(i))). Tag it with a
922
+ // stable class BEFORE the id reclaim above can ever rename it away
923
+ // (on a later call) — the CSS cursor rule and the lightbox click
924
+ // target selector both key off this class (in addition to the
925
+ // WaveDrom_Display_ id prefix, which non-edit pages rely on and
926
+ // never rename), so a diagram stays clickable and zoom-in-affordant
927
+ // for its whole lifetime, independent of id reclaiming.
928
+ var displayDiv = wavePending[j].previousElementSibling;
929
+ if (displayDiv) {
930
+ displayDiv.classList.add('wavedrom-diagram');
931
+ }
932
+ wavePending[j].setAttribute('type', 'WaveDrom-done');
933
+ }
934
+ }
935
+ }
936
+ };
937
+ window.addEventListener('DOMContentLoaded', function () {
938
+ window.__md2docInitDiagrams(document);
939
+ });
940
+ </script>`;
747
941
 
748
- const html = `<!DOCTYPE html>
942
+ const html = `<!DOCTYPE html>
749
943
  <html lang="en">
750
944
  <head>
751
945
  <meta charset="UTF-8">
@@ -825,7 +1019,7 @@ const html = `<!DOCTYPE html>
825
1019
  align-self: stretch;
826
1020
  cursor: col-resize;
827
1021
  display: flex;
828
- justify-content: center;
1022
+ justify-content: flex-start;
829
1023
  touch-action: none;
830
1024
  user-select: none;
831
1025
  }
@@ -835,6 +1029,7 @@ const html = `<!DOCTYPE html>
835
1029
  border-radius: 2px;
836
1030
  background: transparent;
837
1031
  transition: background 0.15s ease;
1032
+ margin-left: 4px;
838
1033
  }
839
1034
  .sidebar-splitter:hover::before,
840
1035
  .sidebar-splitter.is-dragging::before {
@@ -1328,7 +1523,8 @@ const html = `<!DOCTYPE html>
1328
1523
  .content img,
1329
1524
  .content .mermaid,
1330
1525
  .content .graphviz,
1331
- .content [id^="WaveDrom_Display_"] { cursor: zoom-in; }
1526
+ .content [id^="WaveDrom_Display_"],
1527
+ .content .wavedrom-diagram { cursor: zoom-in; }
1332
1528
  .content a img { cursor: pointer; }
1333
1529
 
1334
1530
  .lightbox {
@@ -1501,6 +1697,255 @@ const html = `<!DOCTYPE html>
1501
1697
  pre { font-size: 9pt; }
1502
1698
  a[href]:after { content: none; }
1503
1699
  }
1700
+ /* Editor runtime (browser --edit mode). These selectors only ever appear
1701
+ in edit-mode renders (.ed-block wrappers are only emitted when
1702
+ opts.editMode is set — see the marked.lexer()/marked.parser() loop
1703
+ above), so shipping this unconditionally is inert on normal HTML
1704
+ output, same precedent as the lightbox selectors above. */
1705
+ .ed-block { position: relative; cursor: pointer; }
1706
+ .ed-block:hover { outline: 1px dashed #b0b0b0; }
1707
+ .ed-li-text { display: block; min-height: 1em; }
1708
+ li.ed-block { cursor: text; }
1709
+ .ed-li-check { display: inline-block; width: 14px; height: 14px; margin-right: 6px;
1710
+ border: 1px solid #8a8a8a; border-radius: 3px; vertical-align: middle; cursor: pointer; }
1711
+ .ed-li-check[data-checked="1"] { background: #3b82f6; border-color: #3b82f6; }
1712
+ /* Phase 3 Task 2: always-on paragraph/heading/list editing. Every eligible
1713
+ block's content element is contenteditable from the moment it renders
1714
+ (armEditables() in the client runtime) — no persistent outline (that
1715
+ would outline the whole document at once); the blue "editing" outline
1716
+ only appears while the surface is actually focused, via :focus, so it
1717
+ naturally shows/hides itself in lockstep with the burst lifecycle
1718
+ (focusin starts a burst, focusout ends it) without any JS-driven class
1719
+ toggling. */
1720
+ .ed-wys-armed { cursor: text; }
1721
+ .ed-wys-armed:focus {
1722
+ outline: 2px solid #3b82f6; outline-offset: 2px;
1723
+ caret-color: #3b82f6;
1724
+ }
1725
+ /* Task 5: table cells armed PERMANENTLY (one edit surface per CELL, not
1726
+ per table root — see armEditables()'s 'table' branch in the client
1727
+ runtime) — same "no persistent outline, blue on :focus" language as
1728
+ .ed-wys-armed above, but INSET (negative offset), not outset: the
1729
+ enclosing table element has overflow-x: auto (see the table ruleset
1730
+ below), which would clip an outset outline on any cell near the
1731
+ table's horizontal edges. */
1732
+ .ed-wys-cell { cursor: text; }
1733
+ .ed-wys-cell:focus {
1734
+ outline: 2px solid #3b82f6; outline-offset: -2px;
1735
+ caret-color: #3b82f6;
1736
+ }
1737
+ /* Phase 3 Task 2: the ⠿ block-actions handle — one real per-block node
1738
+ (not a floating, JS-repositioned element like .ed-seltb/.ed-tb-insert),
1739
+ sat in the block's left gutter and revealed on hover. Only ever a
1740
+ visibility toggle (opacity), never display or pointer-events, so a
1741
+ script-driven click still reaches it even without a real hover. */
1742
+ .ed-handle {
1743
+ position: absolute; left: -22px; top: 0; width: 18px; height: 20px;
1744
+ display: flex; align-items: center; justify-content: center;
1745
+ padding: 0; margin: 0; border: none; border-radius: 4px;
1746
+ background: transparent; color: #8a8a8a; font-size: 13px; line-height: 1;
1747
+ cursor: pointer; opacity: 0; transition: opacity .12s ease, background .12s ease;
1748
+ }
1749
+ .ed-block:hover .ed-handle,
1750
+ .ed-handle:focus { opacity: 1; }
1751
+ .ed-handle:hover { background: rgba(0, 0, 0, 0.08); }
1752
+ /* The ⠿ handle's small menu: heading ± / MD 原始碼 / close. Dark
1753
+ translucent pill, bordered icon buttons — same visual language as
1754
+ .ed-seltb below. */
1755
+ .ed-handle-menu {
1756
+ position: absolute; top: -4px; left: -4px; z-index: 6;
1757
+ display: flex; align-items: center; gap: 4px;
1758
+ padding: 4px 6px; border-radius: 8px;
1759
+ background: rgba(16, 18, 21, 0.92); color: #e6edf3;
1760
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
1761
+ }
1762
+ .ed-handle-menu-btn {
1763
+ min-width: 28px; height: 26px; padding: 0 8px;
1764
+ border: 1px solid rgba(255, 255, 255, 0.25); border-radius: 6px;
1765
+ background: rgba(255, 255, 255, 0.08); color: inherit;
1766
+ font: inherit; font-size: 12px; line-height: 1;
1767
+ white-space: nowrap; cursor: pointer;
1768
+ }
1769
+ .ed-handle-menu-btn:hover { background: rgba(255, 255, 255, 0.18); }
1770
+ .ed-handle-menu-btn[hidden] { display: none; }
1771
+ /* §10-gap fix: block-level INSERT — the + button sits NEXT TO the ⠿
1772
+ handle in the left gutter, same node/visibility-toggle shape as
1773
+ .ed-handle above (real per-block node, opacity-only reveal on hover).
1774
+ STACKED ABOVE the handle (same left:-22px column, top shifted up)
1775
+ rather than beside it horizontally: .page-layout's own left padding is
1776
+ only 24px, and .ed-handle at left:-22px already consumes nearly all of
1777
+ it (its absolute viewport x lands at roughly 2px in a real edit-page
1778
+ layout — measured directly). A second 18px-wide button placed FURTHER
1779
+ left (e.g. left:-44px) would land at a NEGATIVE viewport x and be
1780
+ unreachable/unclickable outside the visible page. The brief's own
1781
+ wording allows this ("left gutter, above or beside it"). */
1782
+ .ed-insert {
1783
+ position: absolute; left: -22px; top: -22px; width: 18px; height: 20px;
1784
+ display: flex; align-items: center; justify-content: center;
1785
+ padding: 0; margin: 0; border: none; border-radius: 4px;
1786
+ background: transparent; color: #8a8a8a; font-size: 13px; line-height: 1;
1787
+ cursor: pointer; opacity: 0; transition: opacity .12s ease, background .12s ease;
1788
+ }
1789
+ .ed-block:hover .ed-insert,
1790
+ .ed-insert:focus { opacity: 1; }
1791
+ .ed-insert:hover { background: rgba(0, 0, 0, 0.08); }
1792
+ /* The + button's small menu: 段落/標題/清單/表格/程式碼 — same dark
1793
+ translucent pill language as .ed-handle-menu above. */
1794
+ .ed-insert-menu {
1795
+ position: absolute; top: -4px; left: -4px; z-index: 6;
1796
+ display: flex; align-items: center; gap: 4px;
1797
+ padding: 4px 6px; border-radius: 8px;
1798
+ background: rgba(16, 18, 21, 0.92); color: #e6edf3;
1799
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
1800
+ }
1801
+ .ed-insert-menu-btn {
1802
+ min-width: 28px; height: 26px; padding: 0 8px;
1803
+ border: 1px solid rgba(255, 255, 255, 0.25); border-radius: 6px;
1804
+ background: rgba(255, 255, 255, 0.08); color: inherit;
1805
+ font: inherit; font-size: 12px; line-height: 1;
1806
+ white-space: nowrap; cursor: pointer;
1807
+ }
1808
+ .ed-insert-menu-btn:hover { background: rgba(255, 255, 255, 0.18); }
1809
+ /* Task 5: hover-edge column/row insert bubbles — a SINGLETON pair of "+"
1810
+ buttons (client runtime repositions them via getBoundingClientRect(),
1811
+ never creates more than these two). Visual language deliberately
1812
+ matches .ed-handle above (small, low-contrast, no persistent chrome)
1813
+ rather than .ed-seltb/.ed-handle-menu's dark pill — this is a single
1814
+ small affordance shown only within TB_EDGE_PX of a boundary, not a
1815
+ toolbar. position: fixed matches the viewport-relative coordinates the
1816
+ client runtime computes via getBoundingClientRect(). */
1817
+ .ed-tb-insert {
1818
+ position: fixed; z-index: 8;
1819
+ width: 18px; height: 18px; padding: 0; margin: 0;
1820
+ display: flex; align-items: center; justify-content: center;
1821
+ border: 1px solid #3b82f6; border-radius: 50%;
1822
+ background: #fff; color: #3b82f6; font-size: 13px; line-height: 1;
1823
+ cursor: pointer; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
1824
+ }
1825
+ .ed-tb-insert:hover { background: #3b82f6; color: #fff; }
1826
+ .ed-tb-insert[hidden] { display: none; }
1827
+ /* Task 6: table edge-click menus (delete/align) + row drag-reorder.
1828
+ '.ed-te-hl' marks the currently-selected column's or row's CELLS
1829
+ (th+td) — never the <tr>, which paints nothing the user can see once a
1830
+ th/td above it carries its own opaque background (S3). !important
1831
+ because the sticky-first-column rules below (tbody td:first-child /
1832
+ thead th:first-child) carry higher specificity (0,1,2 vs this class's
1833
+ 0,1,0) and would otherwise win over a first-column/first-row highlight
1834
+ despite this rule appearing later in source order; the plain
1835
+ th-background rule above needs it too. '.ed-te-menu' is a SINGLETON
1836
+ floating menu (position: fixed, same viewport-relative idiom as .ed-seltb/.ed-tb-insert above),
1837
+ relabeled/repositioned per click rather than rebuilt. '.ed-te-drop-
1838
+ indicator' is the singleton line shown while dragging a row.
1839
+ '.ed-te-row-dragging' dims the row actually being dragged. */
1840
+ .ed-te-hl { background: rgba(59, 130, 246, 0.15) !important; }
1841
+ .ed-te-menu {
1842
+ position: fixed; z-index: 12;
1843
+ display: flex; align-items: center; gap: 4px;
1844
+ padding: 4px 6px; border-radius: 8px;
1845
+ background: rgba(16, 18, 21, 0.92); color: #e6edf3;
1846
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
1847
+ }
1848
+ .ed-te-menu[hidden] { display: none; }
1849
+ .ed-te-menu-btn {
1850
+ min-width: 28px; height: 26px; padding: 0 8px;
1851
+ border: 1px solid rgba(255, 255, 255, 0.25); border-radius: 6px;
1852
+ background: rgba(255, 255, 255, 0.08); color: inherit;
1853
+ font: inherit; font-size: 12px; line-height: 1;
1854
+ white-space: nowrap; cursor: pointer;
1855
+ }
1856
+ .ed-te-menu-btn:hover { background: rgba(255, 255, 255, 0.18); }
1857
+ .ed-te-menu-btn[hidden] { display: none; }
1858
+ .ed-te-drop-indicator {
1859
+ position: fixed; z-index: 8; height: 3px; border-radius: 2px;
1860
+ background: #3b82f6; pointer-events: none;
1861
+ }
1862
+ .ed-te-drop-indicator[hidden] { display: none; }
1863
+ .ed-te-row-dragging { opacity: 0.4; }
1864
+ /* Notion-style grip handles: replace the original invisible TE_EDGE_PX=8
1865
+ proximity zone (user-acceptance feedback: unusably small, no visible
1866
+ affordance) with two real, adequately-sized (>=18x24px) click/drag
1867
+ targets. '.ed-te-grip-row' is a vertical 6-dot handle shown at the LEFT
1868
+ EDGE of the hovered row -- every row, the HEADER included (spec 3.10:
1869
+ the header is draggable too), and sitting just INSIDE the table's left
1870
+ border rather than outside it, because the space outside belongs to the
1871
+ block's own gutter; '.ed-te-grip-col' is a horizontal 6-dot handle shown
1872
+ just ABOVE the hovered column (every column). Dots are plain <span>s laid out via CSS grid with
1873
+ place-content: center, so the dot cluster stays compact/centered
1874
+ regardless of the button's own (larger, hit-target-sized) box — no
1875
+ images, no background gradients. '.ed-te-grip-dragging' is EITHER
1876
+ grip's own "active drag handle" visual (grabbing cursor) while a row OR
1877
+ column drag is in flight (Task 8: columns became draggable too, same
1878
+ as rows) — see cancelTeDrag()/the pointermove listener in the client
1879
+ runtime. */
1880
+ .ed-te-grip {
1881
+ position: fixed; z-index: 9; padding: 0; margin: 0; border: none;
1882
+ border-radius: 4px; background: transparent;
1883
+ display: grid; place-content: center;
1884
+ transition: background .12s ease;
1885
+ }
1886
+ .ed-te-grip[hidden] { display: none; }
1887
+ .ed-te-grip:hover { background: rgba(0, 0, 0, 0.06); }
1888
+ .ed-te-grip-dot { width: 3px; height: 3px; border-radius: 50%; background: #9ca3af; }
1889
+ .ed-te-grip:hover .ed-te-grip-dot { background: #6b7280; }
1890
+ .ed-te-grip-row {
1891
+ width: 20px; height: 28px; cursor: grab; z-index: 7;
1892
+ grid-template-columns: repeat(2, 3px); grid-template-rows: repeat(3, 3px);
1893
+ gap: 3px 4px;
1894
+ }
1895
+ .ed-te-grip-row.ed-te-grip-dragging { cursor: grabbing; }
1896
+ .ed-te-grip-col {
1897
+ width: 28px; height: 24px; cursor: grab; z-index: 7;
1898
+ grid-template-columns: repeat(3, 3px); grid-template-rows: repeat(2, 3px);
1899
+ gap: 4px 3px;
1900
+ }
1901
+ .ed-te-grip-col.ed-te-grip-dragging { cursor: grabbing; }
1902
+ /* Task 4: floating selection toolbar (bold/italic/strikethrough/underline/
1903
+ code/link), shown over a
1904
+ non-collapsed selection inside an active WYSIWYG session. position:
1905
+ fixed matches the viewport-relative coordinates the client runtime
1906
+ computes via Range.getBoundingClientRect(). z-index sits below
1907
+ .ed-conflict (999). */
1908
+ .ed-seltb {
1909
+ position: fixed; z-index: 15;
1910
+ display: flex; align-items: center; gap: 4px;
1911
+ padding: 4px 6px; border-radius: 8px;
1912
+ background: rgba(16, 18, 21, 0.92); color: #e6edf3;
1913
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
1914
+ }
1915
+ .ed-seltb-btn {
1916
+ min-width: 28px; height: 26px; padding: 0 8px;
1917
+ border: 1px solid rgba(255, 255, 255, 0.25); border-radius: 6px;
1918
+ background: rgba(255, 255, 255, 0.08); color: inherit;
1919
+ font: inherit; font-size: 13px; line-height: 1;
1920
+ white-space: nowrap; cursor: pointer;
1921
+ }
1922
+ .ed-seltb-btn:hover { background: rgba(255, 255, 255, 0.18); }
1923
+ .ed-seltb-b { font-weight: bold; }
1924
+ .ed-seltb-i { font-style: italic; }
1925
+ .ed-seltb-s { text-decoration: line-through; }
1926
+ .ed-seltb-u { text-decoration: underline; }
1927
+ .ed-editing { position: relative; }
1928
+ .ed-raw {
1929
+ display: block; width: 100%; font-family: monospace; font-size: 13px;
1930
+ min-height: 3em; box-sizing: border-box; padding: 6px;
1931
+ border: 1px solid #808080; resize: vertical;
1932
+ }
1933
+ .ed-controls { display: flex; gap: 6px; margin-top: 4px; }
1934
+ .ed-controls button {
1935
+ font-size: 13px; padding: 2px 10px; cursor: pointer;
1936
+ border: 1px solid #b0b0b0; border-radius: 4px; background: #fff;
1937
+ }
1938
+ .ed-commit { color: #0a7a0a; }
1939
+ .ed-cancel { color: #b00020; }
1940
+ .ed-conflict {
1941
+ position: fixed; top: 0; left: 0; right: 0; padding: 10px;
1942
+ background: #b00020; color: #fff; z-index: 999; text-align: center;
1943
+ display: flex; align-items: center; justify-content: center; gap: 12px;
1944
+ }
1945
+ .ed-conflict button {
1946
+ background: #fff; color: #b00020; border: none; border-radius: 4px;
1947
+ padding: 4px 12px; cursor: pointer; font-weight: bold;
1948
+ }
1504
1949
  </style>
1505
1950
  ${usesMath ? buildKatexStyleTag() : ''}
1506
1951
  </head>
@@ -1515,7 +1960,20 @@ ${bodyHtml}
1515
1960
  </div>
1516
1961
 
1517
1962
  <!-- WaveDrom -->
1518
- ${usesWaveDrom ? `${waveDromSkinTag}
1963
+ ${usesWaveDrom
1964
+ ? (opts.editMode
1965
+ // Edit mode: WaveDrom init is driven entirely by diagramInitHookScript
1966
+ // (emitted from the Mermaid section below, unconditionally whenever
1967
+ // opts.editMode is true) — NOT by this block's own retry script.
1968
+ // Running both here would race: this script's own DOMContentLoaded
1969
+ // listener calls the un-deduped WaveDrom.ProcessAll() directly, so if
1970
+ // it fires before the hook gets a chance to mark the source node
1971
+ // processed, the very first page load already double-renders the
1972
+ // diagram. Only embedding the library here (no init script) removes
1973
+ // that race and leaves the hook as the single authority.
1974
+ ? `${waveDromSkinTag}
1975
+ ${waveDromTag}`
1976
+ : `${waveDromSkinTag}
1519
1977
  ${waveDromTag}
1520
1978
  <script type="text/javascript" data-md2doc-diagram-engine="wavedrom">
1521
1979
  function renderWaveDrom() {
@@ -1527,11 +1985,17 @@ ${waveDromTag}
1527
1985
  window.addEventListener('load', renderWaveDrom);
1528
1986
  setTimeout(renderWaveDrom, 250);
1529
1987
  setTimeout(renderWaveDrom, 1000);
1530
- </script>` : ''}
1988
+ </script>`)
1989
+ : ''}
1531
1990
 
1532
1991
  <!-- Mermaid -->
1533
- ${usesMermaid ? `${mermaidScriptTag}
1534
- ${mermaidInitTag}` : ''}
1992
+ ${usesMermaid
1993
+ ? (opts.editMode
1994
+ ? `${mermaidScriptTag}
1995
+ ${diagramInitHookScript}`
1996
+ : `${mermaidScriptTag}
1997
+ ${mermaidInitTag}`)
1998
+ : (opts.editMode ? diagramInitHookScript : '')}
1535
1999
 
1536
2000
  <!-- Reader runtime -->
1537
2001
  <script id="reader-section-data" type="application/json">${serializedSections}</script>
@@ -1549,7 +2013,14 @@ ${mermaidInitTag}` : ''}
1549
2013
 
1550
2014
  const rawData = document.getElementById('reader-section-data');
1551
2015
  const sections = rawData ? JSON.parse(rawData.textContent || '[]') : [];
1552
- const headingNodes = Array.from(document.querySelectorAll('[data-reader-heading]'));
2016
+ // let, not const: edit mode's rerenderAll() swaps .content's innerHTML
2017
+ // wholesale after a commit (see lib/editor/client.js), which detaches every
2018
+ // node this was captured from. window.__md2docRebindReader() below
2019
+ // re-queries and reassigns this same binding so every closure in this IIFE
2020
+ // that reads headingNodes (detectActiveHeading, the zoom/resize scroll
2021
+ // anchor's binary search, …) sees the live nodes without needing its own
2022
+ // re-init call — they all close over this one variable, not a copy of it.
2023
+ let headingNodes = Array.from(document.querySelectorAll('[data-reader-heading]'));
1553
2024
  const tocLinks = new Map(
1554
2025
  Array.from(document.querySelectorAll('.toc a[href^="#"]')).map((link) => [link.getAttribute('href').slice(1), link])
1555
2026
  );
@@ -1648,8 +2119,22 @@ ${mermaidInitTag}` : ''}
1648
2119
  }
1649
2120
  }
1650
2121
 
1651
- if (typeof IntersectionObserver !== 'undefined' && headingNodes.length) {
1652
- const observer = new IntersectionObserver((entries) => {
2122
+ // Hoisted out of a bare if block (and into a rebindable function) so that
2123
+ // window.__md2docRebindReader() below can disconnect the stale observer
2124
+ // and create a fresh one over the post-edit heading nodes — an observer
2125
+ // keeps observing the exact node references passed to observe(), so after
2126
+ // .content's innerHTML is replaced wholesale, the old observer is watching
2127
+ // detached nodes that will never intersect anything again.
2128
+ let headingObserver = null;
2129
+ function bindHeadingObserver() {
2130
+ if (headingObserver) {
2131
+ headingObserver.disconnect();
2132
+ headingObserver = null;
2133
+ }
2134
+ if (typeof IntersectionObserver === 'undefined' || !headingNodes.length) {
2135
+ return;
2136
+ }
2137
+ headingObserver = new IntersectionObserver((entries) => {
1653
2138
  if (observerFrozen) {
1654
2139
  return;
1655
2140
  }
@@ -1660,8 +2145,9 @@ ${mermaidInitTag}` : ''}
1660
2145
  syncActiveHeading(visible[0].target.id);
1661
2146
  }
1662
2147
  }, { rootMargin: '0px 0px -65% 0px', threshold: [0, 1] });
1663
- headingNodes.forEach((node) => observer.observe(node));
2148
+ headingNodes.forEach((node) => headingObserver.observe(node));
1664
2149
  }
2150
+ bindHeadingObserver();
1665
2151
 
1666
2152
  // Paint the breadcrumb immediately so the header is populated before the
1667
2153
  // first IntersectionObserver callback fires.
@@ -1669,6 +2155,26 @@ ${mermaidInitTag}` : ''}
1669
2155
  renderBreadcrumb(sections[0].id);
1670
2156
  }
1671
2157
 
2158
+ // Edit mode only: re-init hook, sibling to window.__md2docInitDiagrams
2159
+ // (defined in diagramInitHookScript above for edit-mode pages), called
2160
+ // from lib/editor/client.js's rerenderAll() right after it swaps
2161
+ // .content's innerHTML on a commit. Re-queries the heading nodes that
2162
+ // just got replaced and rebinds the IntersectionObserver onto them, so
2163
+ // TOC highlighting / breadcrumb tracking / the zoom-resize scroll anchor
2164
+ // (all of which read the headingNodes binding above) keep working
2165
+ // against live nodes instead of silently going dead after the first edit.
2166
+ // Defined unconditionally (this script runs on every page, not just edit
2167
+ // mode) but never invoked outside the edit-mode client — non-edit pages'
2168
+ // on-load behavior is unchanged since nothing here calls it automatically.
2169
+ window.__md2docRebindReader = function () {
2170
+ // Always re-queries the whole document, matching the initial-load query
2171
+ // above — heading nodes only ever live inside .content, but scoping this
2172
+ // to a passed-in root would just be an equivalent, more fragile way of
2173
+ // saying the same thing.
2174
+ headingNodes = Array.from(document.querySelectorAll('[data-reader-heading]'));
2175
+ bindHeadingObserver();
2176
+ };
2177
+
1672
2178
  const allTocDetails = () => Array.from(document.querySelectorAll('.toc details'));
1673
2179
  const expandAllBtn = document.getElementById('toc-expand-all');
1674
2180
  if (expandAllBtn) {
@@ -2197,7 +2703,7 @@ ${mermaidInitTag}` : ''}
2197
2703
  // Specs are read at 100% but their block diagrams and waveforms are drawn far
2198
2704
  // wider than the column, so the inline copy is unreadably small. Clicking one
2199
2705
  // pops it into a modal stage that zooms and scrolls.
2200
- var LIGHTBOX_TARGETS = 'img, .mermaid, .graphviz, [id^="WaveDrom_Display_"]';
2706
+ var LIGHTBOX_TARGETS = 'img, .mermaid, .graphviz, [id^="WaveDrom_Display_"], .wavedrom-diagram';
2201
2707
  var LIGHTBOX_MIN_ZOOM = 0.05;
2202
2708
  var LIGHTBOX_MAX_ZOOM = 8;
2203
2709
  var LIGHTBOX_STEP = 1.25;
@@ -3068,6 +3574,12 @@ ${mermaidInitTag}` : ''}
3068
3574
  </body>
3069
3575
  </html>`;
3070
3576
 
3577
+ const bakedHtml = await bakeGraphviz(html);
3578
+ const bakedBody = await bakeGraphviz(bodyHtml);
3579
+ return { html: bakedHtml, bodyHtml: bakedBody, blocks };
3580
+ }
3581
+ module.exports = { renderMarkdown };
3582
+
3071
3583
  // Render every deferred dot/graphviz placeholder to inline SVG using the
3072
3584
  // in-process WASM engine. Loads the WASM module only when at least one dot
3073
3585
  // block exists, so text-only docs pay nothing.
@@ -3108,7 +3620,7 @@ function launchBrowser(puppeteer) {
3108
3620
 
3109
3621
  // Pre-render mermaid/wavedrom to inert SVG using headless Chromium, then strip
3110
3622
  // the diagram-engine runtime scripts so the output HTML carries no diagram JS.
3111
- async function bakeDiagrams(htmlStr) {
3623
+ async function bakeDiagrams(htmlStr, dstPath) {
3112
3624
  if (!/data-md2doc-diagram-engine/.test(htmlStr)) return htmlStr;
3113
3625
  let puppeteer;
3114
3626
  try {
@@ -3117,7 +3629,7 @@ async function bakeDiagrams(htmlStr) {
3117
3629
  console.error('[ERROR] --bake-svg requires puppeteer/Chromium — install it, or drop --bake-svg:', e.message);
3118
3630
  process.exit(1);
3119
3631
  }
3120
- const tmp = dst.replace(/\.html$/i, '._bake.html');
3632
+ const tmp = dstPath.replace(/\.html$/i, '._bake.html');
3121
3633
  fs.writeFileSync(tmp, htmlStr, 'utf8');
3122
3634
  let browser;
3123
3635
  try {
@@ -3141,59 +3653,72 @@ async function bakeDiagrams(htmlStr) {
3141
3653
  }
3142
3654
  }
3143
3655
 
3144
- // ── Output ───────────────────────────────────────────────────────────────────
3145
- (async () => {
3146
- let finalHtml = await bakeGraphviz(html);
3147
-
3148
- if (ext === '.html') {
3149
- if (BAKE_SVG) finalHtml = await bakeDiagrams(finalHtml);
3150
- fs.writeFileSync(dst, finalHtml, 'utf8');
3151
- console.log(`[HTML] ${src} → ${dst}`);
3152
-
3153
- } else if (ext === '.pdf') {
3154
- if (BAKE_SVG) console.log('[INFO] --bake-svg is redundant for PDF output (already static); ignoring');
3155
- let puppeteer;
3156
- try {
3157
- puppeteer = require('puppeteer');
3158
- } catch (e) {
3159
- console.error('[ERROR] puppeteer not found — install with: npm install puppeteer');
3160
- process.exit(1);
3161
- }
3656
+ // ── CLI ──────────────────────────────────────────────────────────────────────
3657
+ if (require.main === module) {
3658
+ const [,, src, dst] = process.argv;
3659
+ const BAKE_SVG = process.argv.slice(4).includes('--bake-svg');
3660
+ if (!src || !dst) {
3661
+ console.error('Usage: node md2doc.js <input.md> <output.html|pdf>');
3662
+ process.exit(1);
3663
+ }
3162
3664
 
3163
- // Write temporary HTML, launch headless Chromium, export PDF.
3164
- // Case-insensitive: an uppercase .PDF dst must not make tmp === dst, or the
3165
- // unlinkSync below deletes the freshly written PDF.
3166
- const tmp = dst.replace(/\.pdf$/i, '._tmp.html');
3167
- fs.writeFileSync(tmp, finalHtml, 'utf8');
3665
+ const ext = path.extname(dst).toLowerCase();
3168
3666
 
3169
- const browser = await launchBrowser(puppeteer);
3170
- const page = await browser.newPage();
3667
+ (async () => {
3668
+ const mdText = fs.readFileSync(src, 'utf8');
3669
+ const { html } = await renderMarkdown(mdText, path.resolve(src), {});
3670
+ let finalHtml = html;
3171
3671
 
3172
- await page.goto('file://' + path.resolve(tmp), { waitUntil: 'load' });
3672
+ if (ext === '.html') {
3673
+ if (BAKE_SVG) finalHtml = await bakeDiagrams(finalHtml, dst);
3674
+ fs.writeFileSync(dst, finalHtml, 'utf8');
3675
+ console.log(`[HTML] ${src} → ${dst}`);
3173
3676
 
3174
- // Allow WaveDrom / Mermaid scripts time to render diagrams.
3175
- // NOTE: this sleep is load-bearing for the DEFAULT (view-time) render path.
3176
- // It is only safe to drop under --bake-svg, where the DOM is already final SVG.
3177
- await new Promise(r => setTimeout(r, DIAGRAM_RENDER_WAIT_MS));
3677
+ } else if (ext === '.pdf') {
3678
+ if (BAKE_SVG) console.log('[INFO] --bake-svg is redundant for PDF output (already static); ignoring');
3679
+ let puppeteer;
3680
+ try {
3681
+ puppeteer = require('puppeteer');
3682
+ } catch (e) {
3683
+ console.error('[ERROR] puppeteer not found — install with: npm install puppeteer');
3684
+ process.exit(1);
3685
+ }
3178
3686
 
3179
- await page.pdf({
3180
- path: dst,
3181
- format: 'A4',
3182
- printBackground: true,
3183
- outline: true,
3184
- tagged: true,
3185
- margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' }
3186
- });
3687
+ // Write temporary HTML, launch headless Chromium, export PDF.
3688
+ // Case-insensitive: an uppercase .PDF dst must not make tmp === dst, or the
3689
+ // unlinkSync below deletes the freshly written PDF.
3690
+ const tmp = dst.replace(/\.pdf$/i, '._tmp.html');
3691
+ fs.writeFileSync(tmp, finalHtml, 'utf8');
3692
+
3693
+ const browser = await launchBrowser(puppeteer);
3694
+ const page = await browser.newPage();
3695
+
3696
+ await page.goto('file://' + path.resolve(tmp), { waitUntil: 'load' });
3697
+
3698
+ // Allow WaveDrom / Mermaid scripts time to render diagrams.
3699
+ // NOTE: this sleep is load-bearing for the DEFAULT (view-time) render path.
3700
+ // It is only safe to drop under --bake-svg, where the DOM is already final SVG.
3701
+ await new Promise(r => setTimeout(r, DIAGRAM_RENDER_WAIT_MS));
3702
+
3703
+ await page.pdf({
3704
+ path: dst,
3705
+ format: 'A4',
3706
+ printBackground: true,
3707
+ outline: true,
3708
+ tagged: true,
3709
+ margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' }
3710
+ });
3187
3711
 
3188
- await browser.close();
3189
- fs.unlinkSync(tmp);
3190
- console.log(`[PDF] ${src} → ${dst}`);
3712
+ await browser.close();
3713
+ fs.unlinkSync(tmp);
3714
+ console.log(`[PDF] ${src} → ${dst}`);
3191
3715
 
3192
- } else {
3193
- console.error('[ERROR] Output extension must be .html or .pdf');
3716
+ } else {
3717
+ console.error('[ERROR] Output extension must be .html or .pdf');
3718
+ process.exit(1);
3719
+ }
3720
+ })().catch((e) => {
3721
+ console.error('[ERROR]', (e && e.stack) || e);
3194
3722
  process.exit(1);
3195
- }
3196
- })().catch((e) => {
3197
- console.error('[ERROR]', (e && e.stack) || e);
3198
- process.exit(1);
3199
- });
3723
+ });
3724
+ }