@helping-ai-workflow/md2doc 2.8.1 → 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/README.md +226 -0
- package/bin/md2doc.js +46 -33
- package/lib/editor/blockmap.js +75 -0
- package/lib/editor/cli.js +23 -0
- package/lib/editor/client.js +4739 -0
- package/lib/editor/history.js +94 -0
- package/lib/editor/inline-md.js +220 -0
- package/lib/editor/lineops.js +71 -0
- package/lib/editor/list-md.js +258 -0
- package/lib/editor/open.js +59 -0
- package/lib/editor/server.js +190 -0
- package/lib/editor/table-md.js +169 -0
- package/lib/md2doc.js +1110 -591
- package/package.json +2 -2
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
|
-
// ──
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
// the
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
// Doubles as the allowlist: a reference whose extension is not an image is left
|
|
144
|
-
// alone. Without that gate, `` would happily base64
|
|
145
|
-
// the file into a document meant to be shared.
|
|
146
|
-
const IMAGE_MIME_BY_EXT = {
|
|
147
|
-
'.png': 'image/png',
|
|
148
|
-
'.jpg': 'image/jpeg',
|
|
149
|
-
'.jpeg': 'image/jpeg',
|
|
150
|
-
'.jfif': 'image/jpeg',
|
|
151
|
-
'.gif': 'image/gif',
|
|
152
|
-
'.svg': 'image/svg+xml',
|
|
153
|
-
'.webp': 'image/webp',
|
|
154
|
-
'.avif': 'image/avif',
|
|
155
|
-
'.bmp': 'image/bmp',
|
|
156
|
-
'.ico': 'image/x-icon',
|
|
157
|
-
'.apng': 'image/apng',
|
|
158
|
-
'.tif': 'image/tiff',
|
|
159
|
-
'.tiff': 'image/tiff',
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
const inlinedAssetCache = new Map();
|
|
163
|
-
const skippedAssetWarned = new Set();
|
|
164
|
-
|
|
165
|
-
// Only a scheme we actually know is treated as remote — a bare `letter+colon`
|
|
166
|
-
// test would misread both a Windows drive (`C:/img.png`) and a filename that
|
|
167
|
-
// merely contains a colon. `//host/x.png` is protocol-relative.
|
|
168
|
-
const EXTERNAL_REF_RE = /^(?:(?:https?|data|file|blob|ftps?|mailto|tel|about|chrome|chrome-extension|moz-extension):|\/\/)/i;
|
|
169
|
-
|
|
170
|
-
function isExternalRef(href) {
|
|
171
|
-
return EXTERNAL_REF_RE.test(String(href).trim());
|
|
172
|
-
}
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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, `` 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
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
271
|
+
function warnOnce(href, message) {
|
|
272
|
+
if (skippedAssetWarned.has(href)) return;
|
|
273
|
+
skippedAssetWarned.add(href);
|
|
274
|
+
console.error(message);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Splits `assets/pic.svg?v=2#gear` into its path and its fragment. The query is
|
|
278
|
+
// dropped (it is cache-busting for a fetch that no longer happens); the
|
|
279
|
+
// fragment is kept and re-attached to the data URI, since it selects a view /
|
|
280
|
+
// symbol inside an SVG rather than addressing the file.
|
|
281
|
+
function splitAssetRef(href) {
|
|
282
|
+
const raw = String(href).trim();
|
|
283
|
+
const hash = raw.indexOf('#');
|
|
284
|
+
const withoutHash = hash === -1 ? raw : raw.slice(0, hash);
|
|
285
|
+
const fragment = hash === -1 ? '' : raw.slice(hash);
|
|
286
|
+
return { filePart: withoutHash.replace(/\?.*$/, ''), fragment };
|
|
224
287
|
}
|
|
225
288
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
289
|
+
function resolveAssetPath(filePart) {
|
|
290
|
+
if (!filePart) return null;
|
|
291
|
+
let decoded = filePart;
|
|
229
292
|
try {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
console.error(`[WARN] inlining large image (${(stat.size / 1048576).toFixed(1)} MB): ${href}`);
|
|
234
|
-
}
|
|
235
|
-
uri = `data:${mime};base64,${fs.readFileSync(abs).toString('base64')}`;
|
|
236
|
-
} catch (e) {
|
|
237
|
-
console.error(`[WARN] could not inline image ${href}: ${e.message}`);
|
|
238
|
-
uri = null;
|
|
293
|
+
decoded = decodeURIComponent(filePart);
|
|
294
|
+
} catch (_) {
|
|
295
|
+
// Malformed percent-escapes: probe the raw form only.
|
|
239
296
|
}
|
|
240
|
-
|
|
297
|
+
const candidates = [];
|
|
298
|
+
for (const c of [decoded, filePart]) {
|
|
299
|
+
candidates.push(path.isAbsolute(c) ? c : path.resolve(SRC_DIR, c));
|
|
300
|
+
}
|
|
301
|
+
return firstExistingPath(candidates);
|
|
241
302
|
}
|
|
242
|
-
return uri ? uri + fragment : null;
|
|
243
|
-
}
|
|
244
303
|
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
// and a plain [^>]* would truncate the tag and miss a src that sits after it.
|
|
266
|
-
const ASSET_TAG_RE = /<(?:img|source)\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
|
|
267
|
-
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
268
|
-
const SRC_ATTR_RE = /(\s(?:src|srcset)\s*=\s*)(?:(["'])([^"']*)\2|([^\s"'=<>`]+))/gi;
|
|
269
|
-
|
|
270
|
-
function inlineImagesInTag(tag) {
|
|
271
|
-
return tag.replace(SRC_ATTR_RE, (match, lead, quote, quoted, bare) => {
|
|
272
|
-
const raw = (quoted !== undefined ? quoted : bare) || '';
|
|
273
|
-
const decoded = raw.replace(/&/g, '&');
|
|
274
|
-
const isSrcset = /srcset\s*=\s*$/i.test(lead);
|
|
275
|
-
const value = isSrcset ? inlineSrcsetValue(decoded) : inlineImageSrc(decoded);
|
|
276
|
-
return value ? `${lead}"${value}"` : match;
|
|
277
|
-
});
|
|
278
|
-
}
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
try {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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(/&/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
|
|
319
|
-
if (!
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
if (
|
|
334
|
-
|
|
335
|
-
} else if (cell && typeof cell.text === 'string') {
|
|
336
|
-
appendSectionText(cell.text);
|
|
423
|
+
function appendSectionText(value) {
|
|
424
|
+
if (!currentSection || !value) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const clean = stripHtmlTags(value).replace(/\s+/g, ' ').trim();
|
|
428
|
+
if (clean) {
|
|
429
|
+
currentSection.searchTextParts.push(clean);
|
|
337
430
|
}
|
|
338
431
|
}
|
|
339
|
-
}
|
|
340
432
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
355
|
-
|
|
446
|
+
function buildTocTree(items) {
|
|
447
|
+
const root = [];
|
|
448
|
+
const stack = [{ depth: 0, children: root }];
|
|
356
449
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
450
|
+
for (const item of items) {
|
|
451
|
+
const node = { ...item, children: [] };
|
|
452
|
+
while (stack.length > 1 && item.depth <= stack[stack.length - 1].depth) {
|
|
453
|
+
stack.pop();
|
|
454
|
+
}
|
|
455
|
+
stack[stack.length - 1].children.push(node);
|
|
456
|
+
stack.push(node);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return root;
|
|
360
460
|
}
|
|
361
461
|
|
|
362
|
-
|
|
363
|
-
.
|
|
364
|
-
|
|
365
|
-
|
|
462
|
+
function renderTocNodes(nodes, level = 1) {
|
|
463
|
+
if (!nodes.length) {
|
|
464
|
+
return '';
|
|
465
|
+
}
|
|
366
466
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
467
|
+
const itemsHtml = nodes
|
|
468
|
+
.map((node) => {
|
|
469
|
+
const linkHtml = `<a href="#${node.id}" title="${escapeHtml(node.text)}">${escapeHtml(node.text)}</a>`;
|
|
470
|
+
const hasChildren = node.children && node.children.length > 0;
|
|
370
471
|
|
|
371
|
-
|
|
472
|
+
if (!hasChildren) {
|
|
473
|
+
return `<li class="toc-item toc-level-${level}">${linkHtml}</li>`;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return `<li class="toc-item toc-level-${level} toc-parent">
|
|
372
477
|
<details>
|
|
373
478
|
<summary>${linkHtml}</summary>
|
|
374
479
|
${renderTocNodes(node.children, level + 1)}
|
|
375
480
|
</details>
|
|
376
481
|
</li>`;
|
|
377
|
-
|
|
378
|
-
|
|
482
|
+
})
|
|
483
|
+
.join('\n');
|
|
379
484
|
|
|
380
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
398
|
-
|
|
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
|
-
|
|
401
|
-
})
|
|
402
|
-
.join('');
|
|
403
|
-
}
|
|
555
|
+
const avgCellLen = heuristicTexts.length ? (totalLen / heuristicTexts.length) : 0;
|
|
404
556
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function cellRawText(cell) {
|
|
415
|
-
if (!cell) return '';
|
|
416
|
-
if (Array.isArray(cell.tokens)) return flattenTokenText(cell.tokens);
|
|
417
|
-
return String(cell.text || '');
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
function classifyColumns(token) {
|
|
421
|
-
const colCount = (token.header || []).length;
|
|
422
|
-
const classes = [];
|
|
423
|
-
for (let i = 0; i < colCount; i++) {
|
|
424
|
-
const allCells = [];
|
|
425
|
-
if (token.header && token.header[i]) allCells.push(token.header[i]);
|
|
426
|
-
const dataCells = [];
|
|
427
|
-
if (Array.isArray(token.rows)) {
|
|
428
|
-
for (const row of token.rows) {
|
|
429
|
-
if (row && row[i]) {
|
|
430
|
-
allCells.push(row[i]);
|
|
431
|
-
dataCells.push(row[i]);
|
|
432
|
-
}
|
|
557
|
+
// Narrow takes precedence over prose (tie-break: prefer narrow / conservative).
|
|
558
|
+
if (maxTokenLen <= 12 && !hasWhitespace) {
|
|
559
|
+
classes.push('col-narrow');
|
|
560
|
+
} else if (avgCellLen > 40 || hasSentence) {
|
|
561
|
+
classes.push('col-prose');
|
|
562
|
+
} else {
|
|
563
|
+
classes.push('col-default');
|
|
433
564
|
}
|
|
434
565
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
.
|
|
473
|
-
.
|
|
474
|
-
.
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
function escapeHtml(value) {
|
|
483
|
-
return String(value || '')
|
|
484
|
-
.replace(/&/g, '&')
|
|
485
|
-
.replace(/</g, '<')
|
|
486
|
-
.replace(/>/g, '>')
|
|
487
|
-
.replace(/"/g, '"')
|
|
488
|
-
.replace(/'/g, ''');
|
|
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, '&')
|
|
590
|
+
.replace(/</g, '<')
|
|
591
|
+
.replace(/>/g, '>')
|
|
592
|
+
.replace(/"/g, '"')
|
|
593
|
+
.replace(/'/g, ''');
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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 <IP> 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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 <IP> 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
657
|
+
return `<pre><code class="language-${lang}">${escaped}</code></pre>\n`;
|
|
658
|
+
};
|
|
554
659
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
666
|
+
tocItems.push({
|
|
667
|
+
depth,
|
|
668
|
+
id: headingId,
|
|
669
|
+
text: headingText,
|
|
670
|
+
});
|
|
671
|
+
startSection({ depth, id: headingId, text: headingText });
|
|
567
672
|
|
|
568
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
694
|
+
renderer.table = function(token) {
|
|
695
|
+
collectCellText(token.header);
|
|
696
|
+
if (Array.isArray(token.rows)) {
|
|
697
|
+
for (const row of token.rows) {
|
|
698
|
+
collectCellText(row);
|
|
699
|
+
}
|
|
594
700
|
}
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
const classes = classifyColumns(token);
|
|
598
|
-
const colHtml = classes
|
|
599
|
-
.map((c) => (c === 'col-default' ? '<col>' : `<col class="${c}">`))
|
|
600
|
-
.join('');
|
|
601
|
-
const cellClassAttr = (i) => {
|
|
602
|
-
const c = classes[i];
|
|
603
|
-
if (c === 'col-narrow') return ' class="cell-narrow"';
|
|
604
|
-
if (c === 'col-prose') return ' class="cell-prose"';
|
|
605
|
-
return '';
|
|
606
|
-
};
|
|
607
|
-
const alignStyle = (cell) => (cell && cell.align)
|
|
608
|
-
? ` style="text-align:${cell.align}"`
|
|
609
|
-
: '';
|
|
610
701
|
|
|
611
|
-
|
|
612
|
-
const
|
|
613
|
-
|
|
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
|
-
|
|
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 `<
|
|
720
|
+
return `<th${cellClassAttr(i)}${alignStyle(cell)}>${inner}</th>`;
|
|
625
721
|
}).join('');
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
.
|
|
683
|
-
.
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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, '&')
|
|
752
|
+
.replace(/</g, '<')
|
|
753
|
+
.replace(/>/g, '>')
|
|
754
|
+
.replace(/"/g, '"');
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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:
|
|
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_"]
|
|
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
|
|
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
|
|
1534
|
-
|
|
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
|
|
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 (
|
|
1652
|
-
|
|
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) =>
|
|
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;
|
|
@@ -3068,6 +3568,12 @@ ${mermaidInitTag}` : ''}
|
|
|
3068
3568
|
</body>
|
|
3069
3569
|
</html>`;
|
|
3070
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
|
+
|
|
3071
3577
|
// Render every deferred dot/graphviz placeholder to inline SVG using the
|
|
3072
3578
|
// in-process WASM engine. Loads the WASM module only when at least one dot
|
|
3073
3579
|
// block exists, so text-only docs pay nothing.
|
|
@@ -3108,7 +3614,7 @@ function launchBrowser(puppeteer) {
|
|
|
3108
3614
|
|
|
3109
3615
|
// Pre-render mermaid/wavedrom to inert SVG using headless Chromium, then strip
|
|
3110
3616
|
// the diagram-engine runtime scripts so the output HTML carries no diagram JS.
|
|
3111
|
-
async function bakeDiagrams(htmlStr) {
|
|
3617
|
+
async function bakeDiagrams(htmlStr, dstPath) {
|
|
3112
3618
|
if (!/data-md2doc-diagram-engine/.test(htmlStr)) return htmlStr;
|
|
3113
3619
|
let puppeteer;
|
|
3114
3620
|
try {
|
|
@@ -3117,7 +3623,7 @@ async function bakeDiagrams(htmlStr) {
|
|
|
3117
3623
|
console.error('[ERROR] --bake-svg requires puppeteer/Chromium — install it, or drop --bake-svg:', e.message);
|
|
3118
3624
|
process.exit(1);
|
|
3119
3625
|
}
|
|
3120
|
-
const tmp =
|
|
3626
|
+
const tmp = dstPath.replace(/\.html$/i, '._bake.html');
|
|
3121
3627
|
fs.writeFileSync(tmp, htmlStr, 'utf8');
|
|
3122
3628
|
let browser;
|
|
3123
3629
|
try {
|
|
@@ -3141,59 +3647,72 @@ async function bakeDiagrams(htmlStr) {
|
|
|
3141
3647
|
}
|
|
3142
3648
|
}
|
|
3143
3649
|
|
|
3144
|
-
// ──
|
|
3145
|
-
(
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
if (
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
} else if (ext === '.pdf') {
|
|
3154
|
-
if (BAKE_SVG) console.log('[INFO] --bake-svg is redundant for PDF output (already static); ignoring');
|
|
3155
|
-
let puppeteer;
|
|
3156
|
-
try {
|
|
3157
|
-
puppeteer = require('puppeteer');
|
|
3158
|
-
} catch (e) {
|
|
3159
|
-
console.error('[ERROR] puppeteer not found — install with: npm install puppeteer');
|
|
3160
|
-
process.exit(1);
|
|
3161
|
-
}
|
|
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
|
+
}
|
|
3162
3658
|
|
|
3163
|
-
|
|
3164
|
-
// Case-insensitive: an uppercase .PDF dst must not make tmp === dst, or the
|
|
3165
|
-
// unlinkSync below deletes the freshly written PDF.
|
|
3166
|
-
const tmp = dst.replace(/\.pdf$/i, '._tmp.html');
|
|
3167
|
-
fs.writeFileSync(tmp, finalHtml, 'utf8');
|
|
3659
|
+
const ext = path.extname(dst).toLowerCase();
|
|
3168
3660
|
|
|
3169
|
-
|
|
3170
|
-
const
|
|
3661
|
+
(async () => {
|
|
3662
|
+
const mdText = fs.readFileSync(src, 'utf8');
|
|
3663
|
+
const { html } = await renderMarkdown(mdText, path.resolve(src), {});
|
|
3664
|
+
let finalHtml = html;
|
|
3171
3665
|
|
|
3172
|
-
|
|
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}`);
|
|
3173
3670
|
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
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
|
+
}
|
|
3178
3680
|
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
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
|
+
});
|
|
3187
3705
|
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3706
|
+
await browser.close();
|
|
3707
|
+
fs.unlinkSync(tmp);
|
|
3708
|
+
console.log(`[PDF] ${src} → ${dst}`);
|
|
3191
3709
|
|
|
3192
|
-
|
|
3193
|
-
|
|
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);
|
|
3194
3716
|
process.exit(1);
|
|
3195
|
-
}
|
|
3196
|
-
}
|
|
3197
|
-
console.error('[ERROR]', (e && e.stack) || e);
|
|
3198
|
-
process.exit(1);
|
|
3199
|
-
});
|
|
3717
|
+
});
|
|
3718
|
+
}
|