@helping-ai-workflow/md2doc 3.17.2 → 3.17.4

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.
@@ -123,8 +123,8 @@ function newlineWithIndent(text, pos) {
123
123
  }
124
124
 
125
125
  // D3 Tab / Shift+Tab over a selection (R42). Touched lines are those that
126
- // intersect [selStart, selEnd]; when the selection is not collapsed and its
127
- // end sits at column 0 of a line, that line is not touched. Indent adds two
126
+ // intersect [selStart, selEnd]; when the selection is not collapsed, a line it
127
+ // only touches at an edge (it starts at the line's end, or ends at column 0) is not touched. Indent adds two
128
128
  // spaces; dedent removes up to two leading spaces. A position on a touched
129
129
  // line keeps its column relative to the line's text (indent: a column-0
130
130
  // position moves with the text, unlike VS Code; dedent: clamps at the line
@@ -136,9 +136,10 @@ function indentLines(text, selStart, selEnd, dedent) {
136
136
  for (const l of lines) { starts.push(off); off += l.length + 1; }
137
137
  const touched = lines.map((l, i) => {
138
138
  const s = starts[i], e = s + l.length;
139
- if (!(s <= selEnd && e >= selStart)) return false;
140
- if (selEnd > selStart && s === selEnd) return false;
141
- return true;
139
+ // v3.17.3 C3: a selection that starts exactly at a line's end, or ends at
140
+ // its start, does not touch that line; a collapsed caret touches its own.
141
+ if (selEnd === selStart) return s <= selStart && e >= selStart;
142
+ return s < selEnd && e > selStart;
142
143
  });
143
144
  const delta = lines.map((l, i) => {
144
145
  if (!touched[i]) return 0;
@@ -154,6 +154,13 @@ function listAttrsFor(target) {
154
154
  return null;
155
155
  }
156
156
 
157
+ // A run of hash characters at the very end of a heading's text, set off by a
158
+ // space (or the whole text), reads as the ATX closing sequence and is dropped
159
+ // on the next parse; its first character is written escaped to keep it text.
160
+ function escapeClosingHashes(md) {
161
+ return md.replace(/(^|[ \t])(#+)([ \t]*)$/, '$1\\$2$3');
162
+ }
163
+
157
164
  function emitAs(content, target, opts) {
158
165
  const o = opts || {};
159
166
  const prefix = o.indentPrefix || '';
@@ -170,7 +177,7 @@ function emitAs(content, target, opts) {
170
177
  const text = body.length > 1 ? body.map((l) => l.trim()).join(' ') : body.join(' ');
171
178
  // An EMPTY heading emits no trailing space, or converting '##' to a
172
179
  // heading of the same level would rewrite the line to '## '.
173
- return [text === '' ? prefix + hashes : prefix + hashes + ' ' + text];
180
+ return [text === '' ? prefix + hashes : prefix + hashes + ' ' + escapeClosingHashes(text)];
174
181
  }
175
182
 
176
183
  if (target === 'quote') return body.map((l) => prefix + '> ' + l);
@@ -203,5 +210,5 @@ function emitAs(content, target, opts) {
203
210
  return body.slice();
204
211
  }
205
212
 
206
- return { CONVERT_TARGETS, stripMarker, emitAs, targetIsList, listAttrsFor };
213
+ return { CONVERT_TARGETS, stripMarker, emitAs, targetIsList, listAttrsFor, escapeClosingHashes };
207
214
  });
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+ // A kept-alive worker thread for the Graphviz preview. Created on the first
3
+ // preview, reused for every later one. An engine error comes back as an
4
+ // ordinary Error (the server answers 422 with its first line); a worker that
5
+ // dies takes its in-flight requests down with an Error flagged `crashed`
6
+ // (the server answers 500), and the next request starts a fresh worker.
7
+ const path = require('path');
8
+ const { Worker } = require('worker_threads');
9
+
10
+ function createDotPreview() {
11
+ let worker = null;
12
+ let seq = 0;
13
+ const pending = new Map();
14
+
15
+ // Only the requests that belong to `w` die with it: a worker that was
16
+ // closed or replaced reports its exit late, after a new one took over.
17
+ function failAll(err, w) {
18
+ for (const [id, p] of pending) {
19
+ if (w !== undefined && p.worker !== w) continue;
20
+ p.reject(err);
21
+ pending.delete(id);
22
+ }
23
+ }
24
+ function start() {
25
+ const w = new Worker(path.join(__dirname, 'dot-worker.js'));
26
+ // An idle worker must not keep the process (or a test) alive.
27
+ w.unref();
28
+ w.on('message', (m) => {
29
+ const p = pending.get(m.id);
30
+ if (!p) return;
31
+ pending.delete(m.id);
32
+ if (m.error !== undefined) p.reject(new Error(m.error));
33
+ else p.resolve(m.svg);
34
+ });
35
+ const dead = (err) => {
36
+ if (worker === w) worker = null;
37
+ const e = new Error(String((err && err.message) || 'graphviz worker exited'));
38
+ e.crashed = true;
39
+ failAll(e, w);
40
+ };
41
+ w.on('error', dead);
42
+ w.on('exit', () => dead(null));
43
+ return w;
44
+ }
45
+ return {
46
+ render(source) {
47
+ if (worker === null) worker = start();
48
+ const id = ++seq;
49
+ return new Promise((resolve, reject) => {
50
+ pending.set(id, { resolve, reject, worker });
51
+ // A request in flight keeps the loop alive until it answers.
52
+ worker.ref();
53
+ worker.postMessage({ id, source });
54
+ }).finally(() => { if (pending.size === 0 && worker !== null) worker.unref(); });
55
+ },
56
+ close() {
57
+ const w = worker;
58
+ worker = null;
59
+ if (w !== null) w.terminate();
60
+ failAll(Object.assign(new Error('graphviz worker closed'), { crashed: true }));
61
+ },
62
+ };
63
+ }
64
+
65
+ module.exports = { createDotPreview };
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+ // Worker-thread entry for the stateless /api/preview: lays a dot source out
3
+ // with the same renderDotSvg the bake uses, so a large graph does not block
4
+ // the editor server's event loop (and with it /api/ping and every save).
5
+ const { parentPort } = require('worker_threads');
6
+ const { renderDotSvg } = require('../md2doc.js');
7
+
8
+ parentPort.on('message', async (m) => {
9
+ try {
10
+ parentPort.postMessage({ id: m.id, svg: await renderDotSvg(m.source) });
11
+ } catch (e) {
12
+ parentPort.postMessage({ id: m.id, error: String((e && e.message) || e) });
13
+ }
14
+ });
@@ -517,13 +517,48 @@
517
517
  return { s, altStart, altEnd, destStart, destEnd, angle, title };
518
518
  }
519
519
 
520
+ // v3.17.3 A3: marked keeps a backslash before anything but [ and ] in an
521
+ // image's alt (measured, marked 14: `a\`b` renders alt a\`b, `x\\y` renders
522
+ // x\\y), and an unbalanced backtick in the brackets turns the whole image
523
+ // into a code span. So only [ and ] are backslash-escaped; the backtick,
524
+ // the ampersand and the backslash are written as the character references
525
+ // the browser reads back as the same character. The reader side below
526
+ // undoes those, plus the backslash escapes older files hold.
527
+ // Final review M2 (R25 / R7), widened by v3.17.4 E1 (R10): every character
528
+ // reference the reader decodes — marked passes them into the alt attribute
529
+ // untouched and the browser decodes them — so the field shows what the
530
+ // reader shows. A decimal or hex number needs no table. A name goes through
531
+ // the installed decoder (the client installs one that asks the page itself,
532
+ // so every name the browser knows decodes); with none installed, the six
533
+ // names amp, lt, gt, quot, apos and nbsp. A name the decoder does not know
534
+ // stays as written in the field, and is written so that it renders as that
535
+ // text. An edited alt is written as the decoded character itself.
536
+ const ALT_ESCAPE = { '[': '\\[', ']': '\\]', '&': '&amp;', '`': '&#96;', '\\': '&#92;' };
537
+ const ALT_NAMED_CHAR = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0' };
538
+ const defaultNamedReference = (name) => ALT_NAMED_CHAR[name];
539
+ let namedReference = defaultNamedReference;
540
+ function setNamedReferenceDecoder(fn) { namedReference = typeof fn === 'function' ? fn : defaultNamedReference; }
541
+ const ALT_UNESCAPE = /\\([!-\/:-@\[-`{-~])|&(?:#([0-9]+)|#[xX]([0-9a-fA-F]+)|([A-Za-z][A-Za-z0-9]*));/g;
542
+ // A number the HTML parser would not give as itself (zero, a surrogate,
543
+ // past U+10FFFF) is U+FFFD there, so it is here too.
544
+ function altCodePoint(n) {
545
+ return n === 0 || n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff) ? '\ufffd' : String.fromCodePoint(n);
546
+ }
547
+ function altUnescape(m, c, dec, hex, name) {
548
+ if (c !== undefined) return c;
549
+ if (dec !== undefined) return altCodePoint(parseInt(dec, 10));
550
+ if (hex !== undefined) return altCodePoint(parseInt(hex, 16));
551
+ const r = namedReference(name);
552
+ return typeof r === 'string' ? r : m;
553
+ }
554
+
520
555
  function parseImageParagraph(text) {
521
556
  const f = scanImage(text);
522
557
  if (!f) return null;
523
558
  const rawAlt = f.s.slice(f.altStart, f.altEnd);
524
559
  const rawDest = f.s.slice(f.destStart, f.destEnd);
525
560
  return {
526
- alt: rawAlt.replace(/\\([!-\/:-@\[-`{-~])/g, '$1'),
561
+ alt: rawAlt.replace(ALT_UNESCAPE, altUnescape),
527
562
  dest: f.angle ? rawDest.slice(1, -1) : rawDest,
528
563
  title: f.title,
529
564
  angle: f.angle,
@@ -531,20 +566,20 @@
531
566
  }
532
567
 
533
568
  // Alt and/or destination replaced, everything else kept. `alt` is plain text
534
- // (the three characters that would end or bend the brackets gain a
535
- // backslash); `dest` is written as given, so the caller makes it
569
+ // (the characters that would end or bend the brackets are escaped, see
570
+ // ALT_ESCAPE); `dest` is written as given, so the caller makes it
536
571
  // markdown-ready — an angle-bracketed original loses its brackets.
537
572
  function rewriteImage(text, patch) {
538
573
  const f = scanImage(text);
539
574
  if (!f) return String(text == null ? '' : text);
540
575
  const p = patch || {};
541
576
  let out = f.s.slice(0, f.altStart);
542
- out += typeof p.alt === 'string' ? p.alt.replace(/[\\\[\]]/g, '\\$&') : f.s.slice(f.altStart, f.altEnd);
577
+ out += typeof p.alt === 'string' ? p.alt.replace(/[\\\[\]&`]/g, (c) => ALT_ESCAPE[c]) : f.s.slice(f.altStart, f.altEnd);
543
578
  out += f.s.slice(f.altEnd, f.destStart);
544
579
  out += typeof p.dest === 'string' ? p.dest : f.s.slice(f.destStart, f.destEnd);
545
580
  out += f.s.slice(f.destEnd);
546
581
  return out;
547
582
  }
548
583
 
549
- return { serializeInline, canWysiwyg, escapeText, escapeBlockStarts, normalizeHref, isPastedUrl, parseImageParagraph, rewriteImage };
584
+ return { serializeInline, canWysiwyg, escapeText, escapeBlockStarts, normalizeHref, isPastedUrl, parseImageParagraph, rewriteImage, setNamedReferenceDecoder };
550
585
  });
@@ -304,18 +304,9 @@
304
304
  // per block is what let the per-li degrade path address a different item's
305
305
  // line. See the continuation-column note in the emission below.
306
306
  //
307
- // RUN (spec §3.8): a run breaks at a shallower li, at a same-depth li whose
308
- // data-list-type differs, or at a non-li block. Deeper items never break
309
- // the run they are nested under. Every run's ordinal restarts at 1 — hence
310
- // `counters`, indexed by depth, with everything deeper than the current
311
- // depth cleared whenever the walk comes back up.
312
- //
313
- // Rule (b) compares against the last block seen AT THAT DEPTH (`types`),
314
- // NOT against the immediately previous block: with a deeper item sitting
315
- // between two same-depth blocks, comparing against `prev` makes the
316
- // list-type change invisible and the ordinal never restarts. The md then
317
- // re-lexes as <ol start="3">, §3.8 discards `start`, and the next commit
318
- // renumbers to '1.' — the document oscillates between two states forever.
307
+ // RUN (spec §3.8): the rules and their bookkeeping are planRuns()'s, below
308
+ // (v3.17.3 moved them there so a run's marker style is known before its
309
+ // first item is written).
319
310
  //
320
311
  // Same DOM-API constraint as the rest of this module, plus
321
312
  // `classList.contains` (state classes are token-matched, never compared as
@@ -363,16 +354,115 @@
363
354
  // `prefix` is what §3.4's colDelta measures the OLD side against, so it comes
364
355
  // back as literal text rather than a count — a caller that finds a TAB in it
365
356
  // knows the column arithmetic is not exact and can decline.
366
- const SRC_MARKER_RE = /^([ \t]*)(?:[-*+]|\d{1,9}[.)])([ \t]+)(?:\[[ xX]\][ \t]+)?/;
357
+ const SRC_MARKER_RE = /^([ \t]*)([-*+]|\d{1,9}[.)])([ \t]+)(?:\[[ xX]\][ \t]+)?/;
358
+ // `style` is the LAST marker's bullet character or ordinal delimiter ('-',
359
+ // '*', '+', '.', ')') — the item's own, on a same-line nest — or null.
367
360
  function splitSourceMarkers(line) {
368
361
  const text = typeof line === 'string' ? line : '';
369
362
  let at = 0;
363
+ let style = null;
370
364
  for (;;) {
371
365
  const m = SRC_MARKER_RE.exec(text.slice(at));
372
366
  if (!m) break;
373
367
  at += m[0].length;
368
+ style = m[2].charAt(m[2].length - 1);
374
369
  }
375
- return { prefix: text.slice(0, at), content: text.slice(at) };
370
+ return { prefix: text.slice(0, at), content: text.slice(at), style: style };
371
+ }
372
+
373
+ // ── v3.17.3 A9 (R5): a run is written with ITS OWN marker ───────────────
374
+ // A bullet list may use '-', '*' or '+', an ordered one '.' or ')', and
375
+ // CommonMark starts a NEW list at a change of either. Writing the canonical
376
+ // '- ' / 'n. ' for every item turned `* a\n* b` into `- a\n- bX` on an edit
377
+ // to b — a in the same commit — which rewrote lines nobody touched (README
378
+ // promises only touched lines change).
379
+ //
380
+ // So every run (the RUN rule below: one list at one depth, as marked sees
381
+ // it — `- a` and `* c` side by side are two lists, data-list-start keeps
382
+ // them apart) is written with one style, chosen by its items' SOURCE
383
+ // markers in three tiers, the first tier with any vote deciding (within a
384
+ // tier the majority, ties going to the earliest):
385
+ // 1. the run's own CARRIED items (opts.carryOver: bytes replayed, so the
386
+ // marker on their first line is the list's own);
387
+ // 2. its re-serialised items, by what opts.sourceMarker(blockEl) answers
388
+ // (client.js reads it off the file — the edited item, say);
389
+ // 3. items opts.foreign(blockEl) names — moved in from another list by
390
+ // the gesture (client.js's moveSpanAcrossLists()). Their old marker is
391
+ // another list's, so it counts only for a run made of nothing else (a
392
+ // list moved whole keeps its style); otherwise a moved item takes the
393
+ // run's. Without the tiers a `* moved` dropped into `- alpha` won the
394
+ // 1:1 tie and rewrote the untouched item (review I1, measured).
395
+ // An item with no source at all (one Enter just made) votes for nothing and
396
+ // takes the run's style; a run where no item has one is written
397
+ // canonically. A style the run's type cannot carry (a '*' source on an item
398
+ // converted to ordered) is not a vote.
399
+ const UL_STYLES = '-*+';
400
+ const OL_STYLES = '.)';
401
+ function styleFits(style, listType) {
402
+ return typeof style === 'string' && style.length === 1 &&
403
+ (listType === 'ol' ? OL_STYLES : UL_STYLES).indexOf(style) !== -1;
404
+ }
405
+
406
+ // The run bookkeeping, done once ahead of the emission so a run's style is
407
+ // known before its first item is written (that item may be the one being
408
+ // re-serialised, with no source of its own). Returns, per block, null (not
409
+ // a li) or { run, ordinal }. `firstOrdinal` is the number the FIRST run
410
+ // starts at (default 1; client.js's convert passes the number a joined list
411
+ // continues from, v3.17.3 A7) — every later run restarts at 1.
412
+ //
413
+ // RUN (spec §3.8): a run breaks at a shallower li, at a same-depth li whose
414
+ // data-list-type differs, or at a non-li block. Deeper items never break
415
+ // the run they are nested under. Every run's ordinal restarts at 1 — hence
416
+ // `counters`, indexed by depth, with everything deeper than the current
417
+ // depth cleared whenever the walk comes back up.
418
+ //
419
+ // Rule (b) compares against the last block seen AT THAT DEPTH (`types`),
420
+ // NOT against the immediately previous block: with a deeper item sitting
421
+ // between two same-depth blocks, comparing against `prev` makes the
422
+ // list-type change invisible and the ordinal never restarts. The md then
423
+ // re-lexes as <ol start="3">, §3.8 discards `start`, and the next commit
424
+ // renumbers to '1.' — the document oscillates between two states forever.
425
+ //
426
+ // Rule (d) is the fourth reset, and rules (a)-(c) cannot derive it:
427
+ // ' 1. x' followed by ' 1) y' is TWO nested list tokens (a delimiter
428
+ // change starts a new list) at the SAME depth with the SAME type, so
429
+ // without this the second list is renumbered as a continuation of the
430
+ // first ('2. y') and a commit rewrites a list the user never touched.
431
+ // lib/md2doc.js stamps the attribute; only the renderer still knows
432
+ // where marked's token boundaries were.
433
+ function planRuns(els, firstOrdinal) {
434
+ const plan = [];
435
+ const counters = [];
436
+ const types = [];
437
+ const runAt = [];
438
+ let nextRun = 0;
439
+ let prev = null;
440
+ let first = true;
441
+ els.forEach((blockEl) => {
442
+ if (blockEl.getAttribute('data-block-type') !== 'li') { plan.push(null); return; }
443
+ const indent = Number(blockEl.getAttribute('data-indent')) || 0;
444
+ const listType = blockEl.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul';
445
+ const isListStart = blockEl.getAttribute('data-list-start') === '1';
446
+ // Going DEEPER opens a brand-new run at that depth (even when the type
447
+ // matches); a list-type change against the last block AT THIS DEPTH
448
+ // closes that run and opens another; coming back UP leaves this depth's
449
+ // run open (deeper items did not break it) but clears every deeper
450
+ // depth so the next descent restarts at 1.
451
+ if (isListStart || !prev || indent > prev.indent || types[indent] !== listType) {
452
+ counters[indent] = first ? firstOrdinal - 1 : 0;
453
+ runAt[indent] = nextRun++;
454
+ }
455
+ first = false;
456
+ for (let k = counters.length - 1; k > indent; k--) {
457
+ counters[k] = 0;
458
+ types[k] = undefined;
459
+ }
460
+ types[indent] = listType;
461
+ counters[indent] = (counters[indent] || 0) + 1;
462
+ plan.push({ run: runAt[indent], ordinal: counters[indent] });
463
+ prev = { indent: indent };
464
+ });
465
+ return plan;
376
466
  }
377
467
 
378
468
  function spaces(n) {
@@ -460,6 +550,36 @@
460
550
  // `blank: true`, so
461
551
  // `lineMeta.length === md.split('\n').length` still holds.
462
552
  const blankBefore = (opts && typeof opts.blankBefore === 'function') ? opts.blankBefore : null;
553
+ const sourceMarker = (opts && typeof opts.sourceMarker === 'function') ? opts.sourceMarker : null;
554
+ const foreign = (opts && typeof opts.foreign === 'function') ? opts.foreign : null;
555
+ const firstOrdinal = (opts && Number(opts.firstOrdinal) >= 1) ? Math.floor(Number(opts.firstOrdinal)) : 1;
556
+ const els = toArray(blockEls);
557
+ const plan = planRuns(els, firstOrdinal);
558
+ // v3.17.3 A9: each run's style, by the tiered vote described at UL_STYLES.
559
+ // votes[run][tier] = [{ style, n }] in first-seen order.
560
+ const votes = {};
561
+ els.forEach((blockEl, i) => {
562
+ if (!plan[i]) return;
563
+ const id = blockEl.getAttribute('data-block-id');
564
+ const carried = carryOver && id !== null && carryOver[id];
565
+ const style = carried && carried.length ? splitSourceMarkers(carried[0]).style
566
+ : (sourceMarker ? sourceMarker(blockEl) : null);
567
+ const listType = blockEl.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul';
568
+ if (!styleFits(style, listType)) return;
569
+ const tier = foreign && foreign(blockEl) ? 2 : (carried && carried.length ? 0 : 1);
570
+ const byTier = votes[plan[i].run] || (votes[plan[i].run] = [[], [], []]);
571
+ const v = byTier[tier];
572
+ const hit = v.find((x) => x.style === style);
573
+ if (hit) hit.n++;
574
+ else v.push({ style: style, n: 1 });
575
+ });
576
+ const runStyle = {};
577
+ Object.keys(votes).forEach((r) => {
578
+ const v = votes[r].find((t) => t.length > 0);
579
+ let best = null;
580
+ v.forEach((x) => { if (!best || x.n > best.n) best = x; });
581
+ runStyle[r] = best.style;
582
+ });
463
583
  let prevItemEl = null;
464
584
  const unsupported = [];
465
585
  const unsupportedByLi = [];
@@ -467,13 +587,8 @@
467
587
  const lineMeta = [];
468
588
  const lines = [];
469
589
  // widths[k] = the marker width (as a literal run of spaces) of the
470
- // innermost item seen at depth k; counters[k] = the running ordinal of
471
- // the run currently open at depth k.
590
+ // innermost item seen at depth k. The ordinals are planRuns()'s.
472
591
  const widths = [];
473
- const counters = [];
474
- // types[k] = the list type of the last block seen at depth k, so rule (b)
475
- // survives a deeper item sitting between two same-depth blocks.
476
- const types = [];
477
592
  let prev = null; // { indent, listType }
478
593
 
479
594
  // ── Round 6: a CONTENT-FREE TASK item cannot be given a line of its own ──
@@ -522,7 +637,7 @@
522
637
  return line.slice(k);
523
638
  }
524
639
 
525
- toArray(blockEls).forEach((blockEl) => {
640
+ els.forEach((blockEl, blockIdx) => {
526
641
  const blockId = blockEl.getAttribute('data-block-id');
527
642
 
528
643
  // §3.8 rule (c): a non-li block terminates a run. It has no marker and
@@ -546,40 +661,21 @@
546
661
  if (pending && indent <= pending.indent) flushPending();
547
662
  const listType = blockEl.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul';
548
663
  const isTask = blockEl.getAttribute('data-task') === '1';
549
- // §3.8 rule (d): this block is the first item of its own list TOKEN.
550
- const isListStart = blockEl.getAttribute('data-list-start') === '1';
551
-
552
- // Run bookkeeping. Going DEEPER opens a brand-new run at that depth
553
- // (even when the type matches); a list-type change against the last
554
- // block AT THIS DEPTH closes that run and opens another; coming back
555
- // UP leaves this depth's run open (deeper items did not break it) but
556
- // clears every deeper depth so the next descent restarts at 1.
557
- //
558
- // Rule (d) is the fourth reset, and rules (a)-(c) cannot derive it:
559
- // ' 1. x' followed by ' 1) y' is TWO nested list tokens (a delimiter
560
- // change starts a new list) at the SAME depth with the SAME type, so
561
- // without this the second list is renumbered as a continuation of the
562
- // first ('2. y') and a commit rewrites a list the user never touched.
563
- // lib/md2doc.js stamps the attribute; only the renderer still knows
564
- // where marked's token boundaries were.
565
- if (isListStart || !prev || indent > prev.indent || types[indent] !== listType) {
566
- counters[indent] = 0;
567
- }
568
- for (let k = counters.length - 1; k > indent; k--) {
569
- counters[k] = 0;
570
- types[k] = undefined;
571
- }
572
- types[indent] = listType;
664
+ // §3.8's run bookkeeping (rules (a)-(d)) is planRuns()'s, above.
665
+ const slot = plan[blockIdx];
573
666
 
574
667
  const indentPrefix = widths.slice(0, indent).join('');
575
- counters[indent] = (counters[indent] || 0) + 1;
576
668
 
577
669
  const checkEl = firstChildWithClass(blockEl, 'ed-li-check');
578
670
  const isChecked = checkEl ? checkEl.getAttribute('data-checked') === '1' : false;
579
671
  // Two-part marker: the bullet (ordered ordinal or '- ') and an
580
672
  // optional checkbox, kept independent so an ordered task list gets
581
673
  // BOTH ('1. [ ] todo') — RULING F-N, same as serializeList above.
582
- const bullet = listType === 'ol' ? (counters[indent] + '. ') : '- ';
674
+ // v3.17.3 A9: in the run's own style ('* ', '1) '); every style is one
675
+ // character, so the bullet's width — which `widths` and the child
676
+ // indent are computed from — is the canonical one's.
677
+ const style = runStyle[slot.run];
678
+ const bullet = listType === 'ol' ? (slot.ordinal + (style || '.') + ' ') : ((style || '-') + ' ');
583
679
  const marker = isTask ? bullet + (isChecked ? '[x] ' : '[ ] ') : bullet;
584
680
 
585
681
  const textEl = firstChildWithClass(blockEl, 'ed-li-text');
@@ -4,7 +4,8 @@ const path = require('path');
4
4
  const http = require('http');
5
5
  const crypto = require('crypto');
6
6
  const drawioLib = require('../drawio.js');
7
- const { renderMarkdown, buildKatexStyleTag, diagramEnginePaths, renderDotSvg } = require('../md2doc.js');
7
+ const { renderMarkdown, buildKatexStyleTag, diagramEnginePaths } = require('../md2doc.js');
8
+ const { createDotPreview } = require('./dot-preview.js');
8
9
 
9
10
  const LINEOPS_SRC = fs.readFileSync(path.join(__dirname, 'lineops.js'), 'utf8');
10
11
  const INLINE_MD_SRC = fs.readFileSync(path.join(__dirname, 'inline-md.js'), 'utf8');
@@ -353,6 +354,8 @@ async function createEditorServer({
353
354
  // has ever been registered" — see registerAliveConnection() and
354
355
  // DEFAULT_NEVER_OPENED_GRACE_MS's own comment on why that boundary moved
355
356
  // off GET /edit/:id.
357
+ // v3.17.3 A4: real paths of the files POST /api/asset wrote in this process.
358
+ const writtenAssets = new Set();
356
359
  const aliveConnections = new Set();
357
360
  let shutdownGraceTimer = null;
358
361
  // Guards against the (already-benign, but worth making impossible on
@@ -677,6 +680,8 @@ async function createEditorServer({
677
680
  });
678
681
  }
679
682
 
683
+ // Graphviz preview runs in a worker thread, created on the first request.
684
+ const dotPreview = createDotPreview();
680
685
  const server = http.createServer(async (req, res) => {
681
686
  try {
682
687
  const url = new URL(req.url, 'http://127.0.0.1');
@@ -707,7 +712,10 @@ async function createEditorServer({
707
712
  // is running could fire a cross-site "simple" POST at every candidate
708
713
  // localhost port and write to disk.
709
714
  const STATE_CHANGING_POST_PATHS = new Set(['/api/render', '/api/save', '/api/ping', '/api/asset', '/api/preview']);
710
- if (req.method === 'POST' && STATE_CHANGING_POST_PATHS.has(url.pathname)) {
715
+ // v3.17.3 A4: DELETE /api/asset is state-changing too; it gets the same
716
+ // content-type gate (the client sends it with the fetch).
717
+ if ((req.method === 'POST' && STATE_CHANGING_POST_PATHS.has(url.pathname)) ||
718
+ (req.method === 'DELETE' && url.pathname === '/api/asset')) {
711
719
  const contentType = String(req.headers['content-type'] || '');
712
720
  if (!/^application\/json\b/i.test(contentType)) {
713
721
  return send(res, 415, { error: 'content-type must be application/json' });
@@ -847,8 +855,11 @@ async function createEditorServer({
847
855
  if (body.lang !== 'dot') return send(res, 400, { error: 'lang must be dot' });
848
856
  if (typeof body.source !== 'string') return send(res, 400, { error: 'source must be a string' });
849
857
  try {
850
- return send(res, 200, { svg: await renderDotSvg(body.source) });
858
+ return send(res, 200, { svg: await dotPreview.render(body.source) });
851
859
  } catch (e) {
860
+ // A dead worker is the server's fault, not the source's: 500, and
861
+ // the next request starts a new one.
862
+ if (e && e.crashed === true) return send(res, 500, { error: 'graphviz worker failed' });
852
863
  const first = String((e && e.message) || e).split('\n')[0];
853
864
  return send(res, 422, { error: first });
854
865
  }
@@ -1005,9 +1016,45 @@ async function createEditorServer({
1005
1016
  }
1006
1017
  throw e;
1007
1018
  }
1019
+ writtenAssets.add(path.join(realAssets, name));
1008
1020
  return send(res, 200, { name, path: assetLib.relPath(name) });
1009
1021
  }
1010
1022
 
1023
+ // v3.17.3 A4: takes back an upload the page made and then could not use
1024
+ // (a replace refused while its file was still on the way). Only a file
1025
+ // this server instance wrote through POST /api/asset can go: the path is
1026
+ // looked up in `writtenAssets`, never trusted. Anything else, a file that
1027
+ // exists included, is 404 and untouched. `fileId` (optional) names the
1028
+ // document the path is relative to; without it every open document's
1029
+ // directory is tried.
1030
+ if (req.method === 'DELETE' && url.pathname === '/api/asset') {
1031
+ const rel = url.searchParams.get('path');
1032
+ const fidRaw = url.searchParams.get('fileId');
1033
+ const fid = fidRaw === null ? null : Number(fidRaw);
1034
+ if (typeof rel !== 'string' || rel === '' || rel.indexOf('\0') !== -1 ||
1035
+ (fid !== null && (!Number.isInteger(fid) || fid < 0 || fid >= absFiles.length))) {
1036
+ return send(res, 404, { error: 'not an uploaded asset' });
1037
+ }
1038
+ const candidates = fid === null ? absFiles : [absFiles[fid]];
1039
+ for (const file of candidates) {
1040
+ let target;
1041
+ try {
1042
+ const dir = fs.realpathSync(path.dirname(file));
1043
+ const abs = path.resolve(dir, rel);
1044
+ target = path.join(fs.realpathSync(path.dirname(abs)), path.basename(abs));
1045
+ } catch (e) { continue; }
1046
+ if (!writtenAssets.has(target)) continue;
1047
+ try { fs.unlinkSync(target); } catch (e) {
1048
+ if (e && e.code === 'ENOENT') { writtenAssets.delete(target); continue; }
1049
+ throw e;
1050
+ }
1051
+ writtenAssets.delete(target);
1052
+ res.writeHead(204);
1053
+ return res.end();
1054
+ }
1055
+ return send(res, 404, { error: 'not an uploaded asset' });
1056
+ }
1057
+
1011
1058
  // v3.6.0: the liveness stream. A GET (EventSource cannot send any other
1012
1059
  // method), held open for as long as the tab exists. fileId/clientId are
1013
1060
  // read here purely so a future diagnostic has them to hand — see
@@ -1128,6 +1175,8 @@ async function createEditorServer({
1128
1175
  }, neverOpenedGraceMs);
1129
1176
  if (neverOpenedTimer.unref) neverOpenedTimer.unref();
1130
1177
 
1178
+ server.on('close', () => dotPreview.close());
1179
+
1131
1180
  return {
1132
1181
  server,
1133
1182
  port,
@@ -1020,6 +1020,15 @@
1020
1020
  // moves (the ⠿ menu's 上移 / 下移 say Alt+↑ / Alt+↓); from the
1021
1021
  // canvas, the selected one, as before. A group's ⠿ (or its menu)
1022
1022
  // moves the group, like that menu's 上移 / 下移.
1023
+ // v3.17.3 (C14, R14): 週期與相位…'s fields are text the keyboard is
1024
+ // typing into. Alt+↑/↓ there writes what is typed to the signal
1025
+ // whose panel this is (closePopover flushes to its own index) and
1026
+ // moves no lane; before, the selected lane moved under the open
1027
+ // panel and the value landed on whoever then sat at its index.
1028
+ if (popover !== null && popover.kind === 'period' && popover.el.contains(ev.target)) {
1029
+ closePopover(true);
1030
+ return;
1031
+ }
1023
1032
  const named = keyboardLane(ev.target);
1024
1033
  const group = named === null ? keyboardGroup(ev.target) : null;
1025
1034
  if (named !== null) {