@helping-ai-workflow/md2doc 3.17.2 → 3.17.3

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,43 @@
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): and every other character reference the
528
+ // reader decodes — marked passes them into the alt attribute untouched and
529
+ // the browser decodes them — so the field shows what the reader shows: a
530
+ // decimal or hex number, and the named amp, lt, gt, quot, apos and nbsp. With
531
+ // only `&amp;`, `&#96;` and `&#92;` read before, a source `a &lt; b` showed "a &lt; b" in the field
532
+ // and, once edited, was written `a &amp;lt; b`, which the reader shows as
533
+ // "a &lt; b". A name outside that list stays as written in the field, and
534
+ // is written so that it renders as that text.
535
+ const ALT_ESCAPE = { '[': '\\[', ']': '\\]', '&': '&amp;', '`': '&#96;', '\\': '&#92;' };
536
+ const ALT_NAMED_CHAR = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0' };
537
+ const ALT_UNESCAPE = /\\([!-\/:-@\[-`{-~])|&(?:#([0-9]+)|#[xX]([0-9a-fA-F]+)|(amp|lt|gt|quot|apos|nbsp));/g;
538
+ // A number the HTML parser would not give as itself (zero, a surrogate,
539
+ // past U+10FFFF) is U+FFFD there, so it is here too.
540
+ function altCodePoint(n) {
541
+ return n === 0 || n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff) ? '\ufffd' : String.fromCodePoint(n);
542
+ }
543
+ function altUnescape(m, c, dec, hex, name) {
544
+ if (c !== undefined) return c;
545
+ if (dec !== undefined) return altCodePoint(parseInt(dec, 10));
546
+ if (hex !== undefined) return altCodePoint(parseInt(hex, 16));
547
+ return ALT_NAMED_CHAR[name];
548
+ }
549
+
520
550
  function parseImageParagraph(text) {
521
551
  const f = scanImage(text);
522
552
  if (!f) return null;
523
553
  const rawAlt = f.s.slice(f.altStart, f.altEnd);
524
554
  const rawDest = f.s.slice(f.destStart, f.destEnd);
525
555
  return {
526
- alt: rawAlt.replace(/\\([!-\/:-@\[-`{-~])/g, '$1'),
556
+ alt: rawAlt.replace(ALT_UNESCAPE, altUnescape),
527
557
  dest: f.angle ? rawDest.slice(1, -1) : rawDest,
528
558
  title: f.title,
529
559
  angle: f.angle,
@@ -531,15 +561,15 @@
531
561
  }
532
562
 
533
563
  // 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
564
+ // (the characters that would end or bend the brackets are escaped, see
565
+ // ALT_ESCAPE); `dest` is written as given, so the caller makes it
536
566
  // markdown-ready — an angle-bracketed original loses its brackets.
537
567
  function rewriteImage(text, patch) {
538
568
  const f = scanImage(text);
539
569
  if (!f) return String(text == null ? '' : text);
540
570
  const p = patch || {};
541
571
  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);
572
+ out += typeof p.alt === 'string' ? p.alt.replace(/[\\\[\]&`]/g, (c) => ALT_ESCAPE[c]) : f.s.slice(f.altStart, f.altEnd);
543
573
  out += f.s.slice(f.altEnd, f.destStart);
544
574
  out += typeof p.dest === 'string' ? p.dest : f.s.slice(f.destStart, f.destEnd);
545
575
  out += f.s.slice(f.destEnd);
@@ -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) {
package/lib/md2doc.js CHANGED
@@ -379,6 +379,9 @@ function renderEditModeList(listToken, blocks, biRef, out) {
379
379
  const check = b.task
380
380
  ? `<span class="ed-li-check" data-checked="${b.checked ? 1 : 0}" role="checkbox" aria-checked="${!!b.checked}"></span>`
381
381
  : '';
382
+ // v3.17.3 C10: an svg in the item's own text gets the same white plate
383
+ // in dark as a top-level svg block (the attribute is read by the dark CSS).
384
+ const svgPlate = ownTokens.some((tk) => /<svg[\s>]/i.test(String(tk.raw || ''))) ? ' data-svg-plate="1"' : '';
382
385
  const runStart = liRunStartsHere(biRef, b) ? ' data-run-start="1"' : '';
383
386
  const listStartAttr = listStart ? ' data-list-start="1"' : '';
384
387
  listStart = false;
@@ -389,7 +392,7 @@ function renderEditModeList(listToken, blocks, biRef, out) {
389
392
  parts.push(
390
393
  `<div class="ed-block" data-block-id="${b.id}" data-block-type="li"` +
391
394
  ` data-list-type="${b.listType}" data-task="${b.task ? 1 : 0}" data-indent="${b.indent}"` +
392
- runStart + listStartAttr + looseAttr + ` style="--ed-indent:${b.indent}">` +
395
+ runStart + listStartAttr + looseAttr + svgPlate + ` style="--ed-indent:${b.indent}">` +
393
396
  `<span class="ed-li-marker" aria-hidden="true"></span>` + check +
394
397
  `<div class="ed-li-text">${inner}</div>` +
395
398
  `</div>`
@@ -2767,6 +2770,7 @@ const html = `<!DOCTYPE html>
2767
2770
  border-radius: 3px;
2768
2771
  }
2769
2772
  mark.search-hit-block { display: block; }
2773
+ svg .svg-hit { stroke: var(--md-svg-hit); stroke-width: 6px; stroke-linejoin: round; paint-order: stroke; }
2770
2774
  .toc {
2771
2775
  display: flex;
2772
2776
  flex-direction: column;
@@ -4799,6 +4803,15 @@ ${mermaidInitTag}`)
4799
4803
  }
4800
4804
 
4801
4805
  const SKIP_SELECTOR = 'svg, .mermaid, .graphviz, script, style';
4806
+ // v3.17.3 C11: a Markdown-native inline svg is read (its text is in the
4807
+ // search index); a diagram's own svg (mermaid, graphviz, draw.io, WaveDrom,
4808
+ // a formula) is not. Inside a native svg every text node is walked, the
4809
+ // ones in its title and desc elements too: the index holds those as well
4810
+ // (the paragraph's text tokens between the html tags), so a query that
4811
+ // finds the section can find them here.
4812
+ function isNativeSvg(el) {
4813
+ return el.matches('svg') && !el.closest('.mermaid, .graphviz, .drawio, .katex, .code-block, [class*="wave"], [id^="WaveDrom"]');
4814
+ }
4802
4815
 
4803
4816
  function buildSnippet(section, query) {
4804
4817
  const haystack = section.searchText || section.title || '';
@@ -4845,6 +4858,7 @@ ${mermaidInitTag}`)
4845
4858
  ? readerState.activeHighlights
4846
4859
  : (readerState.activeHighlight ? [readerState.activeHighlight] : []);
4847
4860
  marks.forEach((mark) => {
4861
+ if (mark.namespaceURI === 'http://www.w3.org/2000/svg') { mark.classList.remove('svg-hit'); if (!mark.getAttribute('class')) mark.removeAttribute('class'); return; }
4848
4862
  const parent = mark.parentNode;
4849
4863
  if (!parent) return;
4850
4864
  while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
@@ -4975,7 +4989,8 @@ ${mermaidInitTag}`)
4975
4989
  const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
4976
4990
  acceptNode(node) {
4977
4991
  if (node.nodeType === Node.ELEMENT_NODE) {
4978
- if (node.matches(SKIP_SELECTOR) || node.matches('.reader-sidebar, .heading-anchor, button')) return NodeFilter.FILTER_REJECT;
4992
+ if (node.matches(SKIP_SELECTOR) && !isNativeSvg(node)) return NodeFilter.FILTER_REJECT;
4993
+ if (node.matches('.reader-sidebar, .heading-anchor, button')) return NodeFilter.FILTER_REJECT;
4979
4994
  return node.matches('br, .katex') ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
4980
4995
  }
4981
4996
  if (!node.nodeValue) return NodeFilter.FILTER_REJECT;
@@ -5065,6 +5080,14 @@ ${mermaidInitTag}`)
5065
5080
  const marks = [];
5066
5081
  Array.from(spans.keys()).sort((a, b) => b - a).forEach((ni) => {
5067
5082
  const node = nodes[ni];
5083
+ // v3.17.3 C11: svg cannot hold a mark element; the text node's parent
5084
+ // (text or tspan) is flagged instead and painted by .svg-hit.
5085
+ if (node.nodeType === Node.TEXT_NODE && node.parentElement && node.parentElement.closest('svg')) {
5086
+ // Two text nodes can share one parent (a tspan or a comment splits it).
5087
+ node.parentElement.classList.add('svg-hit');
5088
+ if (marks.indexOf(node.parentElement) === -1) marks.unshift(node.parentElement);
5089
+ return;
5090
+ }
5068
5091
  const mark = document.createElement('mark');
5069
5092
  mark.className = 'search-hit is-selected';
5070
5093
  if (node.nodeType === Node.ELEMENT_NODE) {