@helping-ai-workflow/md2doc 2.8.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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);
224
275
  }
225
276
 
226
- let uri = inlinedAssetCache.get(abs);
227
- if (uri === undefined) {
228
- uri = null;
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 };
287
+ }
288
+
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.
296
+ }
297
+ const candidates = [];
298
+ for (const c of [decoded, filePart]) {
299
+ candidates.push(path.isAbsolute(c) ? c : path.resolve(SRC_DIR, c));
239
300
  }
240
- inlinedAssetCache.set(abs, uri);
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
-
341
- function buildTocTree(items) {
342
- const root = [];
343
- const stack = [{ depth: 0, children: root }];
344
432
 
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 }];
449
+
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
+ }
356
458
 
357
- function renderTocNodes(nodes, level = 1) {
358
- if (!nodes.length) {
359
- return '';
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;
471
+
472
+ if (!hasChildren) {
473
+ return `<li class="toc-item toc-level-${level}">${linkHtml}</li>`;
474
+ }
370
475
 
371
- return `<li class="toc-item toc-level-${level} toc-parent">
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
- }
404
-
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
- }
555
+ const avgCellLen = heuristicTexts.length ? (totalLen / heuristicTexts.length) : 0;
413
556
 
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
701
 
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
-
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,249 @@ 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 cells (th+td) or row
1829
+ (the <tr> itself) — !important because the sticky-first-column rules
1830
+ below (tbody td:first-child / thead th:first-child) carry higher
1831
+ specificity (0,1,2 vs this class's 0,1,0) and would otherwise win over
1832
+ a first-column/first-row highlight despite this rule appearing later in
1833
+ source order. '.ed-te-menu' is a SINGLETON floating menu (position:
1834
+ fixed, same viewport-relative idiom as .ed-seltb/.ed-tb-insert above),
1835
+ relabeled/repositioned per click rather than rebuilt. '.ed-te-drop-
1836
+ indicator' is the singleton line shown while dragging a row.
1837
+ '.ed-te-row-dragging' dims the row actually being dragged. */
1838
+ .ed-te-hl { background: rgba(59, 130, 246, 0.15) !important; }
1839
+ .ed-te-menu {
1840
+ position: fixed; z-index: 12;
1841
+ display: flex; align-items: center; gap: 4px;
1842
+ padding: 4px 6px; border-radius: 8px;
1843
+ background: rgba(16, 18, 21, 0.92); color: #e6edf3;
1844
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
1845
+ }
1846
+ .ed-te-menu[hidden] { display: none; }
1847
+ .ed-te-menu-btn {
1848
+ min-width: 28px; height: 26px; padding: 0 8px;
1849
+ border: 1px solid rgba(255, 255, 255, 0.25); border-radius: 6px;
1850
+ background: rgba(255, 255, 255, 0.08); color: inherit;
1851
+ font: inherit; font-size: 12px; line-height: 1;
1852
+ white-space: nowrap; cursor: pointer;
1853
+ }
1854
+ .ed-te-menu-btn:hover { background: rgba(255, 255, 255, 0.18); }
1855
+ .ed-te-menu-btn[hidden] { display: none; }
1856
+ .ed-te-drop-indicator {
1857
+ position: fixed; z-index: 8; height: 3px; border-radius: 2px;
1858
+ background: #3b82f6; pointer-events: none;
1859
+ }
1860
+ .ed-te-drop-indicator[hidden] { display: none; }
1861
+ .ed-te-row-dragging { opacity: 0.4; }
1862
+ /* Notion-style grip handles: replace the original invisible TE_EDGE_PX=8
1863
+ proximity zone (user-acceptance feedback: unusably small, no visible
1864
+ affordance) with two real, adequately-sized (>=18x24px) click/drag
1865
+ targets. '.ed-te-grip-row' is a vertical 6-dot handle shown just LEFT
1866
+ of the hovered BODY row (never the header); '.ed-te-grip-col' is a
1867
+ horizontal 6-dot handle shown just ABOVE the hovered column (every
1868
+ column). Dots are plain <span>s laid out via CSS grid with
1869
+ place-content: center, so the dot cluster stays compact/centered
1870
+ regardless of the button's own (larger, hit-target-sized) box — no
1871
+ images, no background gradients. '.ed-te-grip-dragging' is the row
1872
+ grip's own "active drag handle" visual (grabbing cursor) while a row
1873
+ drag is in flight — see cancelTeDrag()/the pointermove listener in the
1874
+ client runtime. */
1875
+ .ed-te-grip {
1876
+ position: fixed; z-index: 9; padding: 0; margin: 0; border: none;
1877
+ border-radius: 4px; background: transparent;
1878
+ display: grid; place-content: center;
1879
+ transition: background .12s ease;
1880
+ }
1881
+ .ed-te-grip[hidden] { display: none; }
1882
+ .ed-te-grip:hover { background: rgba(0, 0, 0, 0.06); }
1883
+ .ed-te-grip-dot { width: 3px; height: 3px; border-radius: 50%; background: #9ca3af; }
1884
+ .ed-te-grip:hover .ed-te-grip-dot { background: #6b7280; }
1885
+ .ed-te-grip-row {
1886
+ width: 20px; height: 28px; cursor: grab; z-index: 7;
1887
+ grid-template-columns: repeat(2, 3px); grid-template-rows: repeat(3, 3px);
1888
+ gap: 3px 4px;
1889
+ }
1890
+ .ed-te-grip-row.ed-te-grip-dragging { cursor: grabbing; }
1891
+ .ed-te-grip-col {
1892
+ width: 28px; height: 24px; cursor: pointer; z-index: 7;
1893
+ grid-template-columns: repeat(3, 3px); grid-template-rows: repeat(2, 3px);
1894
+ gap: 4px 3px;
1895
+ }
1896
+ /* Task 4: floating selection toolbar (bold/italic/strikethrough/underline/
1897
+ code/link), shown over a
1898
+ non-collapsed selection inside an active WYSIWYG session. position:
1899
+ fixed matches the viewport-relative coordinates the client runtime
1900
+ computes via Range.getBoundingClientRect(). z-index sits below
1901
+ .ed-conflict (999). */
1902
+ .ed-seltb {
1903
+ position: fixed; z-index: 15;
1904
+ display: flex; align-items: center; gap: 4px;
1905
+ padding: 4px 6px; border-radius: 8px;
1906
+ background: rgba(16, 18, 21, 0.92); color: #e6edf3;
1907
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
1908
+ }
1909
+ .ed-seltb-btn {
1910
+ min-width: 28px; height: 26px; padding: 0 8px;
1911
+ border: 1px solid rgba(255, 255, 255, 0.25); border-radius: 6px;
1912
+ background: rgba(255, 255, 255, 0.08); color: inherit;
1913
+ font: inherit; font-size: 13px; line-height: 1;
1914
+ white-space: nowrap; cursor: pointer;
1915
+ }
1916
+ .ed-seltb-btn:hover { background: rgba(255, 255, 255, 0.18); }
1917
+ .ed-seltb-b { font-weight: bold; }
1918
+ .ed-seltb-i { font-style: italic; }
1919
+ .ed-seltb-s { text-decoration: line-through; }
1920
+ .ed-seltb-u { text-decoration: underline; }
1921
+ .ed-editing { position: relative; }
1922
+ .ed-raw {
1923
+ display: block; width: 100%; font-family: monospace; font-size: 13px;
1924
+ min-height: 3em; box-sizing: border-box; padding: 6px;
1925
+ border: 1px solid #808080; resize: vertical;
1926
+ }
1927
+ .ed-controls { display: flex; gap: 6px; margin-top: 4px; }
1928
+ .ed-controls button {
1929
+ font-size: 13px; padding: 2px 10px; cursor: pointer;
1930
+ border: 1px solid #b0b0b0; border-radius: 4px; background: #fff;
1931
+ }
1932
+ .ed-commit { color: #0a7a0a; }
1933
+ .ed-cancel { color: #b00020; }
1934
+ .ed-conflict {
1935
+ position: fixed; top: 0; left: 0; right: 0; padding: 10px;
1936
+ background: #b00020; color: #fff; z-index: 999; text-align: center;
1937
+ display: flex; align-items: center; justify-content: center; gap: 12px;
1938
+ }
1939
+ .ed-conflict button {
1940
+ background: #fff; color: #b00020; border: none; border-radius: 4px;
1941
+ padding: 4px 12px; cursor: pointer; font-weight: bold;
1942
+ }
1504
1943
  </style>
1505
1944
  ${usesMath ? buildKatexStyleTag() : ''}
1506
1945
  </head>
@@ -1515,7 +1954,20 @@ ${bodyHtml}
1515
1954
  </div>
1516
1955
 
1517
1956
  <!-- WaveDrom -->
1518
- ${usesWaveDrom ? `${waveDromSkinTag}
1957
+ ${usesWaveDrom
1958
+ ? (opts.editMode
1959
+ // Edit mode: WaveDrom init is driven entirely by diagramInitHookScript
1960
+ // (emitted from the Mermaid section below, unconditionally whenever
1961
+ // opts.editMode is true) — NOT by this block's own retry script.
1962
+ // Running both here would race: this script's own DOMContentLoaded
1963
+ // listener calls the un-deduped WaveDrom.ProcessAll() directly, so if
1964
+ // it fires before the hook gets a chance to mark the source node
1965
+ // processed, the very first page load already double-renders the
1966
+ // diagram. Only embedding the library here (no init script) removes
1967
+ // that race and leaves the hook as the single authority.
1968
+ ? `${waveDromSkinTag}
1969
+ ${waveDromTag}`
1970
+ : `${waveDromSkinTag}
1519
1971
  ${waveDromTag}
1520
1972
  <script type="text/javascript" data-md2doc-diagram-engine="wavedrom">
1521
1973
  function renderWaveDrom() {
@@ -1527,11 +1979,17 @@ ${waveDromTag}
1527
1979
  window.addEventListener('load', renderWaveDrom);
1528
1980
  setTimeout(renderWaveDrom, 250);
1529
1981
  setTimeout(renderWaveDrom, 1000);
1530
- </script>` : ''}
1982
+ </script>`)
1983
+ : ''}
1531
1984
 
1532
1985
  <!-- Mermaid -->
1533
- ${usesMermaid ? `${mermaidScriptTag}
1534
- ${mermaidInitTag}` : ''}
1986
+ ${usesMermaid
1987
+ ? (opts.editMode
1988
+ ? `${mermaidScriptTag}
1989
+ ${diagramInitHookScript}`
1990
+ : `${mermaidScriptTag}
1991
+ ${mermaidInitTag}`)
1992
+ : (opts.editMode ? diagramInitHookScript : '')}
1535
1993
 
1536
1994
  <!-- Reader runtime -->
1537
1995
  <script id="reader-section-data" type="application/json">${serializedSections}</script>
@@ -1549,7 +2007,14 @@ ${mermaidInitTag}` : ''}
1549
2007
 
1550
2008
  const rawData = document.getElementById('reader-section-data');
1551
2009
  const sections = rawData ? JSON.parse(rawData.textContent || '[]') : [];
1552
- const headingNodes = Array.from(document.querySelectorAll('[data-reader-heading]'));
2010
+ // let, not const: edit mode's rerenderAll() swaps .content's innerHTML
2011
+ // wholesale after a commit (see lib/editor/client.js), which detaches every
2012
+ // node this was captured from. window.__md2docRebindReader() below
2013
+ // re-queries and reassigns this same binding so every closure in this IIFE
2014
+ // that reads headingNodes (detectActiveHeading, the zoom/resize scroll
2015
+ // anchor's binary search, …) sees the live nodes without needing its own
2016
+ // re-init call — they all close over this one variable, not a copy of it.
2017
+ let headingNodes = Array.from(document.querySelectorAll('[data-reader-heading]'));
1553
2018
  const tocLinks = new Map(
1554
2019
  Array.from(document.querySelectorAll('.toc a[href^="#"]')).map((link) => [link.getAttribute('href').slice(1), link])
1555
2020
  );
@@ -1648,8 +2113,22 @@ ${mermaidInitTag}` : ''}
1648
2113
  }
1649
2114
  }
1650
2115
 
1651
- if (typeof IntersectionObserver !== 'undefined' && headingNodes.length) {
1652
- const observer = new IntersectionObserver((entries) => {
2116
+ // Hoisted out of a bare if block (and into a rebindable function) so that
2117
+ // window.__md2docRebindReader() below can disconnect the stale observer
2118
+ // and create a fresh one over the post-edit heading nodes — an observer
2119
+ // keeps observing the exact node references passed to observe(), so after
2120
+ // .content's innerHTML is replaced wholesale, the old observer is watching
2121
+ // detached nodes that will never intersect anything again.
2122
+ let headingObserver = null;
2123
+ function bindHeadingObserver() {
2124
+ if (headingObserver) {
2125
+ headingObserver.disconnect();
2126
+ headingObserver = null;
2127
+ }
2128
+ if (typeof IntersectionObserver === 'undefined' || !headingNodes.length) {
2129
+ return;
2130
+ }
2131
+ headingObserver = new IntersectionObserver((entries) => {
1653
2132
  if (observerFrozen) {
1654
2133
  return;
1655
2134
  }
@@ -1660,8 +2139,9 @@ ${mermaidInitTag}` : ''}
1660
2139
  syncActiveHeading(visible[0].target.id);
1661
2140
  }
1662
2141
  }, { rootMargin: '0px 0px -65% 0px', threshold: [0, 1] });
1663
- headingNodes.forEach((node) => observer.observe(node));
2142
+ headingNodes.forEach((node) => headingObserver.observe(node));
1664
2143
  }
2144
+ bindHeadingObserver();
1665
2145
 
1666
2146
  // Paint the breadcrumb immediately so the header is populated before the
1667
2147
  // first IntersectionObserver callback fires.
@@ -1669,6 +2149,26 @@ ${mermaidInitTag}` : ''}
1669
2149
  renderBreadcrumb(sections[0].id);
1670
2150
  }
1671
2151
 
2152
+ // Edit mode only: re-init hook, sibling to window.__md2docInitDiagrams
2153
+ // (defined in diagramInitHookScript above for edit-mode pages), called
2154
+ // from lib/editor/client.js's rerenderAll() right after it swaps
2155
+ // .content's innerHTML on a commit. Re-queries the heading nodes that
2156
+ // just got replaced and rebinds the IntersectionObserver onto them, so
2157
+ // TOC highlighting / breadcrumb tracking / the zoom-resize scroll anchor
2158
+ // (all of which read the headingNodes binding above) keep working
2159
+ // against live nodes instead of silently going dead after the first edit.
2160
+ // Defined unconditionally (this script runs on every page, not just edit
2161
+ // mode) but never invoked outside the edit-mode client — non-edit pages'
2162
+ // on-load behavior is unchanged since nothing here calls it automatically.
2163
+ window.__md2docRebindReader = function () {
2164
+ // Always re-queries the whole document, matching the initial-load query
2165
+ // above — heading nodes only ever live inside .content, but scoping this
2166
+ // to a passed-in root would just be an equivalent, more fragile way of
2167
+ // saying the same thing.
2168
+ headingNodes = Array.from(document.querySelectorAll('[data-reader-heading]'));
2169
+ bindHeadingObserver();
2170
+ };
2171
+
1672
2172
  const allTocDetails = () => Array.from(document.querySelectorAll('.toc details'));
1673
2173
  const expandAllBtn = document.getElementById('toc-expand-all');
1674
2174
  if (expandAllBtn) {
@@ -2197,7 +2697,7 @@ ${mermaidInitTag}` : ''}
2197
2697
  // Specs are read at 100% but their block diagrams and waveforms are drawn far
2198
2698
  // wider than the column, so the inline copy is unreadably small. Clicking one
2199
2699
  // pops it into a modal stage that zooms and scrolls.
2200
- var LIGHTBOX_TARGETS = 'img, .mermaid, .graphviz, [id^="WaveDrom_Display_"]';
2700
+ var LIGHTBOX_TARGETS = 'img, .mermaid, .graphviz, [id^="WaveDrom_Display_"], .wavedrom-diagram';
2201
2701
  var LIGHTBOX_MIN_ZOOM = 0.05;
2202
2702
  var LIGHTBOX_MAX_ZOOM = 8;
2203
2703
  var LIGHTBOX_STEP = 1.25;
@@ -2216,7 +2716,8 @@ ${mermaidInitTag}` : ''}
2216
2716
  // scratch, and localStorage keys would go stale when the doc regenerates).
2217
2717
  var ANNO_NS = 'http://www.w3.org/2000/svg';
2218
2718
  var ANNO_COLORS = ['#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#111827'];
2219
- var ANNO_WIDTHS = [0.6, 1, 1.8];
2719
+ // Office-like absolute stroke widths: rendered px at fit zoom (S/M/L).
2720
+ var ANNO_WIDTHS = [1, 2, 4];
2220
2721
  var annoStore = (typeof WeakMap === 'function') ? new WeakMap() : null;
2221
2722
  var annoShapes = [];
2222
2723
  var annoSvg = null;
@@ -2227,7 +2728,7 @@ ${mermaidInitTag}` : ''}
2227
2728
  var annoRedoStack = [];
2228
2729
  var annoStrokeW = 3;
2229
2730
  var annoColor = ANNO_COLORS[0];
2230
- var annoWidthScale = 1;
2731
+ var annoWidth = ANNO_WIDTHS[1];
2231
2732
  var annoToolButtons = {};
2232
2733
  var annoStyleButtons = [];
2233
2734
  var annoSourceNode = null;
@@ -2367,7 +2868,7 @@ ${mermaidInitTag}` : ''}
2367
2868
  el.setAttribute('stroke-linecap', 'round');
2368
2869
  }
2369
2870
  el.setAttribute('stroke', color);
2370
- el.setAttribute('stroke-width', (annoStrokeW * (shape.ws || 1)).toFixed(1));
2871
+ el.setAttribute('stroke-width', (shape.sw || annoStrokeW).toFixed(2));
2371
2872
  return el;
2372
2873
  }
2373
2874
 
@@ -2411,13 +2912,14 @@ ${mermaidInitTag}` : ''}
2411
2912
  }
2412
2913
  hit.setAttribute('fill', 'none');
2413
2914
  hit.setAttribute('stroke', 'transparent');
2414
- hit.setAttribute('stroke-width', String(annoStrokeW * 5));
2915
+ hit.setAttribute('stroke-width', String(Math.max((shape.sw || annoStrokeW) * 5, 10 / (lightboxFitZoom || 1))));
2415
2916
  hit.setAttribute('data-anno-hit', '');
2416
2917
  g.appendChild(hit);
2417
2918
  }
2418
2919
  annoSvg.appendChild(g);
2419
2920
  });
2420
2921
  if (annoSelected && annoShapes.indexOf(annoSelected) !== -1) {
2922
+ var u = 1.5 / (lightboxFitZoom || 1); // ~1.5px at fit zoom
2421
2923
  var box = annoBBox(annoSelected);
2422
2924
  var ui = document.createElementNS(ANNO_NS, 'rect');
2423
2925
  ui.setAttribute('data-anno-ui', '');
@@ -2425,10 +2927,10 @@ ${mermaidInitTag}` : ''}
2425
2927
  ui.setAttribute('width', Math.max(0.1, box.w)); ui.setAttribute('height', Math.max(0.1, box.h));
2426
2928
  ui.setAttribute('fill', 'none');
2427
2929
  ui.setAttribute('stroke', '#3b82f6');
2428
- ui.setAttribute('stroke-width', String(Math.max(1, annoStrokeW / 2)));
2429
- ui.setAttribute('stroke-dasharray', (annoStrokeW * 2) + ' ' + annoStrokeW);
2930
+ ui.setAttribute('stroke-width', String(u));
2931
+ ui.setAttribute('stroke-dasharray', (u * 4) + ' ' + (u * 2));
2430
2932
  annoSvg.appendChild(ui);
2431
- var hs = annoStrokeW * 3;
2933
+ var hs = u * 6;
2432
2934
  [[box.x, box.y], [box.x + box.w, box.y], [box.x, box.y + box.h], [box.x + box.w, box.y + box.h]]
2433
2935
  .forEach(function (corner, i) {
2434
2936
  var h = document.createElementNS(ANNO_NS, 'rect');
@@ -2460,7 +2962,7 @@ ${mermaidInitTag}` : ''}
2460
2962
  } else if (op.kind === 'style') {
2461
2963
  var style = reverse ? op.before : op.after;
2462
2964
  op.shape.color = style.color;
2463
- op.shape.ws = style.ws;
2965
+ op.shape.sw = style.sw;
2464
2966
  } else if (op.kind === 'clear') {
2465
2967
  if (reverse) op.shapes.forEach(function (s) { annoShapes.push(s); });
2466
2968
  else annoShapes.length = 0;
@@ -2507,21 +3009,21 @@ ${mermaidInitTag}` : ''}
2507
3009
 
2508
3010
  function setAnnoStyle(patch) {
2509
3011
  if (patch.color) annoColor = patch.color;
2510
- if (patch.ws) annoWidthScale = patch.ws;
3012
+ if (patch.w) annoWidth = patch.w;
2511
3013
  annoStyleButtons.forEach(function (entry) {
2512
3014
  entry.button.classList.toggle('is-active',
2513
- entry.kind === 'color' ? entry.value === annoColor : entry.value === annoWidthScale);
3015
+ entry.kind === 'color' ? entry.value === annoColor : entry.value === annoWidth);
2514
3016
  });
2515
3017
  // With a selection, the pickers restyle it (undoably); otherwise they only
2516
3018
  // set the style for the next shape.
2517
3019
  if (annoSelected) {
2518
- var beforeStyle = { color: annoSelected.color, ws: annoSelected.ws };
3020
+ var beforeStyle = { color: annoSelected.color, sw: annoSelected.sw };
2519
3021
  annoSelected.color = patch.color || annoSelected.color;
2520
- annoSelected.ws = patch.ws || annoSelected.ws;
3022
+ if (patch.w) annoSelected.sw = patch.w / (lightboxFitZoom || 1);
2521
3023
  annoPushOp({
2522
3024
  kind: 'style', shape: annoSelected,
2523
3025
  before: beforeStyle,
2524
- after: { color: annoSelected.color, ws: annoSelected.ws },
3026
+ after: { color: annoSelected.color, sw: annoSelected.sw },
2525
3027
  });
2526
3028
  annoRedraw();
2527
3029
  }
@@ -2690,6 +3192,11 @@ ${mermaidInitTag}` : ''}
2690
3192
  annoSelected = null;
2691
3193
  annoRedraw();
2692
3194
  }
3195
+ // The baked clone still shows the shape at its old spot — revert to the
3196
+ // base image for the gesture; the overlay alone renders the live shapes.
3197
+ if (annoDraft && annoCloneEl && annoCloneEl.tagName === 'IMG' && annoBaseSrc) {
3198
+ annoCloneEl.src = annoBaseSrc;
3199
+ }
2693
3200
  return;
2694
3201
  }
2695
3202
  var shape;
@@ -2699,7 +3206,7 @@ ${mermaidInitTag}` : ''}
2699
3206
  else if (annoMode === 'a') shape = { type: 'arrow', x1: pt.x, y1: pt.y, x2: pt.x, y2: pt.y };
2700
3207
  else shape = { type: 'rect', x: pt.x, y: pt.y, w: 0, h: 0 };
2701
3208
  shape.color = annoColor;
2702
- shape.ws = annoWidthScale;
3209
+ shape.sw = annoWidth / (lightboxFitZoom || 1);
2703
3210
  annoDraft = { kind: 'draw', shape: shape, start: pt, el: annoShapeEl(shape), clientDist: 0, lastClient: [event.clientX, event.clientY] };
2704
3211
  annoSvg.appendChild(annoDraft.el);
2705
3212
  }
@@ -2766,6 +3273,8 @@ ${mermaidInitTag}` : ''}
2766
3273
  }
2767
3274
  if (d.moved) {
2768
3275
  annoPushOp({ kind: 'geom', shape: d.shape, before: d.before, after: annoGeomOf(d.shape) });
3276
+ } else {
3277
+ annoBake(); // gesture reverted the clone to the base image — restore it
2769
3278
  }
2770
3279
  annoRedraw();
2771
3280
  }
@@ -2845,9 +3354,9 @@ ${mermaidInitTag}` : ''}
2845
3354
  bar.appendChild(swatch);
2846
3355
  });
2847
3356
  [['S', ANNO_WIDTHS[0]], ['M', ANNO_WIDTHS[1]], ['L', ANNO_WIDTHS[2]]].forEach(function (width) {
2848
- var button = lightboxButton(width[0], 'Stroke width ' + width[0], function () { setAnnoStyle({ ws: width[1] }); });
3357
+ var button = lightboxButton(width[0], 'Stroke width ' + width[1] + 'px', function () { setAnnoStyle({ w: width[1] }); });
2849
3358
  button.setAttribute('data-anno-width', String(width[1]));
2850
- if (width[1] === annoWidthScale) button.classList.add('is-active');
3359
+ if (width[1] === annoWidth) button.classList.add('is-active');
2851
3360
  annoStyleButtons.push({ kind: 'width', value: width[1], button: button });
2852
3361
  bar.appendChild(button);
2853
3362
  });
@@ -2948,7 +3457,18 @@ ${mermaidInitTag}` : ''}
2948
3457
 
2949
3458
  var isVector = !!(source.tagName && source.tagName.toLowerCase() === 'svg');
2950
3459
  var clone = source.cloneNode(true);
2951
- clone.removeAttribute('id');
3460
+ // Mermaid scopes its embedded <style> to the svg's #id — dropping the id
3461
+ // kills the theme. Rename it instead (avoids a duplicate id) and rewrite
3462
+ // the scoped selectors to the new name.
3463
+ if (isVector && source.id) {
3464
+ var lightboxCloneId = 'lightbox-' + source.id;
3465
+ clone.setAttribute('id', lightboxCloneId);
3466
+ Array.prototype.forEach.call(clone.querySelectorAll('style'), function (styleEl) {
3467
+ styleEl.textContent = styleEl.textContent.split('#' + source.id).join('#' + lightboxCloneId);
3468
+ });
3469
+ } else {
3470
+ clone.removeAttribute('id');
3471
+ }
2952
3472
  clone.removeAttribute('class');
2953
3473
  clone.removeAttribute('style');
2954
3474
  clone.removeAttribute('width');
@@ -3048,6 +3568,12 @@ ${mermaidInitTag}` : ''}
3048
3568
  </body>
3049
3569
  </html>`;
3050
3570
 
3571
+ const bakedHtml = await bakeGraphviz(html);
3572
+ const bakedBody = await bakeGraphviz(bodyHtml);
3573
+ return { html: bakedHtml, bodyHtml: bakedBody, blocks };
3574
+ }
3575
+ module.exports = { renderMarkdown };
3576
+
3051
3577
  // Render every deferred dot/graphviz placeholder to inline SVG using the
3052
3578
  // in-process WASM engine. Loads the WASM module only when at least one dot
3053
3579
  // block exists, so text-only docs pay nothing.
@@ -3088,7 +3614,7 @@ function launchBrowser(puppeteer) {
3088
3614
 
3089
3615
  // Pre-render mermaid/wavedrom to inert SVG using headless Chromium, then strip
3090
3616
  // the diagram-engine runtime scripts so the output HTML carries no diagram JS.
3091
- async function bakeDiagrams(htmlStr) {
3617
+ async function bakeDiagrams(htmlStr, dstPath) {
3092
3618
  if (!/data-md2doc-diagram-engine/.test(htmlStr)) return htmlStr;
3093
3619
  let puppeteer;
3094
3620
  try {
@@ -3097,7 +3623,7 @@ async function bakeDiagrams(htmlStr) {
3097
3623
  console.error('[ERROR] --bake-svg requires puppeteer/Chromium — install it, or drop --bake-svg:', e.message);
3098
3624
  process.exit(1);
3099
3625
  }
3100
- const tmp = dst.replace(/\.html$/i, '._bake.html');
3626
+ const tmp = dstPath.replace(/\.html$/i, '._bake.html');
3101
3627
  fs.writeFileSync(tmp, htmlStr, 'utf8');
3102
3628
  let browser;
3103
3629
  try {
@@ -3121,59 +3647,72 @@ async function bakeDiagrams(htmlStr) {
3121
3647
  }
3122
3648
  }
3123
3649
 
3124
- // ── Output ───────────────────────────────────────────────────────────────────
3125
- (async () => {
3126
- let finalHtml = await bakeGraphviz(html);
3127
-
3128
- if (ext === '.html') {
3129
- if (BAKE_SVG) finalHtml = await bakeDiagrams(finalHtml);
3130
- fs.writeFileSync(dst, finalHtml, 'utf8');
3131
- console.log(`[HTML] ${src} → ${dst}`);
3132
-
3133
- } else if (ext === '.pdf') {
3134
- if (BAKE_SVG) console.log('[INFO] --bake-svg is redundant for PDF output (already static); ignoring');
3135
- let puppeteer;
3136
- try {
3137
- puppeteer = require('puppeteer');
3138
- } catch (e) {
3139
- console.error('[ERROR] puppeteer not found — install with: npm install puppeteer');
3140
- process.exit(1);
3141
- }
3650
+ // ── CLI ──────────────────────────────────────────────────────────────────────
3651
+ if (require.main === module) {
3652
+ const [,, src, dst] = process.argv;
3653
+ const BAKE_SVG = process.argv.slice(4).includes('--bake-svg');
3654
+ if (!src || !dst) {
3655
+ console.error('Usage: node md2doc.js <input.md> <output.html|pdf>');
3656
+ process.exit(1);
3657
+ }
3142
3658
 
3143
- // Write temporary HTML, launch headless Chromium, export PDF.
3144
- // Case-insensitive: an uppercase .PDF dst must not make tmp === dst, or the
3145
- // unlinkSync below deletes the freshly written PDF.
3146
- const tmp = dst.replace(/\.pdf$/i, '._tmp.html');
3147
- fs.writeFileSync(tmp, finalHtml, 'utf8');
3659
+ const ext = path.extname(dst).toLowerCase();
3148
3660
 
3149
- const browser = await launchBrowser(puppeteer);
3150
- const page = await browser.newPage();
3661
+ (async () => {
3662
+ const mdText = fs.readFileSync(src, 'utf8');
3663
+ const { html } = await renderMarkdown(mdText, path.resolve(src), {});
3664
+ let finalHtml = html;
3151
3665
 
3152
- await page.goto('file://' + path.resolve(tmp), { waitUntil: 'load' });
3666
+ if (ext === '.html') {
3667
+ if (BAKE_SVG) finalHtml = await bakeDiagrams(finalHtml, dst);
3668
+ fs.writeFileSync(dst, finalHtml, 'utf8');
3669
+ console.log(`[HTML] ${src} → ${dst}`);
3153
3670
 
3154
- // Allow WaveDrom / Mermaid scripts time to render diagrams.
3155
- // NOTE: this sleep is load-bearing for the DEFAULT (view-time) render path.
3156
- // It is only safe to drop under --bake-svg, where the DOM is already final SVG.
3157
- await new Promise(r => setTimeout(r, DIAGRAM_RENDER_WAIT_MS));
3671
+ } else if (ext === '.pdf') {
3672
+ if (BAKE_SVG) console.log('[INFO] --bake-svg is redundant for PDF output (already static); ignoring');
3673
+ let puppeteer;
3674
+ try {
3675
+ puppeteer = require('puppeteer');
3676
+ } catch (e) {
3677
+ console.error('[ERROR] puppeteer not found — install with: npm install puppeteer');
3678
+ process.exit(1);
3679
+ }
3158
3680
 
3159
- await page.pdf({
3160
- path: dst,
3161
- format: 'A4',
3162
- printBackground: true,
3163
- outline: true,
3164
- tagged: true,
3165
- margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' }
3166
- });
3681
+ // Write temporary HTML, launch headless Chromium, export PDF.
3682
+ // Case-insensitive: an uppercase .PDF dst must not make tmp === dst, or the
3683
+ // unlinkSync below deletes the freshly written PDF.
3684
+ const tmp = dst.replace(/\.pdf$/i, '._tmp.html');
3685
+ fs.writeFileSync(tmp, finalHtml, 'utf8');
3686
+
3687
+ const browser = await launchBrowser(puppeteer);
3688
+ const page = await browser.newPage();
3689
+
3690
+ await page.goto('file://' + path.resolve(tmp), { waitUntil: 'load' });
3691
+
3692
+ // Allow WaveDrom / Mermaid scripts time to render diagrams.
3693
+ // NOTE: this sleep is load-bearing for the DEFAULT (view-time) render path.
3694
+ // It is only safe to drop under --bake-svg, where the DOM is already final SVG.
3695
+ await new Promise(r => setTimeout(r, DIAGRAM_RENDER_WAIT_MS));
3696
+
3697
+ await page.pdf({
3698
+ path: dst,
3699
+ format: 'A4',
3700
+ printBackground: true,
3701
+ outline: true,
3702
+ tagged: true,
3703
+ margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' }
3704
+ });
3167
3705
 
3168
- await browser.close();
3169
- fs.unlinkSync(tmp);
3170
- console.log(`[PDF] ${src} → ${dst}`);
3706
+ await browser.close();
3707
+ fs.unlinkSync(tmp);
3708
+ console.log(`[PDF] ${src} → ${dst}`);
3171
3709
 
3172
- } else {
3173
- console.error('[ERROR] Output extension must be .html or .pdf');
3710
+ } else {
3711
+ console.error('[ERROR] Output extension must be .html or .pdf');
3712
+ process.exit(1);
3713
+ }
3714
+ })().catch((e) => {
3715
+ console.error('[ERROR]', (e && e.stack) || e);
3174
3716
  process.exit(1);
3175
- }
3176
- })().catch((e) => {
3177
- console.error('[ERROR]', (e && e.stack) || e);
3178
- process.exit(1);
3179
- });
3717
+ });
3718
+ }