@helping-ai-workflow/md2doc 3.0.2 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,12 +23,50 @@ const INDENT_CLAMP_SRC = fs.readFileSync(path.join(__dirname, 'indent-clamp.js')
23
23
  // indent-clamp.js above — it only has to land before client.js reads
24
24
  // window.md2docConvertMd.
25
25
  const CONVERT_MD_SRC = fs.readFileSync(path.join(__dirname, 'convert-md.js'), 'utf8');
26
+ // v3.2.0: the pure string-diff module behind incremental DOM patching —
27
+ // stripBlockId()/patchmap() compare oldParts[i] against newParts[j] with only
28
+ // data-block-id normalized (see the global constraint on that). Same "no
29
+ // dependency on any other editor module" property as indent-clamp.js and
30
+ // convert-md.js above; it only has to land before client.js reads
31
+ // window.md2docPatchmap.
32
+ const PATCHMAP_SRC = fs.readFileSync(path.join(__dirname, 'patchmap.js'), 'utf8');
26
33
  // S3 spec §3.3/§3.6/§4.4: the pure block multi-select model — line-range
27
34
  // normalization, membership, Shift+arrow stepping, the §3.3 grip rule and the
28
35
  // post-operation collapse. Same "no dependency on any other editor module"
29
36
  // property as indent-clamp.js and convert-md.js above; it only has to land
30
37
  // before client.js reads window.md2docSelection.
31
38
  const SELECTION_SRC = fs.readFileSync(path.join(__dirname, 'selection.js'), 'utf8');
39
+ // v3.1.0 Task E: the four Phase-1 modules behind the visible toolbar, the
40
+ // paste pipeline, the image-drop asset endpoint and the whole-document
41
+ // source escape hatch. Same "pure module, no dependency on any other editor
42
+ // module" property as indent-clamp.js / convert-md.js / selection.js above —
43
+ // each only has to land before client.js reads its window global.
44
+ const TOOLBAR_MODEL_SRC = fs.readFileSync(path.join(__dirname, 'toolbar-model.js'), 'utf8');
45
+ const DOCSOURCE_SRC = fs.readFileSync(path.join(__dirname, 'docsource.js'), 'utf8');
46
+ const ASSET_SRC = fs.readFileSync(path.join(__dirname, 'asset.js'), 'utf8');
47
+ // turndown's BROWSER build. node's `require('turndown')` resolves to
48
+ // lib/turndown.cjs.js, which is not loadable as a plain <script> tag — the
49
+ // UMD build is, and it defines the global `TurndownService` that
50
+ // paste-md.js's getTurndownService() reads at CALL time (not factory time,
51
+ // so this tag's position relative to paste-md.js's is not load-bearing;
52
+ // it is injected first anyway, to keep the dependency reading left-to-right).
53
+ const TURNDOWN_SRC = fs.readFileSync(
54
+ require.resolve('turndown/lib/turndown.browser.umd.js'), 'utf8');
55
+ const PASTE_MD_SRC = fs.readFileSync(path.join(__dirname, 'paste-md.js'), 'utf8');
56
+
57
+ // The SAME pure module the page gets, required here for the node side of
58
+ // /api/asset. asset.js is deliberately free of `require('fs')`/`require('path')`
59
+ // (it is plain string arithmetic), so every filesystem decision below is made
60
+ // here — including the '/'-normalization isWithin's contract demands.
61
+ const assetLib = require('./asset.js');
62
+
63
+ // asset.js's isWithin() is POSIX-only BY CONTRACT (see its own comment): it
64
+ // does not understand '\\' as a separator and fails CLOSED on a native-Windows
65
+ // path, refusing every legitimate write. Normalizing at this boundary is the
66
+ // caller's job, and this is that boundary.
67
+ function toPosixPath(p) {
68
+ return String(p === null || p === undefined ? '' : p).split('\\').join('/');
69
+ }
32
70
 
33
71
  function readJson(req, limitBytes = 50 * 1024 * 1024) {
34
72
  return new Promise((resolve, reject) => {
@@ -51,6 +89,37 @@ function readJson(req, limitBytes = 50 * 1024 * 1024) {
51
89
  });
52
90
  }
53
91
 
92
+ // DNS-rebinding defense. The content-type gate below stops a cross-site
93
+ // "simple" POST, but it cannot stop a request the browser believes is
94
+ // SAME-origin: a page on evil.com served with a 1-second-TTL A record can
95
+ // rebind that name to 127.0.0.1 and then POST to http://evil.com:PORT/... with
96
+ // a real application/json body. No preflight fires, the content-type check is
97
+ // satisfied, and the attacker is inside.
98
+ //
99
+ // What made that reachable is that a route needs no unguessable precondition.
100
+ // /api/save happens to have one — `baseMtimeMs` must equal the file's actual
101
+ // mtimeMs, a float an attacker cannot guess — but /api/asset needs only a
102
+ // `fileId` inside [0, absFiles.length), which is guessable in one try, and it
103
+ // WRITES FILES. So the check belongs at the front door rather than on the one
104
+ // route that happened to lack a second lock: the Host header is attacker-
105
+ // controlled in name only, since a rebinding attack must send the name it
106
+ // rebound, and that name is never a loopback literal.
107
+ //
108
+ // A missing Host is rejected too (HTTP/1.0 clients): this server is only ever
109
+ // addressed by lib/editor/open.js and a browser, both of which send one.
110
+ function hostIsLoopback(hostHeader, port) {
111
+ if (typeof hostHeader !== 'string' || hostHeader === '') return false;
112
+ const h = hostHeader.toLowerCase();
113
+ const names = ['127.0.0.1', 'localhost', '[::1]'];
114
+ for (const n of names) {
115
+ if (h === n + ':' + port) return true;
116
+ // A bare Host (no ':port') means the scheme's default port. Only accept it
117
+ // when that is genuinely the port we are listening on.
118
+ if (port === 80 && h === n) return true;
119
+ }
120
+ return false;
121
+ }
122
+
54
123
  function send(res, status, obj) {
55
124
  const body = JSON.stringify(obj);
56
125
  res.writeHead(status, { 'content-type': 'application/json' });
@@ -91,6 +160,16 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
91
160
  const server = http.createServer(async (req, res) => {
92
161
  try {
93
162
  const url = new URL(req.url, 'http://127.0.0.1');
163
+
164
+ // FIRST, ahead of every route including GET /edit/:id — see
165
+ // hostIsLoopback() above for the attack this closes. Placed here rather
166
+ // than on /api/asset alone: that route is merely where it was reachable,
167
+ // not where the hole is.
168
+ const addr = server.address();
169
+ if (!hostIsLoopback(req.headers.host, addr && addr.port)) {
170
+ return send(res, 403, { error: 'forbidden host' });
171
+ }
172
+
94
173
  const editMatch = url.pathname.match(/^\/edit\/(\d+)$/);
95
174
 
96
175
  // Cross-origin defense for every state-changing POST route: a
@@ -101,7 +180,13 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
101
180
  // browser would otherwise let the request fire-and-forget cross-origin.
102
181
  // /api/ping is included; lib/editor/client.js's fetch calls already
103
182
  // send this header for render, save, and ping.
104
- const STATE_CHANGING_POST_PATHS = new Set(['/api/render', '/api/save', '/api/ping']);
183
+ // v3.1.0: '/api/asset' is in this set for the same reason the other
184
+ // three are, and more urgently — it is the only route that CREATES a
185
+ // file. A route left out of this set has NO cross-origin defense at
186
+ // all: any page the user happens to be browsing while `md2doc --edit`
187
+ // is running could fire a cross-site "simple" POST at every candidate
188
+ // localhost port and write to disk.
189
+ const STATE_CHANGING_POST_PATHS = new Set(['/api/render', '/api/save', '/api/ping', '/api/asset']);
105
190
  if (req.method === 'POST' && STATE_CHANGING_POST_PATHS.has(url.pathname)) {
106
191
  const contentType = String(req.headers['content-type'] || '');
107
192
  if (!/^application\/json\b/i.test(contentType)) {
@@ -165,7 +250,13 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
165
250
  `<script>${HISTORY_SRC}</script>\n` +
166
251
  `<script>${INDENT_CLAMP_SRC}</script>\n` +
167
252
  `<script>${CONVERT_MD_SRC}</script>\n` +
253
+ `<script>${PATCHMAP_SRC}</script>\n` +
168
254
  `<script>${SELECTION_SRC}</script>\n` +
255
+ `<script>${TOOLBAR_MODEL_SRC}</script>\n` +
256
+ `<script>${DOCSOURCE_SRC}</script>\n` +
257
+ `<script>${ASSET_SRC}</script>\n` +
258
+ `<script>${TURNDOWN_SRC}</script>\n` +
259
+ `<script>${PASTE_MD_SRC}</script>\n` +
169
260
  `<script>${clientJs}</script>\n`;
170
261
  // Splice at the LAST "</body>" — the document's real closing tag.
171
262
  // The first occurrence can sit inside an inlined diagram bundle's JS
@@ -192,9 +283,9 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
192
283
  const { fileId, content } = await readJson(req);
193
284
  const file = absFiles[fileId];
194
285
  if (!file) return send(res, 404, { error: 'unknown file' });
195
- const { bodyHtml, blocks } = await renderMarkdown(content, file, { editMode: true });
286
+ const { parts, blocks } = await renderMarkdown(content, file, { editMode: true });
196
287
  bumpIdle(server);
197
- return send(res, 200, { bodyHtml, blocks });
288
+ return send(res, 200, { parts, blocks });
198
289
  }
199
290
 
200
291
  if (req.method === 'POST' && url.pathname === '/api/save') {
@@ -218,6 +309,107 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
218
309
  return send(res, 200, { mtimeMs: fs.statSync(file).mtimeMs });
219
310
  }
220
311
 
312
+ // v3.1.0 追加 3: the image-drop / image-paste asset endpoint. Writes ONE
313
+ // raster image next to the document under `assets/` and answers with the
314
+ // relative markdown path the client then inserts as source.
315
+ //
316
+ // Six guards, none of them optional:
317
+ // 0. '/api/asset' is in STATE_CHANGING_POST_PATHS above (the CSRF
318
+ // content-type gate). Without it this is a world-writable file
319
+ // drop for any page in the user's browser.
320
+ // 1. fileId is type-checked. `/api/save`'s `!file` guard alone lets
321
+ // `fileId: "length"` through (absFiles.length is a number, so the
322
+ // lookup succeeds); Number.isInteger + a range check does not.
323
+ // 2. An explicit 8 MB readJson limit, not the 50 MB default.
324
+ // 3. The extension comes ONLY from the MIME whitelist (extFor); a
325
+ // caller-supplied 'x.html' can never become the name on disk.
326
+ // 4. assets/ is created here — nothing else in this server creates
327
+ // directories, so the first drop would otherwise be a guaranteed
328
+ // ENOENT 500.
329
+ // 5. The string-level isWithin() check is followed by a realpath
330
+ // re-check (a literal comparison cannot see a symlinked assets/,
331
+ // and writeFileSync FOLLOWS symlinks), and the file itself is
332
+ // opened 'wx' — which refuses to follow an existing symlink AND
333
+ // closes uniqueName()'s check-then-write TOCTOU window.
334
+ if (req.method === 'POST' && url.pathname === '/api/asset') {
335
+ const body = await readJson(req, 8 * 1024 * 1024);
336
+ const fileId = body ? body.fileId : undefined;
337
+ if (!Number.isInteger(fileId) || fileId < 0 || fileId >= absFiles.length) {
338
+ return send(res, 400, { error: 'fileId must be an index into the open file list' });
339
+ }
340
+ const file = absFiles[fileId];
341
+ const ext = assetLib.extFor(body.mime);
342
+ if (!ext) return send(res, 400, { error: 'unsupported image type' });
343
+ const b64 = typeof body.data === 'string' ? body.data : '';
344
+ const buf = Buffer.from(b64, 'base64');
345
+ if (buf.length === 0) return send(res, 400, { error: 'empty image payload' });
346
+
347
+ const baseDir = path.dirname(file);
348
+ const assetsDir = path.join(baseDir, 'assets');
349
+ fs.mkdirSync(assetsDir, { recursive: true });
350
+ let existing;
351
+ try { existing = fs.readdirSync(assetsDir); } catch (e) { existing = []; }
352
+
353
+ // Ruling 10: the candidate is composed as a FULL filename before
354
+ // uniqueName sees it. `existing` is a raw readdir listing whose
355
+ // entries carry extensions, so a bare stem could never match one —
356
+ // a second same-named image would sail past collision detection and
357
+ // then hit the 'wx' flag as a hard EEXIST instead of becoming -2.
358
+ //
359
+ // The comparison is CASE-FOLDED, because the write below is not.
360
+ // macOS and Windows are both supported targets (it is why
361
+ // sanitizeName defends '\\' and ':'), and their filesystems are
362
+ // case-insensitive: `existing.indexOf('Shot.png')` misses an on-disk
363
+ // `shot.png`, so no '-2' is generated, open(..., 'wx') returns EEXIST
364
+ // and the user gets a generic 「圖片上傳失敗」 that repeats identically
365
+ // on every retry. asset.js is reviewed and unchanged — the numbering
366
+ // is still entirely its own; only the SPACE the question is asked in
367
+ // moves. The user's original stem casing is then restored, so a
368
+ // dropped `Shot.png` is still written as `Shot.png`.
369
+ const stem = assetLib.sanitizeName(body.name);
370
+ const desiredLower = (stem + ext).toLowerCase();
371
+ const pickedLower = assetLib.uniqueName(
372
+ desiredLower, existing.map((e) => String(e).toLowerCase()));
373
+ let name;
374
+ if (pickedLower === desiredLower) {
375
+ name = stem + ext;
376
+ } else {
377
+ // uniqueName only ever inserts '-<n>' immediately before the
378
+ // extension, so that suffix is what is lifted back across. Matched
379
+ // rather than sliced by length: toLowerCase() is not guaranteed to
380
+ // preserve length for every code point.
381
+ const m = /(-\d+)$/.exec(pickedLower.slice(0, pickedLower.length - ext.length));
382
+ name = stem + (m ? m[1] : '') + ext;
383
+ }
384
+ const target = path.join(assetsDir, name);
385
+ if (!assetLib.isWithin(toPosixPath(assetsDir), toPosixPath(target))) {
386
+ return send(res, 400, { error: 'refused: asset path escapes the assets directory' });
387
+ }
388
+ // Second boundary, against the filesystem rather than the string: a
389
+ // pre-existing `assets` SYMLINK pointing anywhere at all passes the
390
+ // check above unchanged, because that check only ever saw text.
391
+ let realBase, realAssets;
392
+ try {
393
+ realBase = fs.realpathSync(baseDir);
394
+ realAssets = fs.realpathSync(assetsDir);
395
+ } catch (e) {
396
+ return send(res, 500, { error: 'cannot resolve the assets directory' });
397
+ }
398
+ if (!assetLib.isWithin(toPosixPath(realBase), toPosixPath(realAssets))) {
399
+ return send(res, 400, { error: 'refused: assets directory escapes the document directory' });
400
+ }
401
+ try {
402
+ fs.writeFileSync(path.join(realAssets, name), buf, { flag: 'wx' });
403
+ } catch (e) {
404
+ if (e && e.code === 'EEXIST') {
405
+ return send(res, 409, { error: 'asset already exists: ' + name });
406
+ }
407
+ throw e;
408
+ }
409
+ bumpIdle(server);
410
+ return send(res, 200, { name, path: assetLib.relPath(name) });
411
+ }
412
+
221
413
  if (req.method === 'POST' && url.pathname === '/api/ping') {
222
414
  bumpIdle(server);
223
415
  res.writeHead(204);
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+ /* UMD, same shape as convert-md.js / lineops.js: `require`-able in node for
3
+ the unit tests, and injected into the editor page as
4
+ `window.md2docToolbarModel` (lib/editor/server.js). client.js is inlined
5
+ into the page as a plain <script>, not bundled, so a bare require(...) at
6
+ the factory's top level would be an undefined identifier in the browser.
7
+ This module is pure button-model arithmetic: no `document`, `window`, or
8
+ `navigator` reference anywhere below. */
9
+ (function (root, factory) {
10
+ if (typeof module === 'object' && module.exports) module.exports = factory();
11
+ else root.md2docToolbarModel = factory();
12
+ })(typeof self !== 'undefined' ? self : this, function () {
13
+
14
+ // The 22-button roster, spec §4 table (`export▾` moved out of scope — no
15
+ // browser-reachable export path). Six groups, in render order; group order
16
+ // here doubles as separator placement for the caller (a separator sits
17
+ // between consecutive groups).
18
+ const BUTTON_DEFS = [
19
+ // history
20
+ { id: 'undo', group: 'history', label: 'Undo', icon: '↶', title: '復原 (Ctrl+Z)', toggle: false },
21
+ { id: 'redo', group: 'history', label: 'Redo', icon: '↷', title: '重做 (Ctrl+Y)', toggle: false },
22
+
23
+ // block
24
+ { id: 'headings', group: 'block', label: 'H', icon: 'H', title: '標題', toggle: true },
25
+ { id: 'quote', group: 'block', label: '❝', icon: '❝', title: '引用', toggle: true },
26
+ { id: 'code', group: 'block', label: '</>', icon: '</>', title: '程式碼區塊', toggle: true },
27
+ { id: 'list', group: 'block', label: '•', icon: '•', title: '項目符號列表', toggle: true },
28
+ { id: 'ordered-list', group: 'block', label: '1.', icon: '1.', title: '編號列表', toggle: true },
29
+ { id: 'check', group: 'block', label: '☑', icon: '☑', title: '待辦清單', toggle: false },
30
+
31
+ // inline
32
+ { id: 'bold', group: 'inline', label: 'B', icon: 'B', title: '粗體', toggle: false },
33
+ { id: 'italic', group: 'inline', label: 'I', icon: 'I', title: '斜體', toggle: false },
34
+ { id: 'strike', group: 'inline', label: 'S', icon: 'S', title: '刪除線', toggle: false },
35
+ { id: 'inline-code', group: 'inline', label: '`', icon: '`', title: '行內程式碼', toggle: false },
36
+ { id: 'link', group: 'inline', label: '🔗', icon: '🔗', title: '連結', toggle: false },
37
+
38
+ // indent
39
+ { id: 'outdent', group: 'indent', label: '⇤', icon: '⇤', title: '減少縮排', toggle: false },
40
+ { id: 'indent', group: 'indent', label: '⇥', icon: '⇥', title: '增加縮排', toggle: false },
41
+
42
+ // insert
43
+ { id: 'table', group: 'insert', label: '⊞', icon: '⊞', title: '插入表格', toggle: false },
44
+ { id: 'insert-before', group: 'insert', label: '⬆', icon: '⬆', title: '在上方插入區塊', toggle: false },
45
+ { id: 'insert-after', group: 'insert', label: '⬇', icon: '⬇', title: '在下方插入區塊', toggle: false },
46
+ { id: 'line', group: 'insert', label: '―', icon: '―', title: '插入分隔線', toggle: false },
47
+ { id: 'image', group: 'insert', label: '🖼', icon: '🖼', title: '插入圖片', toggle: false },
48
+
49
+ // view
50
+ { id: 'outline', group: 'view', label: '☰', icon: '☰', title: '大綱', toggle: false },
51
+ // v3.2.1: 這顆是 edit <-> source 的兩態切換(preview 模式已移除)。id 保留
52
+ // 為 'preview' 是刻意的 —— 每一支選擇器、journey 案例與 runtime 測試都指名
53
+ // 它,改名只換得一個更好聽的字串。label / title 不是烘死值:deriveState()
54
+ // 每次都依當前 mode 覆寫成【下一個】狀態的名字,這裡的值只是 edit 模式的
55
+ // 起始值。toggle: false —— 兩態下 aria-pressed 講不出真話(按鈕講的是去處,
56
+ // 不是目前所在),真話由會變的 label / title 承擔。
57
+ { id: 'preview', group: 'view', label: 'M↓', icon: 'M↓', title: '切換到原始碼模式', toggle: false },
58
+ ];
59
+
60
+ const BUTTONS = Object.freeze(BUTTON_DEFS.map((b) => Object.freeze(Object.assign({}, b))));
61
+
62
+ const GROUPS = Object.freeze(['history', 'block', 'inline', 'indent', 'insert', 'view']);
63
+
64
+ // Buttons that stay enabled even when there is no block at all (the zeroed
65
+ // state right after rerenderAll, before any block gets focus/selection).
66
+ const NO_BLOCK_ALLOWED = new Set(['undo', 'redo', 'outline', 'preview']);
67
+
68
+ // ctx: {blockType, indent, headingDepth, inList, listOrdered, hasSelection, mode}
69
+ // -> { [id]: {active: bool, disabled: bool, label?: string} }
70
+ function deriveState(ctx) {
71
+ const c = ctx || {};
72
+ const blockType = c.blockType;
73
+ const indent = c.indent;
74
+ const headingDepth = c.headingDepth;
75
+ const inList = !!c.inList;
76
+ const listOrdered = !!c.listOrdered;
77
+ const mode = c.mode;
78
+ const hasBlock = blockType !== null && blockType !== undefined;
79
+
80
+ const state = {};
81
+ for (const b of BUTTONS) {
82
+ state[b.id] = { active: false, disabled: false };
83
+ }
84
+
85
+ // v3.2.1: the mode button names the NEXT state, never the current one.
86
+ // Under the old tri-state cycle it was permanently labelled 「預覽」 and
87
+ // reported aria-pressed=true in BOTH non-edit modes, so pressing it twice
88
+ // landed in source with no grips and an interface that said nothing about
89
+ // where you were — the reported "stuck" defect. It carries no `active`
90
+ // now: with two states the honest signal is the label, and the current
91
+ // mode is spelled out in .ed-toolbar-status by the client.
92
+ // It stays enabled in source mode via the mode==='source' override below
93
+ // (do not gate this on inList/hasBlock — it is the escape hatch OUT of
94
+ // those states).
95
+ state.preview.label = mode === 'source' ? '✎' : 'M↓';
96
+ state.preview.title = mode === 'source' ? '切換到編輯模式' : '切換到原始碼模式';
97
+
98
+ // headings: dropdown whose label tracks the current heading depth.
99
+ state.headings.label = blockType === 'heading' ? 'H' + headingDepth : 'H';
100
+ state.headings.active = blockType === 'heading';
101
+
102
+ // other block-type-driven active flags
103
+ state.quote.active = blockType === 'blockquote';
104
+ state.code.active = blockType === 'code';
105
+
106
+ // list vs ordered-list are mutually exclusive by construction: exactly one
107
+ // reflects the current list's ordered-ness, the other stays inactive.
108
+ if (inList && listOrdered) {
109
+ state['ordered-list'].active = true;
110
+ state.list.active = false;
111
+ } else if (inList && !listOrdered) {
112
+ state.list.active = true;
113
+ state['ordered-list'].active = false;
114
+ }
115
+
116
+ // outdent/indent guard on list membership and current depth.
117
+ if (!inList) {
118
+ state.outdent.disabled = true;
119
+ state.indent.disabled = true;
120
+ }
121
+ if (indent === 0) {
122
+ state.outdent.disabled = true;
123
+ }
124
+
125
+ // check is a `block`-group conversion target, same as quote/code/list/
126
+ // ordered-list: it turns the CURRENT block into a task-list item, so it is
127
+ // not gated on already being in a list — a bare paragraph is exactly the
128
+ // primary case (spec review ruling: gating it on inList made it dead for
129
+ // that case).
130
+
131
+ // No block at all (rerenderAll's zeroed state): everything disabled except
132
+ // the handful of buttons that don't depend on having a focused block.
133
+ if (!hasBlock) {
134
+ for (const b of BUTTONS) {
135
+ if (!NO_BLOCK_ALLOWED.has(b.id)) state[b.id].disabled = true;
136
+ }
137
+ }
138
+
139
+ // v3.2.0: the five inline-format buttons are dead unless there is a
140
+ // non-collapsed selection INSIDE the burst's own edit surface.
141
+ // applyMarkToggle() (client.js, `function applyMarkToggle`) and
142
+ // applyLinkToggle() (just below it) both return immediately when the root
143
+ // is null, when there is no range, when the range is collapsed, or when
144
+ // either of its containers lies outside that root — `link` included (it
145
+ // does not even open the prompt).
146
+ //
147
+ // `hasSelection` is that whole four-part refusal test, computed once on the
148
+ // client as hasFormattableSelection() and handed in here — so "no button is
149
+ // enabled that the action would silently refuse" holds for these five. It
150
+ // is NOT a claim about the model as a whole: every other enablement rule
151
+ // below is derived from the block record, and a runtime action can still
152
+ // refuse for reasons this ctx does not carry (a structural list gate, a
153
+ // block that owns no source line).
154
+ if (!c.hasSelection) {
155
+ for (const id of ['bold', 'italic', 'strike', 'inline-code', 'link']) {
156
+ state[id].disabled = true;
157
+ }
158
+ }
159
+
160
+ // mode 'source' is the full-document raw-source escape hatch: everything
161
+ // is disabled except the mode button that got you there (id 'preview',
162
+ // which in v3.2.1 is the edit <-> source toggle). This overrides every
163
+ // disabled flag computed above, and is the ONLY way back out — do not
164
+ // narrow it.
165
+ if (mode === 'source') {
166
+ for (const b of BUTTONS) {
167
+ state[b.id].disabled = b.id !== 'preview';
168
+ }
169
+ }
170
+
171
+ return state;
172
+ }
173
+
174
+ return { BUTTONS, GROUPS, deriveState };
175
+ });
package/lib/md2doc.js CHANGED
@@ -313,7 +313,7 @@ function renderEditModeList(listToken, blocks, biRef, out) {
313
313
  item.tokens.filter((tk) => tk.type === 'list')
314
314
  .forEach((ct) => renderEditModeList(ct, blocks, biRef, parts));
315
315
  });
316
- return parts.join('\n');
316
+ return parts;
317
317
  }
318
318
 
319
319
  async function renderMarkdown(mdText, srcPath, opts = {}) {
@@ -487,6 +487,10 @@ async function renderMarkdown(mdText, srcPath, opts = {}) {
487
487
  }
488
488
 
489
489
  let bodyHtml;
490
+ // v3.2.0: the per-block edit-mode parts (1:1 with `blocks`), already baked.
491
+ // Stays null outside editMode — the reader/PDF path has no per-block DOM to
492
+ // patch, only the whole-document `bodyHtml`.
493
+ let parts = null;
490
494
  let tocHtml = '';
491
495
  let serializedSections = '[]';
492
496
  // NOTE: intentionally no try/catch here. A throw from marked/katex parsing
@@ -893,7 +897,7 @@ ${itemsHtml}
893
897
  // exactly what the whole-document parse would emit for that token,
894
898
  // using the same renderer/options set above.
895
899
  const tokens = marked.lexer(mdPre);
896
- const parts = [];
900
+ parts = [];
897
901
  // `v` is the block cursor; `prevLi`/`types` are the §3.8 run state the
898
902
  // flat list renderer above threads through its own recursion (see
899
903
  // liRunStartsHere()).
@@ -901,7 +905,7 @@ ${itemsHtml}
901
905
  for (const t of tokens) {
902
906
  if (t.type === 'space') continue;
903
907
  if (t.type === 'list') {
904
- parts.push(renderEditModeList(t, blocks, biRef));
908
+ parts.push(...renderEditModeList(t, blocks, biRef));
905
909
  continue;
906
910
  }
907
911
  resetLiRunState(biRef); // §3.8 rule (c): a non-li block ends every run
@@ -920,6 +924,14 @@ ${itemsHtml}
920
924
  throw new Error('edit-mode render consumed ' + biRef.v +
921
925
  ' blocks but the block map has ' + blocks.length);
922
926
  }
927
+ // v3.2.0: bake EACH part, then join — not join-then-bake. The patch path
928
+ // inserts parts[i] straight into the DOM and the client has no graphviz
929
+ // renderer (__md2docInitDiagrams handles only mermaid and WaveDrom), so
930
+ // an unbaked part lands as an empty <div class="graphviz">. bakeGraphviz
931
+ // short-circuits on parts with no data-graphviz-src, and each placeholder
932
+ // falls entirely inside one part, so this is byte-identical to baking the
933
+ // joined string.
934
+ parts = await Promise.all(parts.map(bakeGraphviz));
923
935
  bodyHtml = parts.join('\n');
924
936
  } else {
925
937
  bodyHtml = marked.parse(mdPre);
@@ -1328,6 +1340,48 @@ ${itemsHtml}
1328
1340
  content: ""; position: absolute;
1329
1341
  left: calc(-1 * (var(--ed-gutter-w) + var(--ed-gutter-shift))); top: 0;
1330
1342
  width: calc(var(--ed-gutter-w) + var(--ed-gutter-shift)); height: 100%;
1343
+ }
1344
+ /* v3.1.0 §4: room for the fixed toolbar (.ed-toolbar, further up this
1345
+ stylesheet). The bar is position:fixed — NOT sticky — because
1346
+ lib/editor/client.js mounts it on document.body: rerenderAll() replaces
1347
+ .content's innerHTML wholesale, so a toolbar living inside the content
1348
+ column would be destroyed by the first commit with nothing to rebuild
1349
+ it. Fixed chrome takes no layout space, so the page has to make its
1350
+ own: this is the only place that offset is written. The mobile rule at
1351
+ max-width:1080px above already reserves 60px for .sidebar-toggle; the
1352
+ bar sits above it, so the two offsets ADD there.
1353
+ .reader-sidebar is sticky at top: 24px in the base stylesheet — pushed
1354
+ down by the same amount here so the outline does not slide under the
1355
+ bar, with its height reduced to match so it still ends at the viewport
1356
+ bottom. */
1357
+ .page-layout { padding-top: var(--ed-toolbar-h); }
1358
+ .reader-sidebar {
1359
+ top: calc(24px + var(--ed-toolbar-h));
1360
+ height: calc(100vh - 48px - var(--ed-toolbar-h));
1361
+ }
1362
+ @media (max-width: 1080px) {
1363
+ .page-layout { padding-top: calc(60px + var(--ed-toolbar-h)); }
1364
+ .reader-sidebar { top: 0; height: 100vh; padding-top: calc(16px + var(--ed-toolbar-h)); }
1365
+ }
1366
+ /* The 大綱 button's DESKTOP half, on an attribute of its OWN.
1367
+ ⚠ It deliberately does NOT reuse body[data-sidebar-open], and the reason
1368
+ was measured rather than reasoned: the rule
1369
+ 'body[data-sidebar-open] .sidebar-scrim { display: block; }' sits at BASE scope in this
1370
+ stylesheet, not inside the max-width:1080px drawer block. Giving that
1371
+ same attribute a second, desktop meaning therefore also paints
1372
+ .sidebar-scrim (position:fixed; inset:0; z-index:98; rgba(0,0,0,.35))
1373
+ across the whole viewport — the desktop page went grey and
1374
+ click-through-dead, and the scrim's own click handler (which calls
1375
+ setSidebarOpen(false)) meant the next click anywhere silently undid it.
1376
+ A private attribute, written by toggleOutlineSidebar() in
1377
+ lib/editor/client.js, leaves data-sidebar-open to the mobile drawer
1378
+ alone and lets #sidebar-toggle's aria-expanded stay truthful.
1379
+ The attribute names the non-default state ("hidden") because on desktop
1380
+ the outline is a sticky column that is SHOWN by default; the mobile
1381
+ drawer keeps its own "open" attribute for the same reason in reverse. */
1382
+ @media (min-width: 1081px) {
1383
+ body[data-ed-outline-hidden] .reader-sidebar,
1384
+ body[data-ed-outline-hidden] .sidebar-splitter { display: none; }
1331
1385
  }`;
1332
1386
 
1333
1387
  const html = `<!DOCTYPE html>
@@ -2480,6 +2534,110 @@ ${itemsHtml}
2480
2534
  .ed-seltb-i { font-style: italic; }
2481
2535
  .ed-seltb-s { text-decoration: line-through; }
2482
2536
  .ed-seltb-u { text-decoration: underline; }
2537
+ /* v3.1.0 §4: the always-visible editing toolbar. Its numeric language is
2538
+ .lightbox-bar's (8px/12px padding, 6px gap, 13px type) over .ed-seltb's
2539
+ dark pill, so the two floating surfaces read as one family.
2540
+ position:fixed, NOT sticky: lib/editor/client.js mounts this on
2541
+ document.body because rerenderAll() replaces .content's innerHTML
2542
+ wholesale and would silently destroy a toolbar mounted inside it.
2543
+ Deliberately does NOT read any --ed-gutter-* token — those describe the
2544
+ ⠿/+ pair's geometry and this is a new, unrelated scale (the height is
2545
+ its own token because the edit-mode layout chunk at the end of this
2546
+ stylesheet has to reserve exactly that much room for it).
2547
+ z-index sits above the mobile drawer (99) and .sidebar-toggle (100) but
2548
+ below .ed-conflict (999): a save conflict must always be reachable. */
2549
+ :root { --ed-toolbar-h: 44px; }
2550
+ .ed-toolbar {
2551
+ position: fixed; top: 0; left: 0; right: 0; z-index: 101;
2552
+ box-sizing: border-box; height: var(--ed-toolbar-h);
2553
+ display: flex; align-items: center; justify-content: center;
2554
+ flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden;
2555
+ gap: 6px; padding: 8px 12px;
2556
+ background: rgba(16, 18, 21, 0.92); color: #e6edf3;
2557
+ font-size: 13px;
2558
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
2559
+ }
2560
+ .ed-toolbar-sep {
2561
+ flex: 0 0 auto; width: 1px; height: 18px; margin: 0 2px;
2562
+ background: rgba(255, 255, 255, 0.25);
2563
+ }
2564
+ .ed-toolbar-btn {
2565
+ flex: 0 0 auto;
2566
+ min-width: 28px; height: 26px; padding: 0 8px;
2567
+ border: 1px solid rgba(255, 255, 255, 0.25); border-radius: 6px;
2568
+ background: rgba(255, 255, 255, 0.08); color: inherit;
2569
+ font: inherit; font-size: 13px; line-height: 1;
2570
+ white-space: nowrap; cursor: pointer;
2571
+ }
2572
+ .ed-toolbar-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.18); }
2573
+ .ed-toolbar-btn:disabled { opacity: 0.38; cursor: default; }
2574
+ .ed-toolbar-btn[aria-pressed="true"] {
2575
+ background: #e6edf3; color: #101215; border-color: #e6edf3;
2576
+ }
2577
+ .ed-toolbar-b { font-weight: bold; }
2578
+ .ed-toolbar-i { font-style: italic; }
2579
+ .ed-toolbar-s { text-decoration: line-through; }
2580
+ /* H1 fix: the brief's literal margin-left: auto (a flex-flow item) was
2581
+ measured to break .ed-toolbar's justify-content: center — an auto
2582
+ margin on any flex item absorbs ALL positive free space before
2583
+ justify-content sees any, so the ~22-button group snapped flush left on
2584
+ every viewport wide enough not to overflow. position: fixed removes
2585
+ the slot from the flex layout entirely (buttons measured byte-identical
2586
+ to a build with the slot absent, at both a 1400px and a 420px viewport),
2587
+ and — unlike position: absolute against .ed-toolbar's own
2588
+ position: fixed box — it anchors to the browser viewport, not to
2589
+ .ed-toolbar's scrolled content, so it does not drift as the button
2590
+ row's overflow-x: auto scrolls (measured identical rect at scrollLeft
2591
+ 0 / mid / max, where the absolute alternative moved by the exact scroll
2592
+ delta). References no gutter-alignment custom property.
2593
+ H2 fix: pointer-events: none below is LOAD-BEARING, not decorative.
2594
+ v3.2.1 filled this slot with the current mode's name ('編輯' / '原始碼'),
2595
+ so it no longer ships empty — but that content is inert TEXT, never a
2596
+ control, which is exactly what keeps the rule below sufficient. At narrow viewports .ed-toolbar's own
2597
+ overflow-x: auto lets the button row scroll to a rest position where
2598
+ this fixed 7rem box's footprint physically covers three real buttons
2599
+ (image / outline / preview, measured at 420px width, scrollLeft at its
2600
+ max 221) — document.elementFromPoint() over any of them resolves to
2601
+ this span, not the button underneath, and a real click on it never
2602
+ reaches the button's listener. Harmless today only because the span
2603
+ carries nothing to interact with. WHOEVER PUTS A CONTROL HERE (or
2604
+ wires up two-way sync, §3) MUST remove or scope this
2605
+ rule AND independently solve the overlap (e.g. reflow the slot in-flow
2606
+ at narrow widths, or shrink/hide it below some width) — simply
2607
+ deleting this line without doing that reopens the swallowed-click
2608
+ regression, this time on content someone can actually see and try to
2609
+ click. */
2610
+ .ed-toolbar-status { position: fixed; top: 0; right: 12px;
2611
+ height: var(--ed-toolbar-h); line-height: var(--ed-toolbar-h);
2612
+ min-width: 7rem; text-align: right; font-size: 12px; opacity: .8;
2613
+ pointer-events: none; }
2614
+ /* The H▾ dropdown: same singleton-panel idiom as .ed-insert-menu, but
2615
+ anchored to a fixed bar rather than moved into a block, so it is
2616
+ positioned in viewport coordinates by the client runtime. */
2617
+ .ed-toolbar-menu {
2618
+ position: fixed; z-index: 102;
2619
+ display: flex; flex-direction: column; gap: 2px;
2620
+ padding: 4px; border-radius: 8px;
2621
+ background: rgba(16, 18, 21, 0.96); color: #e6edf3;
2622
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
2623
+ }
2624
+ .ed-toolbar-menu[hidden] { display: none; }
2625
+ .ed-toolbar-menu-btn {
2626
+ min-width: 120px; padding: 4px 10px; text-align: left;
2627
+ border: none; border-radius: 4px; background: transparent; color: inherit;
2628
+ font: inherit; font-size: 13px; line-height: 1.4; cursor: pointer;
2629
+ }
2630
+ .ed-toolbar-menu-btn:hover { background: rgba(255, 255, 255, 0.18); }
2631
+ /* 追加 4: the whole-document source escape hatch. Replaces the rendered
2632
+ content column for the duration of source mode; the document's own
2633
+ lines[] is untouched until the mode is left (see docsource.js). */
2634
+ .ed-source {
2635
+ display: block; width: 100%; box-sizing: border-box;
2636
+ min-height: 70vh; padding: 12px;
2637
+ font-family: monospace; font-size: 13px; line-height: 1.6;
2638
+ border: 1px solid #d0d7de; border-radius: 8px; resize: vertical;
2639
+ white-space: pre; overflow-wrap: normal;
2640
+ }
2483
2641
  .ed-editing { position: relative; }
2484
2642
  .ed-raw {
2485
2643
  display: block; width: 100%; font-family: monospace; font-size: 13px;
@@ -4132,8 +4290,11 @@ ${mermaidInitTag}`)
4132
4290
  </html>`;
4133
4291
 
4134
4292
  const bakedHtml = await bakeGraphviz(html);
4293
+ // editMode's bodyHtml is already baked per-part above; re-baking here is a
4294
+ // short-circuit no-op (bakeGraphviz bails out when there's no
4295
+ // data-graphviz-src left). Non-editMode bodyHtml is baked here as before.
4135
4296
  const bakedBody = await bakeGraphviz(bodyHtml);
4136
- return { html: bakedHtml, bodyHtml: bakedBody, blocks };
4297
+ return { html: bakedHtml, bodyHtml: bakedBody, parts, blocks };
4137
4298
  }
4138
4299
  module.exports = { renderMarkdown };
4139
4300