@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.
- package/lib/editor/blockmap.js +65 -0
- package/lib/editor/client.js +1640 -131
- package/lib/editor/code-edit.js +6 -5
- package/lib/editor/convert-md.js +9 -2
- package/lib/editor/dot-preview.js +65 -0
- package/lib/editor/dot-worker.js +14 -0
- package/lib/editor/inline-md.js +34 -4
- package/lib/editor/list-md.js +143 -47
- package/lib/editor/server.js +52 -3
- package/lib/editor/wave-ui.js +9 -0
- package/lib/md2doc.js +25 -2
- package/lib/theme/tokens.js +4 -1
- package/package.json +1 -1
package/lib/editor/code-edit.js
CHANGED
|
@@ -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
|
|
127
|
-
//
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
return
|
|
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;
|
package/lib/editor/convert-md.js
CHANGED
|
@@ -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
|
+
});
|
package/lib/editor/inline-md.js
CHANGED
|
@@ -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 `&`, ``` and `\` read before, a source `a < b` showed "a < b" in the field
|
|
532
|
+
// and, once edited, was written `a &lt; b`, which the reader shows as
|
|
533
|
+
// "a < 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 = { '[': '\\[', ']': '\\]', '&': '&', '`': '`', '\\': '\' };
|
|
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(
|
|
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
|
|
535
|
-
//
|
|
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,
|
|
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);
|
package/lib/editor/list-md.js
CHANGED
|
@@ -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):
|
|
308
|
-
//
|
|
309
|
-
//
|
|
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]*)(
|
|
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
|
|
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
|
-
|
|
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
|
|
550
|
-
const
|
|
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
|
-
|
|
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');
|
package/lib/editor/server.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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,
|
package/lib/editor/wave-ui.js
CHANGED
|
@@ -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)
|
|
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) {
|