@helping-ai-workflow/md2doc 3.3.0 → 3.4.2
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 +33 -8
- package/THIRD-PARTY-NOTICES.md +44 -0
- package/lib/drawio.js +178 -0
- package/lib/editor/blockmap.js +79 -6
- package/lib/editor/client.js +3353 -157
- package/lib/editor/inline-md.js +122 -12
- package/lib/editor/lineops.js +190 -3
- package/lib/editor/paste-md.js +5 -1
- package/lib/editor/server.js +359 -5
- package/lib/editor/toolbar-model.js +51 -14
- package/lib/editor/wave-codec.js +2631 -0
- package/lib/editor/wave-geometry.js +320 -0
- package/lib/editor/wave-store.js +950 -0
- package/lib/editor/wave-ui.js +2124 -0
- package/lib/md2doc.js +677 -14
- package/package.json +14 -6
- package/vendor/drawio/LICENSE +201 -0
- package/vendor/drawio/NOTICE +109 -0
- package/vendor/drawio/README.md +25 -0
- package/vendor/drawio/viewer-static.min.js +8596 -0
package/lib/editor/server.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const http = require('http');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const drawioLib = require('../drawio.js');
|
|
5
7
|
const { renderMarkdown } = require('../md2doc.js');
|
|
6
8
|
|
|
7
9
|
const LINEOPS_SRC = fs.readFileSync(path.join(__dirname, 'lineops.js'), 'utf8');
|
|
@@ -54,6 +56,50 @@ const TURNDOWN_SRC = fs.readFileSync(
|
|
|
54
56
|
require.resolve('turndown/lib/turndown.browser.umd.js'), 'utf8');
|
|
55
57
|
const PASTE_MD_SRC = fs.readFileSync(path.join(__dirname, 'paste-md.js'), 'utf8');
|
|
56
58
|
|
|
59
|
+
// v3.4.0 batch3 Task 6: the wavedrom GUI's four modules.
|
|
60
|
+
//
|
|
61
|
+
// wave-codec.js / wave-geometry.js / wave-store.js are plain CommonJS ON
|
|
62
|
+
// PURPOSE, and rewriting them into the UMD header every other module here uses
|
|
63
|
+
// is not available: test/wave-codec.test.js bans the spelling `require(` in the
|
|
64
|
+
// codec outright, test/wave-geometry.test.js bans every host-object read a UMD
|
|
65
|
+
// header performs (`typeof self`, `root.x = …`), and test/wave-store.test.js
|
|
66
|
+
// pins the store's require list to exactly `["require('./wave-codec.js')"]`. So
|
|
67
|
+
// the browser gets them through the small CommonJS shim below instead —
|
|
68
|
+
// the module text itself is inlined byte for byte.
|
|
69
|
+
//
|
|
70
|
+
// wave-ui.js is UMD like its neighbours and needs no shim; in the page it
|
|
71
|
+
// defines `window.md2docWaveUi` and takes the other three as parameters.
|
|
72
|
+
const WAVE_CODEC_SRC = fs.readFileSync(path.join(__dirname, 'wave-codec.js'), 'utf8');
|
|
73
|
+
const WAVE_GEOMETRY_SRC = fs.readFileSync(path.join(__dirname, 'wave-geometry.js'), 'utf8');
|
|
74
|
+
const WAVE_STORE_SRC = fs.readFileSync(path.join(__dirname, 'wave-store.js'), 'utf8');
|
|
75
|
+
const WAVE_UI_SRC = fs.readFileSync(path.join(__dirname, 'wave-ui.js'), 'utf8');
|
|
76
|
+
|
|
77
|
+
// One CommonJS module, wrapped so a plain <script> can carry it.
|
|
78
|
+
//
|
|
79
|
+
// The `'use strict'` is the FIRST statement of the script, not of the wrapper
|
|
80
|
+
// function, so the whole tag — module body included — is strict. That is
|
|
81
|
+
// load-bearing rather than tidy: the store deep-freezes every document it
|
|
82
|
+
// keeps, and a frozen object only BITES in strict mode. test/wave-store.test.js
|
|
83
|
+
// T24 measured the sloppy-mode alternative — four of five writes to a frozen
|
|
84
|
+
// document were silent — so a wrapper that lost the prologue would put the
|
|
85
|
+
// original corruption back and make it quieter.
|
|
86
|
+
//
|
|
87
|
+
// `require` here resolves by basename against what has already been shimmed,
|
|
88
|
+
// which is enough for the only two edges that exist (both './wave-codec.js')
|
|
89
|
+
// and stays a total function for anything else: an unknown name answers
|
|
90
|
+
// undefined and the module that asked fails where it asked.
|
|
91
|
+
function waveModuleTag(name, src) {
|
|
92
|
+
return '<script>\'use strict\';\n' +
|
|
93
|
+
'window.__md2docWave = window.__md2docWave || {};\n' +
|
|
94
|
+
'(function () {\n' +
|
|
95
|
+
'var module = { exports: {} };\n' +
|
|
96
|
+
'var exports = module.exports;\n' +
|
|
97
|
+
'function require(p) { return window.__md2docWave[String(p).split(\'/\').pop()]; }\n' +
|
|
98
|
+
src + '\n' +
|
|
99
|
+
'window.__md2docWave[' + JSON.stringify(name) + '] = module.exports;\n' +
|
|
100
|
+
'})();</script>\n';
|
|
101
|
+
}
|
|
102
|
+
|
|
57
103
|
// The SAME pure module the page gets, required here for the node side of
|
|
58
104
|
// /api/asset. asset.js is deliberately free of `require('fs')`/`require('path')`
|
|
59
105
|
// (it is plain string arithmetic), so every filesystem decision below is made
|
|
@@ -194,6 +240,235 @@ async function createEditorServer({
|
|
|
194
240
|
let neverOpenedTimer = null;
|
|
195
241
|
let started = false;
|
|
196
242
|
|
|
243
|
+
// v3.4.0 batch2 Task 6 — Ruling B2-2: watch every `.drawio`/`.xml` file the
|
|
244
|
+
// CURRENTLY OPEN document references, and let /api/ping's already-firing
|
|
245
|
+
// 10s heartbeat (see its own comment below) double as the staleness check,
|
|
246
|
+
// rather than inventing a push transport server.js has never had (no
|
|
247
|
+
// WebSocket/SSE/fs.watch anywhere in this stack — see this task's
|
|
248
|
+
// investigation note).
|
|
249
|
+
//
|
|
250
|
+
// TWO maps, and the split is the whole point of fix round 1:
|
|
251
|
+
//
|
|
252
|
+
// drawioWatch fileId -> { refs }. WHAT THE LAST BAKE RESOLVED.
|
|
253
|
+
// The list of absolute paths that render actually resolved,
|
|
254
|
+
// i.e. the only paths worth stat'ing for this document.
|
|
255
|
+
// Fix round 2 (re-review G13): this used to carry a
|
|
256
|
+
// `stamps` Map as well, which nothing ever read — the new
|
|
257
|
+
// tab's baseline comes from trackDrawioRefs()'s RETURN
|
|
258
|
+
// value, handed straight to registerDrawioClient() in the
|
|
259
|
+
// same expression. Dead state in a freshly written
|
|
260
|
+
// mechanism is a maintenance trap, so it is gone; the stamp
|
|
261
|
+
// lib/md2doc.js takes before the bake (review F4) still
|
|
262
|
+
// travels, just not through here.
|
|
263
|
+
//
|
|
264
|
+
// drawioClients clientKey -> { fileId, stamps, pending, lastSeen }.
|
|
265
|
+
// WHAT ONE BROWSER TAB IS ACTUALLY SHOWING. Review F3: the
|
|
266
|
+
// baseline used to be per-file, so with two tabs open on one
|
|
267
|
+
// document the first to ping consumed the one signal and the
|
|
268
|
+
// second sat on a stale diagram looking perfectly healthy.
|
|
269
|
+
// A tab gets its id minted by GET /edit/:id — the same
|
|
270
|
+
// response that paints its DOM — so its baseline is exactly
|
|
271
|
+
// the bytes it is displaying, with no window in which
|
|
272
|
+
// another tab's render could seed it wrong.
|
|
273
|
+
//
|
|
274
|
+
// Review F2's invariant: a client's `stamps` advances ONLY when that client
|
|
275
|
+
// has told us its DOM now shows those bytes. Until then every heartbeat
|
|
276
|
+
// keeps reporting the same staleness, so every one of the client's bail
|
|
277
|
+
// paths (network error, mid-gesture DOM, focus inside the block, …) really
|
|
278
|
+
// does get retried on the next tick instead of losing the change forever.
|
|
279
|
+
const drawioWatch = new Map();
|
|
280
|
+
const drawioClients = new Map();
|
|
281
|
+
// The token that identifies a CONTENT VERSION, not a ping. Minted once per
|
|
282
|
+
// (client, observed stamp set) and repeated verbatim on every later ping
|
|
283
|
+
// that still sees the same bytes — if it were per-ping, a refresh that took
|
|
284
|
+
// longer than one 10s beat would always ack a token the server had already
|
|
285
|
+
// superseded, and the pair would never converge.
|
|
286
|
+
let drawioTokenSeq = 0;
|
|
287
|
+
let drawioLastSweep = 0;
|
|
288
|
+
// 10 minutes = 60 missed heartbeats. A tab that has not pinged in that long
|
|
289
|
+
// is closed (or its machine slept); keeping its entry would leak one small
|
|
290
|
+
// object per tab ever opened for the life of the server process.
|
|
291
|
+
const DRAWIO_CLIENT_IDLE_MS = 10 * 60 * 1000;
|
|
292
|
+
|
|
293
|
+
function drawioClientKey(fileId, clientId) {
|
|
294
|
+
if (typeof clientId !== 'string' || clientId === '') return null;
|
|
295
|
+
if (typeof fileId !== 'number' || !isFinite(fileId)) return null;
|
|
296
|
+
return String(fileId) + '\u0000' + clientId;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function sweepDrawioClients(now) {
|
|
300
|
+
if (now - drawioLastSweep < 60 * 1000) return;
|
|
301
|
+
drawioLastSweep = now;
|
|
302
|
+
for (const [k, c] of drawioClients) {
|
|
303
|
+
if (now - c.lastSeen > DRAWIO_CLIENT_IDLE_MS) drawioClients.delete(k);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Fix round 2 (re-review G5). A tab that is still talking to us is alive by
|
|
308
|
+
// definition, so a well-formed clientId with no entry gets one back rather
|
|
309
|
+
// than being answered 204 forever with no signal and no route back but a
|
|
310
|
+
// reload. Reachable the moment a laptop sleeps or a background tab's timers
|
|
311
|
+
// are frozen for longer than the sweep window — and before this, the tab's
|
|
312
|
+
// own returning heartbeat was what deleted it (the sweep ran BEFORE the
|
|
313
|
+
// lastSeen refresh), so the failure mode was silent, permanent, and caused
|
|
314
|
+
// by the recovery attempt itself.
|
|
315
|
+
//
|
|
316
|
+
// Seeded with an EMPTY stamp map on purpose, not with the last bake's: we
|
|
317
|
+
// do not know what that tab's DOM is showing, and an empty baseline makes
|
|
318
|
+
// the next check report stale, which costs one re-render the client then
|
|
319
|
+
// acks. Wrong in the safe direction (one redundant bake) instead of the
|
|
320
|
+
// unsafe one (a tab that silently never updates again).
|
|
321
|
+
//
|
|
322
|
+
// Fix round 3 (re-review2 H4): the bound is EVICT-OLDEST, applied at BOTH
|
|
323
|
+
// doors. Round 2 capped only the ping door and refused past the cap, which
|
|
324
|
+
// was wrong twice over: `GET /edit/:id` wrote into the same map with no
|
|
325
|
+
// check at all, and refusing meant that once 64 entries accumulated, a
|
|
326
|
+
// genuinely swept live tab could never re-register — G5's own guard rail
|
|
327
|
+
// reintroducing exactly the silent deafness G5 was raised about. Dropping
|
|
328
|
+
// the least-recently-seen entry instead keeps the map at <= MAX from both
|
|
329
|
+
// doors AND never turns a live tab away: the entry we drop is by
|
|
330
|
+
// construction the one that has gone quietest.
|
|
331
|
+
//
|
|
332
|
+
// WHAT THIS BOUNDS, EXACTLY (fix round 4, re-review3 M3 — the sentence this
|
|
333
|
+
// replaces claimed more than the code delivers): **the map, not the render
|
|
334
|
+
// load.** Round 2's refuse-at-cap did cap the bakes a fresh-id-per-ping
|
|
335
|
+
// caller could provoke — past 64 ids it answered a bare 204, so no token and
|
|
336
|
+
// no `/api/render` followed. Evict-oldest removes that ceiling on purpose:
|
|
337
|
+
// a caller rotating a new id every ping now gets `{stale, token}` every
|
|
338
|
+
// time and can drive `/api/render` — and therefore headless-Chromium bakes
|
|
339
|
+
// — without bound. That trade is deliberate, because the refusal it removes
|
|
340
|
+
// is exactly what made a swept live tab permanently deaf, and availability
|
|
341
|
+
// for the real tab is worth more here than a ceiling on a caller that is
|
|
342
|
+
// already inside the loopback-only, Host-checked front door (see the
|
|
343
|
+
// DNS-rebinding note above `hostIsLoopback`). Recorded as a known
|
|
344
|
+
// limitation rather than guarded: every guard added to this mechanism so
|
|
345
|
+
// far has produced the next round's finding.
|
|
346
|
+
const DRAWIO_CLIENT_MAX = 64;
|
|
347
|
+
function makeRoomForDrawioClient() {
|
|
348
|
+
while (drawioClients.size >= DRAWIO_CLIENT_MAX) {
|
|
349
|
+
let oldestKey = null, oldestSeen = Infinity;
|
|
350
|
+
for (const [k, c] of drawioClients) {
|
|
351
|
+
if (c.lastSeen < oldestSeen) { oldestSeen = c.lastSeen; oldestKey = k; }
|
|
352
|
+
}
|
|
353
|
+
if (oldestKey === null) return; // unreachable while size > 0
|
|
354
|
+
drawioClients.delete(oldestKey);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function ensureDrawioClient(fileId, clientId, now) {
|
|
358
|
+
const key = drawioClientKey(fileId, clientId);
|
|
359
|
+
if (key === null) return null;
|
|
360
|
+
let c = drawioClients.get(key);
|
|
361
|
+
if (c) { c.lastSeen = now; return c; }
|
|
362
|
+
if (!drawioWatch.has(fileId)) return null;
|
|
363
|
+
makeRoomForDrawioClient();
|
|
364
|
+
c = { fileId, stamps: new Map(), pending: null, lastSeen: now };
|
|
365
|
+
drawioClients.set(key, c);
|
|
366
|
+
return c;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Called after every render that actually re-reads the source markdown
|
|
370
|
+
// (GET /edit/:id and POST /api/render both call renderMarkdown() fresh).
|
|
371
|
+
// Records WHAT WAS BAKED; deliberately does NOT touch any client's
|
|
372
|
+
// baseline — that is review F2's invariant, and the trap F2 names is
|
|
373
|
+
// exactly this function: /api/render runs BEFORE the client has decided
|
|
374
|
+
// whether it can apply the result, so advancing anything here would lose
|
|
375
|
+
// every post-response bail.
|
|
376
|
+
function trackDrawioRefs(fileId, refs) {
|
|
377
|
+
const list = Array.isArray(refs) ? refs : [];
|
|
378
|
+
const paths = [];
|
|
379
|
+
const stamps = new Map();
|
|
380
|
+
for (const r of list) {
|
|
381
|
+
const abs = r && typeof r.path === 'string' ? r.path : null;
|
|
382
|
+
if (!abs || stamps.has(abs)) continue;
|
|
383
|
+
paths.push(abs);
|
|
384
|
+
stamps.set(abs, r.stamp === undefined ? null : r.stamp);
|
|
385
|
+
}
|
|
386
|
+
drawioWatch.set(fileId, { refs: paths });
|
|
387
|
+
return stamps;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function registerDrawioClient(fileId, stamps) {
|
|
391
|
+
const clientId = crypto.randomBytes(12).toString('hex');
|
|
392
|
+
const key = drawioClientKey(fileId, clientId);
|
|
393
|
+
if (key === null) return clientId; // unreachable today; never key on null
|
|
394
|
+
const now = Date.now();
|
|
395
|
+
makeRoomForDrawioClient(); // H4: this door is bounded too
|
|
396
|
+
drawioClients.set(key, {
|
|
397
|
+
fileId, stamps: new Map(stamps), pending: null, lastSeen: now,
|
|
398
|
+
});
|
|
399
|
+
sweepDrawioClients(now); // G5: after the write, never before
|
|
400
|
+
return clientId;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function sameStamps(a, b) {
|
|
404
|
+
if (a.size !== b.size) return false;
|
|
405
|
+
for (const [k, v] of a) { if (!b.has(k) || b.get(k) !== v) return false; }
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// (a) requirement: a document with no drawio references must cost this
|
|
410
|
+
// heartbeat NOTHING beyond the Map lookups below — zero fs.statSync calls,
|
|
411
|
+
// same as the bare 204 this replaces. Only a document that actually has
|
|
412
|
+
// `refs.length > 0` pays for the stat loop that follows.
|
|
413
|
+
//
|
|
414
|
+
// Returns the token to hand the client, or null for "nothing stale".
|
|
415
|
+
function checkDrawioStale(fileId, clientId) {
|
|
416
|
+
const state = drawioWatch.get(fileId);
|
|
417
|
+
if (!state || state.refs.length === 0) return null;
|
|
418
|
+
const key = drawioClientKey(fileId, clientId);
|
|
419
|
+
if (key === null) return null;
|
|
420
|
+
const c = drawioClients.get(key);
|
|
421
|
+
// An unknown client here is one touchDrawioClient() declined to
|
|
422
|
+
// (re-)register: a page from before this feature (no clientId at all), a
|
|
423
|
+
// malformed id, or a fileId this server never rendered. It gets the old
|
|
424
|
+
// bare 204 rather than a signal it has no baseline to interpret. Note
|
|
425
|
+
// that hitting DRAWIO_CLIENT_MAX is NOT one of those cases any more —
|
|
426
|
+
// see makeRoomForDrawioClient() (re-review2 H4).
|
|
427
|
+
if (!c) return null;
|
|
428
|
+
const now = new Map();
|
|
429
|
+
let changed = false;
|
|
430
|
+
for (const p of state.refs) {
|
|
431
|
+
const st = drawioLib.drawioStampOf(p);
|
|
432
|
+
now.set(p, st);
|
|
433
|
+
if (c.stamps.get(p) !== st) changed = true;
|
|
434
|
+
}
|
|
435
|
+
if (!changed) { c.pending = null; return null; }
|
|
436
|
+
if (c.pending && sameStamps(c.pending.stamps, now)) return c.pending.id;
|
|
437
|
+
drawioTokenSeq += 1;
|
|
438
|
+
c.pending = { id: 'd' + drawioTokenSeq, stamps: now };
|
|
439
|
+
return c.pending.id;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Keeps a live tab out of the idle sweep above (re-registering it if the
|
|
443
|
+
// sweep already got there — G5), and is the one place the sweep is actually
|
|
444
|
+
// driven from: the heartbeat is the only thing that fires often enough to
|
|
445
|
+
// be worth hanging it on, and it is throttled to at most once a minute
|
|
446
|
+
// inside sweepDrawioClients().
|
|
447
|
+
//
|
|
448
|
+
// ORDER IS LOAD-BEARING: ensure/touch first, sweep second. The other way
|
|
449
|
+
// round, a tab returning from a >10-minute freeze is deleted by the very
|
|
450
|
+
// ping it sent to say it was back.
|
|
451
|
+
function touchDrawioClient(fileId, clientId) {
|
|
452
|
+
const now = Date.now();
|
|
453
|
+
ensureDrawioClient(fileId, clientId, now);
|
|
454
|
+
sweepDrawioClients(now);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// The client's half of F2's invariant: it sends back the token it was given
|
|
458
|
+
// ONLY after its DOM really carries that render. Anything less than a
|
|
459
|
+
// complete apply (a block skipped because the user's focus was inside it,
|
|
460
|
+
// a mid-gesture DOM, a failed fetch) sends nothing, so `stamps` stays put
|
|
461
|
+
// and the very next heartbeat reports the same staleness again.
|
|
462
|
+
function ackDrawioApplied(fileId, clientId, token) {
|
|
463
|
+
if (typeof token !== 'string' || token === '') return;
|
|
464
|
+
const key = drawioClientKey(fileId, clientId);
|
|
465
|
+
if (key === null) return;
|
|
466
|
+
const c = drawioClients.get(key);
|
|
467
|
+
if (!c || !c.pending || c.pending.id !== token) return;
|
|
468
|
+
c.stamps = c.pending.stamps;
|
|
469
|
+
c.pending = null;
|
|
470
|
+
}
|
|
471
|
+
|
|
197
472
|
function bumpIdle(server) {
|
|
198
473
|
if (!started) return;
|
|
199
474
|
if (idleTimer) clearTimeout(idleTimer);
|
|
@@ -281,11 +556,15 @@ async function createEditorServer({
|
|
|
281
556
|
const eol = (crlfCount > bareLf && crlfCount > bareCr) ? '\r\n'
|
|
282
557
|
: (bareCr > bareLf && bareCr > crlfCount) ? '\r'
|
|
283
558
|
: '\n';
|
|
284
|
-
const { html, blocks } = await renderMarkdown(mdText, file, { editMode: true });
|
|
559
|
+
const { html, blocks, drawioRefs } = await renderMarkdown(mdText, file, { editMode: true });
|
|
560
|
+
// Fix round 1 (review F3): mint this TAB's own staleness baseline from
|
|
561
|
+
// the very render that is about to paint it. Per-tab, not per-file —
|
|
562
|
+
// two tabs on one document must each get told about an external edit.
|
|
563
|
+
const drawioClientId = registerDrawioClient(fileId, trackDrawioRefs(fileId, drawioRefs));
|
|
285
564
|
const lines = mdText.split(/\r\n|\r|\n/);
|
|
286
565
|
assertBlockRangesFit(blocks, lines);
|
|
287
566
|
const payload = JSON.stringify({
|
|
288
|
-
fileId, mtimeMs, eol, lines, blocks,
|
|
567
|
+
fileId, mtimeMs, eol, lines, blocks, drawioClientId,
|
|
289
568
|
});
|
|
290
569
|
const inject =
|
|
291
570
|
`<script>window.__ED__ = ${payload.replace(/</g, '\\u003c')}</script>\n` +
|
|
@@ -303,6 +582,10 @@ async function createEditorServer({
|
|
|
303
582
|
`<script>${ASSET_SRC}</script>\n` +
|
|
304
583
|
`<script>${TURNDOWN_SRC}</script>\n` +
|
|
305
584
|
`<script>${PASTE_MD_SRC}</script>\n` +
|
|
585
|
+
waveModuleTag('wave-codec.js', WAVE_CODEC_SRC) +
|
|
586
|
+
waveModuleTag('wave-geometry.js', WAVE_GEOMETRY_SRC) +
|
|
587
|
+
waveModuleTag('wave-store.js', WAVE_STORE_SRC) +
|
|
588
|
+
`<script>${WAVE_UI_SRC}</script>\n` +
|
|
306
589
|
`<script>${clientJs}</script>\n`;
|
|
307
590
|
// Splice at the LAST "</body>" — the document's real closing tag.
|
|
308
591
|
// The first occurrence can sit inside an inlined diagram bundle's JS
|
|
@@ -329,7 +612,10 @@ async function createEditorServer({
|
|
|
329
612
|
const { fileId, content } = await readJson(req);
|
|
330
613
|
const file = absFiles[fileId];
|
|
331
614
|
if (!file) return send(res, 404, { error: 'unknown file' });
|
|
332
|
-
const { parts, blocks } = await renderMarkdown(content, file, { editMode: true });
|
|
615
|
+
const { parts, blocks, drawioRefs } = await renderMarkdown(content, file, { editMode: true });
|
|
616
|
+
// Refs only — NOT any client's baseline. See trackDrawioRefs()'s own
|
|
617
|
+
// comment: the client has not yet decided whether it can apply this.
|
|
618
|
+
trackDrawioRefs(fileId, drawioRefs);
|
|
333
619
|
bumpIdle(server);
|
|
334
620
|
return send(res, 200, { parts, blocks });
|
|
335
621
|
}
|
|
@@ -384,12 +670,46 @@ async function createEditorServer({
|
|
|
384
670
|
return send(res, 400, { error: 'fileId must be an index into the open file list' });
|
|
385
671
|
}
|
|
386
672
|
const file = absFiles[fileId];
|
|
387
|
-
const ext = assetLib.extFor(body.mime);
|
|
388
|
-
if (!ext) return send(res, 400, { error: 'unsupported image type' });
|
|
389
673
|
const b64 = typeof body.data === 'string' ? body.data : '';
|
|
390
674
|
const buf = Buffer.from(b64, 'base64');
|
|
391
675
|
if (buf.length === 0) return send(res, 400, { error: 'empty image payload' });
|
|
392
676
|
|
|
677
|
+
// v3.4.1: two gates, not one widened whitelist.
|
|
678
|
+
//
|
|
679
|
+
// The raster gate is `extFor(body.mime)` and it is unchanged --
|
|
680
|
+
// `image/svg+xml` stays out for the reason asset.js documents (an SVG
|
|
681
|
+
// can carry <script>, and a MIME string is attacker-controlled).
|
|
682
|
+
//
|
|
683
|
+
// The drawio gate below exists because that first gate CANNOT answer
|
|
684
|
+
// for .drawio at all: browsers report `file.type === ''` for it, so
|
|
685
|
+
// v3.4.0 shipped a renderer that embeds `.drawio`/`.xml` and an insert
|
|
686
|
+
// path that answered every such upload with 'unsupported image type'.
|
|
687
|
+
//
|
|
688
|
+
// Its rule is the half that makes it safe: the EXTENSION picks the
|
|
689
|
+
// candidate and the CONTENT decides whether anything is written. The
|
|
690
|
+
// extension is matched against two literals and the stored value is
|
|
691
|
+
// taken from this table rather than from the name, so no casing,
|
|
692
|
+
// Unicode lookalike or double-extension in an attacker-supplied
|
|
693
|
+
// filename can become the extension on disk. A payload renamed to
|
|
694
|
+
// .xml is refused unless drawio's own parser recognises a root it
|
|
695
|
+
// writes (<mxfile> or <mxGraphModel>).
|
|
696
|
+
const DRAWIO_EXT = { '.drawio': '.drawio', '.xml': '.xml' };
|
|
697
|
+
let ext = assetLib.extFor(body.mime);
|
|
698
|
+
if (!ext) {
|
|
699
|
+
const rawName = String(body.name === null || body.name === undefined ? '' : body.name);
|
|
700
|
+
const m = /\.(drawio|xml)$/i.exec(rawName);
|
|
701
|
+
const candidate = m ? DRAWIO_EXT['.' + m[1].toLowerCase()] : null;
|
|
702
|
+
if (!candidate) return send(res, 400, { error: 'unsupported image type' });
|
|
703
|
+
// `buf` is what lands on disk, so `buf` is what gets checked --
|
|
704
|
+
// never a re-decode of `body.data`, which could differ.
|
|
705
|
+
if (!drawioLib.isDrawioXml(buf.toString('utf8'))) {
|
|
706
|
+
return send(res, 400, {
|
|
707
|
+
error: 'refused: this file is not a draw.io diagram (no <mxfile> or <mxGraphModel> root)',
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
ext = candidate;
|
|
711
|
+
}
|
|
712
|
+
|
|
393
713
|
const baseDir = path.dirname(file);
|
|
394
714
|
const assetsDir = path.join(baseDir, 'assets');
|
|
395
715
|
fs.mkdirSync(assetsDir, { recursive: true });
|
|
@@ -458,6 +778,40 @@ async function createEditorServer({
|
|
|
458
778
|
|
|
459
779
|
if (req.method === 'POST' && url.pathname === '/api/ping') {
|
|
460
780
|
bumpIdle(server);
|
|
781
|
+
// v3.4.0 batch2 Task 6: fileId / drawioClientId / drawioAck are NEW,
|
|
782
|
+
// all optional, on the body this heartbeat already sent every 10s
|
|
783
|
+
// (old clients / the existing test in test/editor-server.test.js send
|
|
784
|
+
// `{}` — that still resolves to "no tracked state", the same
|
|
785
|
+
// zero-cost 204 as before). A body that fails to parse degrades the
|
|
786
|
+
// same way: no staleness check, plain 204 — a malformed ping body
|
|
787
|
+
// must never be the thing that breaks the idle-keepalive contract.
|
|
788
|
+
//
|
|
789
|
+
// Fix round 1 (review F8): an explicit 8 KB readJson limit, not the
|
|
790
|
+
// 50 MB default — the same precedent /api/save set for its own body,
|
|
791
|
+
// for the same reason. This body is always three short scalars, and
|
|
792
|
+
// this is the ONE route that fires unconditionally every 10 seconds
|
|
793
|
+
// from every open tab; it has no business also being the most
|
|
794
|
+
// permissive one in the server.
|
|
795
|
+
let fileId, drawioClientId, drawioAck;
|
|
796
|
+
try {
|
|
797
|
+
const body = await readJson(req, 8 * 1024);
|
|
798
|
+
if (body) {
|
|
799
|
+
fileId = body.fileId;
|
|
800
|
+
drawioClientId = body.drawioClientId;
|
|
801
|
+
drawioAck = body.drawioAck;
|
|
802
|
+
}
|
|
803
|
+
} catch (e) {
|
|
804
|
+
fileId = undefined; drawioClientId = undefined; drawioAck = undefined;
|
|
805
|
+
}
|
|
806
|
+
// Ack FIRST, then re-check: a client that has just confirmed it
|
|
807
|
+
// applied version N must get a 204 on this same beat unless the file
|
|
808
|
+
// has moved on past N since.
|
|
809
|
+
touchDrawioClient(fileId, drawioClientId);
|
|
810
|
+
ackDrawioApplied(fileId, drawioClientId, drawioAck);
|
|
811
|
+
const staleToken = checkDrawioStale(fileId, drawioClientId);
|
|
812
|
+
if (staleToken) {
|
|
813
|
+
return send(res, 200, { stale: true, token: staleToken });
|
|
814
|
+
}
|
|
461
815
|
res.writeHead(204);
|
|
462
816
|
return res.end();
|
|
463
817
|
}
|
|
@@ -11,11 +11,14 @@
|
|
|
11
11
|
else root.md2docToolbarModel = factory();
|
|
12
12
|
})(typeof self !== 'undefined' ? self : this, function () {
|
|
13
13
|
|
|
14
|
-
// The
|
|
15
|
-
// browser-reachable export path)
|
|
16
|
-
//
|
|
17
|
-
// between consecutive groups).
|
|
14
|
+
// The 23-button roster, spec §4 table (`export▾` moved out of scope — no
|
|
15
|
+
// browser-reachable export path) plus the v3.4.0 §3 `save` button. Seven
|
|
16
|
+
// groups, in render order; group order here doubles as separator placement
|
|
17
|
+
// for the caller (a separator sits between consecutive groups).
|
|
18
18
|
const BUTTON_DEFS = [
|
|
19
|
+
// file
|
|
20
|
+
{ id: 'save', group: 'file', label: '💾', icon: '💾', title: '儲存 (Ctrl+S)', toggle: false },
|
|
21
|
+
|
|
19
22
|
// history
|
|
20
23
|
{ id: 'undo', group: 'history', label: 'Undo', icon: '↶', title: '復原 (Ctrl+Z)', toggle: false },
|
|
21
24
|
{ id: 'redo', group: 'history', label: 'Redo', icon: '↷', title: '重做 (Ctrl+Y)', toggle: false },
|
|
@@ -59,11 +62,22 @@ const BUTTON_DEFS = [
|
|
|
59
62
|
|
|
60
63
|
const BUTTONS = Object.freeze(BUTTON_DEFS.map((b) => Object.freeze(Object.assign({}, b))));
|
|
61
64
|
|
|
62
|
-
const GROUPS = Object.freeze(['history', 'block', 'inline', 'indent', 'insert', 'view']);
|
|
65
|
+
const GROUPS = Object.freeze(['file', 'history', 'block', 'inline', 'indent', 'insert', 'view']);
|
|
63
66
|
|
|
64
67
|
// Buttons that stay enabled even when there is no block at all (the zeroed
|
|
65
68
|
// state right after rerenderAll, before any block gets focus/selection).
|
|
66
|
-
|
|
69
|
+
// v3.4.0 §3: 'save' is here because unsaved changes have nothing to do with
|
|
70
|
+
// where the caret is — a document can be dirty with no block focused at all.
|
|
71
|
+
// NOTE (ruling P2): this entry does NOT currently change save's behaviour.
|
|
72
|
+
// `state.save.disabled = !dirty` below runs AFTER the no-block loop that
|
|
73
|
+
// reads this set, so it unconditionally overwrites whatever the no-block
|
|
74
|
+
// loop decided for 'save' — today the two never disagree in a way that
|
|
75
|
+
// shows through. This entry is kept anyway because it records the intent
|
|
76
|
+
// ("save must not be gateable on hasBlock") independently of where the
|
|
77
|
+
// `!dirty` line happens to sit; if that line is ever moved above the
|
|
78
|
+
// no-block loop, this set becomes the only thing still enforcing it. Do not
|
|
79
|
+
// delete it as "dead code" without re-checking that ordering.
|
|
80
|
+
const NO_BLOCK_ALLOWED = new Set(['undo', 'redo', 'outline', 'preview', 'save']);
|
|
67
81
|
|
|
68
82
|
// The mark element each inline button toggles. This module cannot ask the
|
|
69
83
|
// DOM which marks a selection sits in, so the client measures that and hands
|
|
@@ -103,6 +117,7 @@ function deriveState(ctx) {
|
|
|
103
117
|
const inList = !!c.inList;
|
|
104
118
|
const listOrdered = !!c.listOrdered;
|
|
105
119
|
const mode = c.mode;
|
|
120
|
+
const dirty = !!c.dirty;
|
|
106
121
|
const hasBlock = blockType !== null && blockType !== undefined;
|
|
107
122
|
|
|
108
123
|
const state = {};
|
|
@@ -211,21 +226,43 @@ function deriveState(ctx) {
|
|
|
211
226
|
}
|
|
212
227
|
}
|
|
213
228
|
|
|
229
|
+
// v3.4.0 §3: the save button IS the save-state indicator — grey when the
|
|
230
|
+
// document matches disk, lit when it does not. The truth stays
|
|
231
|
+
// single-sourced: client.js's `documentIsDirty()` — since v3.4.0 batch3
|
|
232
|
+
// Task 7 that is `stack.isDirty() || burstHasUncommittedEdit() ||
|
|
233
|
+
// waveUnwrittenEdit` — arrives as ctx.dirty and is not recomputed here.
|
|
234
|
+
// This runs before the source-mode override below so that override can
|
|
235
|
+
// still give save its own exemption.
|
|
236
|
+
state.save.disabled = !dirty;
|
|
237
|
+
|
|
214
238
|
// mode 'source' is the full-document raw-source escape hatch: everything
|
|
215
239
|
// is disabled except the mode button that got you there (id 'preview',
|
|
216
|
-
// which in v3.2.1 is the edit <-> source toggle)
|
|
217
|
-
//
|
|
218
|
-
// that has not changed. 'outline' stays reachable because
|
|
219
|
-
// opens the sidebar drawer (toggleOutlineSidebar() in
|
|
240
|
+
// which in v3.2.1 is the edit <-> source toggle), since T11-2 'outline'
|
|
241
|
+
// (☰), and since v3.4.0 §3 'save'. The mode button is still the ONLY way
|
|
242
|
+
// back to 'edit' — that has not changed. 'outline' stays reachable because
|
|
243
|
+
// it is what opens the sidebar drawer (toggleOutlineSidebar() in
|
|
220
244
|
// lib/editor/client.js), and the fixed `.sidebar-toggle` button that used
|
|
221
245
|
// to reach the drawer another way is hidden in edit mode entirely (see the
|
|
222
246
|
// T11-2 comment in lib/md2doc.js) — a disabled ☰ here would leave source
|
|
223
|
-
// mode with no way to open that drawer at all.
|
|
224
|
-
//
|
|
225
|
-
//
|
|
247
|
+
// mode with no way to open that drawer at all. 'save' stays reachable
|
|
248
|
+
// because source mode can still hold unsaved changes (the user can type
|
|
249
|
+
// into the source textarea) — see the per-button comment below for the
|
|
250
|
+
// exact shape of the exemption. This override still runs last and still
|
|
251
|
+
// overrides every disabled flag computed above for every other button; do
|
|
252
|
+
// not narrow it for anything but these three ids.
|
|
226
253
|
if (mode === 'source') {
|
|
227
254
|
for (const b of BUTTONS) {
|
|
228
|
-
|
|
255
|
+
if (b.id === 'preview' || b.id === 'outline') { state[b.id].disabled = false; continue; }
|
|
256
|
+
// v3.4.0 §3: save is a third exemption, for the same shape of reason
|
|
257
|
+
// outline already had one — source mode can still hold unsaved
|
|
258
|
+
// changes (the user can type into the source textarea), so forcing
|
|
259
|
+
// the only save button grey here would make a mode that holds
|
|
260
|
+
// unsaved work with no way out of it. The exemption is from the MODE
|
|
261
|
+
// forcing it grey, not from being grey — a clean document still shows
|
|
262
|
+
// it disabled in source mode (see the `state.save.disabled = !dirty`
|
|
263
|
+
// line above, which already ran and is left untouched here).
|
|
264
|
+
if (b.id === 'save') continue;
|
|
265
|
+
state[b.id].disabled = true;
|
|
229
266
|
}
|
|
230
267
|
}
|
|
231
268
|
|