@helping-ai-workflow/md2doc 3.16.1 → 3.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,184 @@
1
+ 'use strict';
2
+ /* UMD, same shape as convert-md.js: `require`-able in node for the unit tests
3
+ (test/code-edit.test.js), and injected into the editor page as
4
+ `window.md2docCodeEdit` (lib/editor/server.js). Interpolated raw into a
5
+ <script> tag, so it must never contain a closing script tag. */
6
+ (function (root, factory) {
7
+ if (typeof module === 'object' && module.exports) module.exports = factory();
8
+ else root.md2docCodeEdit = factory();
9
+ })(typeof self !== 'undefined' ? self : this, function () {
10
+
11
+ // v3.17.0 T1: the pure core of the in-place code editor (decisions D3, D7,
12
+ // D10). No DOM, no editor state.
13
+
14
+ // D7: the languages the highlighter registers (lib/highlight.js LANGS, same
15
+ // order), each with its display name and every alias that resolves to it —
16
+ // highlight.js's own built-in aliases plus the two custom lists. A unit row
17
+ // keeps this table equal to the server's.
18
+ const CODE_LANGS = [
19
+ { id: 'verilog', label: 'Verilog', aliases: ['v', 'sv', 'svh', 'systemverilog'] },
20
+ { id: 'python', label: 'Python', aliases: ['py', 'gyp', 'ipython'] },
21
+ { id: 'bash', label: 'Bash', aliases: ['sh', 'zsh', 'shell'] },
22
+ { id: 'c', label: 'C', aliases: ['h'] },
23
+ { id: 'cpp', label: 'C++', aliases: ['cc', 'c++', 'h++', 'hpp', 'hh', 'hxx', 'cxx'] },
24
+ { id: 'json', label: 'JSON', aliases: ['jsonc'] },
25
+ { id: 'yaml', label: 'YAML', aliases: ['yml'] },
26
+ { id: 'tcl', label: 'Tcl', aliases: ['tk'] },
27
+ { id: 'makefile', label: 'Makefile', aliases: ['mk', 'mak', 'make'] },
28
+ { id: 'javascript', label: 'JavaScript', aliases: ['js', 'jsx', 'mjs', 'cjs'] },
29
+ { id: 'diff', label: 'Diff', aliases: ['patch'] },
30
+ { id: '', label: '純文字', aliases: ['plaintext', 'text', 'txt'] },
31
+ ];
32
+
33
+ function firstWord(info) {
34
+ return String(info || '').trim().split(/\s+/)[0] || '';
35
+ }
36
+
37
+ // The chip's text for a fence info string: the first word's language name,
38
+ // aliases resolved; empty is 純文字; an unknown word is shown as written.
39
+ function langLabel(info) {
40
+ const word = firstWord(info);
41
+ if (!word) return '純文字';
42
+ const w = word.toLowerCase();
43
+ for (const l of CODE_LANGS) {
44
+ if ((l.id && l.id === w) || l.aliases.indexOf(w) !== -1) return l.label;
45
+ }
46
+ return word;
47
+ }
48
+
49
+ // The language menu's filter: case-insensitive prefix on id, label or alias.
50
+ function filterLangs(query) {
51
+ const q = String(query || '').trim().toLowerCase();
52
+ if (!q) return CODE_LANGS.slice();
53
+ return CODE_LANGS.filter((l) =>
54
+ (l.id && l.id.indexOf(q) === 0) || l.label.toLowerCase().indexOf(q) === 0 ||
55
+ l.aliases.some((a) => a.indexOf(q) === 0));
56
+ }
57
+
58
+ // Replace the info string's first word; '' removes it. The other words are
59
+ // kept, single-spaced.
60
+ function setFenceLang(info, lang) {
61
+ const words = String(info || '').trim().split(/\s+/).filter(Boolean);
62
+ const rest = words.slice(1);
63
+ return (lang ? [lang].concat(rest) : rest).join(' ');
64
+ }
65
+
66
+ // blockLines[0] must open a fence (up to 3 spaces, then 3+ backticks or
67
+ // tildes). CommonMark: a backtick fence's info string may not hold a backtick
68
+ // (that line is inline code). Body lines are returned raw (indentation
69
+ // included); the closer is the first line of 0-3 spaces + the same char at
70
+ // least `open` times + nothing else. No closer: the block runs to the end.
71
+ function parseFence(blockLines) {
72
+ if (!blockLines || !blockLines.length) return null;
73
+ const m = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(blockLines[0]);
74
+ if (!m) return null;
75
+ const char = m[2][0];
76
+ const info = m[3].trim();
77
+ if (char === '`' && info.indexOf('`') !== -1) return null;
78
+ const open = m[2].length;
79
+ const closer = new RegExp('^ {0,3}' + (char === '`' ? '`' : '~') + '{' + open + ',}[ \\t]*$');
80
+ const body = [];
81
+ let closed = false;
82
+ for (let i = 1; i < blockLines.length; i++) {
83
+ if (closer.test(blockLines[i])) { closed = true; break; }
84
+ body.push(blockLines[i]);
85
+ }
86
+ return { indent: m[1], char, open, info, body, closed };
87
+ }
88
+
89
+ // D10: n = max(3, the opening length, the longest run of the fence char
90
+ // anywhere in the body + 1). The body is written as given.
91
+ function buildFence(f) {
92
+ let longest = 0;
93
+ const re = f.char === '~' ? /~+/g : /`+/g;
94
+ for (const line of f.body) {
95
+ let r;
96
+ re.lastIndex = 0;
97
+ while ((r = re.exec(line))) if (r[0].length > longest) longest = r[0].length;
98
+ }
99
+ const n = Math.max(3, f.open || 3, longest + 1);
100
+ const bar = f.indent + f.char.repeat(n);
101
+ return [bar + f.info].concat(f.body, [bar]);
102
+ }
103
+
104
+ // D3 Enter: a newline plus the current line's leading whitespace (only as far
105
+ // as the caret when the caret sits inside the indentation).
106
+ function newlineWithIndent(text, pos) {
107
+ const lineStart = text.lastIndexOf('\n', pos - 1) + 1;
108
+ const ws = /^[ \t]*/.exec(text.slice(lineStart))[0];
109
+ const indent = ws.slice(0, Math.max(0, Math.min(ws.length, pos - lineStart)));
110
+ return { text: text.slice(0, pos) + '\n' + indent + text.slice(pos), caret: pos + 1 + indent.length };
111
+ }
112
+
113
+ // D3 Tab / Shift+Tab over a selection (R42). Touched lines are those that
114
+ // intersect [selStart, selEnd]; when the selection is not collapsed and its
115
+ // end sits at column 0 of a line, that line is not touched. Indent adds two
116
+ // spaces; dedent removes up to two leading spaces. A position on a touched
117
+ // line keeps its column relative to the line's text (indent: a column-0
118
+ // position moves with the text, unlike VS Code; dedent: clamps at the line
119
+ // start); positions on other lines just follow the text.
120
+ function indentLines(text, selStart, selEnd, dedent) {
121
+ const lines = text.split('\n');
122
+ const starts = [];
123
+ let off = 0;
124
+ for (const l of lines) { starts.push(off); off += l.length + 1; }
125
+ const touched = lines.map((l, i) => {
126
+ const s = starts[i], e = s + l.length;
127
+ if (!(s <= selEnd && e >= selStart)) return false;
128
+ if (selEnd > selStart && s === selEnd) return false;
129
+ return true;
130
+ });
131
+ const delta = lines.map((l, i) => {
132
+ if (!touched[i]) return 0;
133
+ if (!dedent) return 2;
134
+ const lead = /^ */.exec(l)[0].length;
135
+ return -Math.min(2, lead);
136
+ });
137
+ const newStarts = [];
138
+ let n = 0;
139
+ for (let i = 0; i < lines.length; i++) { newStarts.push(n); n += lines[i].length + delta[i] + 1; }
140
+ const out = lines.map((l, i) => (delta[i] > 0 ? ' ' + l : delta[i] < 0 ? l.slice(-delta[i]) : l));
141
+ function map(pos) {
142
+ let i = lines.length - 1;
143
+ while (i > 0 && starts[i] > pos) i--;
144
+ const col = pos - starts[i];
145
+ return newStarts[i] + (delta[i] < 0 ? Math.max(0, col + delta[i]) : col + delta[i]);
146
+ }
147
+ return { text: out.join('\n'), selStart: map(selStart), selEnd: map(selEnd) };
148
+ }
149
+
150
+ // The edit's own undo stack (native undo dies when re-colouring replaces the
151
+ // innerHTML). Snap = { text, selStart, selEnd }. A push whose text equals the
152
+ // current one is not a step; a push drops the redo tail; the stack keeps the
153
+ // last `cap` snapshots.
154
+ class TextHistory {
155
+ constructor(initial, cap) {
156
+ this.cap = cap || 200;
157
+ this.stack = [initial];
158
+ this.i = 0;
159
+ }
160
+ push(snap) {
161
+ if (snap.text === this.stack[this.i].text) {
162
+ this.stack[this.i] = snap;
163
+ return;
164
+ }
165
+ this.stack.length = this.i + 1;
166
+ this.stack.push(snap);
167
+ if (this.stack.length > this.cap) this.stack.shift();
168
+ this.i = this.stack.length - 1;
169
+ }
170
+ undo() {
171
+ if (this.i <= 0) return null;
172
+ this.i--;
173
+ return this.stack[this.i];
174
+ }
175
+ redo() {
176
+ if (this.i >= this.stack.length - 1) return null;
177
+ this.i++;
178
+ return this.stack[this.i];
179
+ }
180
+ }
181
+
182
+ return { CODE_LANGS, langLabel, filterLangs, parseFence, buildFence, setFenceLang,
183
+ newlineWithIndent, indentLines, TextHistory };
184
+ });
@@ -11,8 +11,10 @@ const BODY = {
11
11
  "arrow-up-to-line": "<path d=\"M5 3h14\" /><path d=\"m18 13-6-6-6 6\" /><path d=\"M12 7v14\" />",
12
12
  "bold": "<path d=\"M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8\" />",
13
13
  "check": "<path d=\"M20 6 9 17l-5-5\" />",
14
+ "chevron-down": "<path d=\"m6 9 6 6 6-6\" />",
14
15
  "circle-alert": "<circle cx=\"12\" cy=\"12\" r=\"10\" /><line x1=\"12\" x2=\"12\" y1=\"8\" y2=\"12\" /><line x1=\"12\" x2=\"12.01\" y1=\"16\" y2=\"16\" />",
15
16
  "code": "<polyline points=\"16 18 22 12 16 6\" /><polyline points=\"8 6 2 12 8 18\" />",
17
+ "copy": "<rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\" /><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\" />",
16
18
  "grip-vertical": "<circle cx=\"9\" cy=\"12\" r=\"1\" /><circle cx=\"9\" cy=\"5\" r=\"1\" /><circle cx=\"9\" cy=\"19\" r=\"1\" /><circle cx=\"15\" cy=\"12\" r=\"1\" /><circle cx=\"15\" cy=\"5\" r=\"1\" /><circle cx=\"15\" cy=\"19\" r=\"1\" />",
17
19
  "heading": "<path d=\"M6 12h12\" /><path d=\"M6 20V4\" /><path d=\"M18 20V4\" />",
18
20
  "image": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" ry=\"2\" /><circle cx=\"9\" cy=\"9\" r=\"2\" /><path d=\"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21\" />",
@@ -26,6 +28,7 @@ const BODY = {
26
28
  "list-ordered": "<path d=\"M10 12h11\" /><path d=\"M10 18h11\" /><path d=\"M10 6h11\" /><path d=\"M4 10h2\" /><path d=\"M4 6h1v4\" /><path d=\"M6 18H4c0-1 2-2 2-3s-1-1.5-2-1\" />",
27
29
  "minus": "<path d=\"M5 12h14\" />",
28
30
  "panel-left": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" /><path d=\"M9 3v18\" />",
31
+ "pencil": "<path d=\"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z\" /><path d=\"m15 5 4 4\" />",
29
32
  "plus": "<path d=\"M5 12h14\" /><path d=\"M12 5v14\" />",
30
33
  "quote": "<path d=\"M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z\" /><path d=\"M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z\" />",
31
34
  "redo-2": "<path d=\"m15 14 5-5-5-5\" /><path d=\"M20 9H9.5A5.5 5.5 0 0 0 4 14.5A5.5 5.5 0 0 0 9.5 20H13\" />",
@@ -442,5 +442,109 @@
442
442
  return /^https?:\/\/\S+$/.test(String(text == null ? '' : text).trim());
443
443
  }
444
444
 
445
- return { serializeInline, canWysiwyg, escapeText, escapeBlockStarts, normalizeHref, isPastedUrl };
445
+ // ── v3.17.0 T7 (D17): the image paragraph ───────────────────────────────
446
+ // A paragraph that is one `![alt](dest "title")` and nothing else. scanImage
447
+ // finds the three spans a rewrite may touch (the alt between the brackets,
448
+ // the destination, everything else is left byte-for-byte): the title with
449
+ // its quote style and the whitespace around it, the leading and trailing
450
+ // whitespace of the line. A linked image, a reference image (`![a][r]`),
451
+ // text around the image or a second image is not one.
452
+ function scanImage(text) {
453
+ const s = String(text == null ? '' : text);
454
+ const m = /^[ \t]*!\[/.exec(s);
455
+ if (!m) return null;
456
+ let i = m[0].length;
457
+ const altStart = i;
458
+ let depth = 0;
459
+ for (; i < s.length; i++) {
460
+ const c = s[i];
461
+ if (c === '\\') { i++; continue; }
462
+ if (c === '\n') return null;
463
+ if (c === '[') depth++;
464
+ else if (c === ']') { if (depth === 0) break; depth--; }
465
+ }
466
+ if (i >= s.length || s[i] !== ']') return null;
467
+ const altEnd = i;
468
+ i++;
469
+ if (s[i] !== '(') return null;
470
+ i++;
471
+ while (i < s.length && (s[i] === ' ' || s[i] === '\t')) i++;
472
+ const destStart = i;
473
+ let angle = false;
474
+ let destEnd;
475
+ if (s[i] === '<') {
476
+ angle = true;
477
+ i++;
478
+ for (; i < s.length; i++) {
479
+ const c = s[i];
480
+ if (c === '\\') { i++; continue; }
481
+ if (c === '\n' || c === '<') return null;
482
+ if (c === '>') break;
483
+ }
484
+ if (i >= s.length) return null;
485
+ destEnd = i + 1;
486
+ i = destEnd;
487
+ } else {
488
+ let parens = 0;
489
+ for (; i < s.length; i++) {
490
+ const c = s[i];
491
+ if (c === '\\') { i++; continue; }
492
+ if (c === ' ' || c === '\t' || c === '\n') break;
493
+ if (c === '(') parens++;
494
+ else if (c === ')') { if (parens === 0) break; parens--; }
495
+ }
496
+ if (parens !== 0) return null;
497
+ destEnd = i;
498
+ }
499
+ let j = i;
500
+ while (j < s.length && (s[j] === ' ' || s[j] === '\t')) j++;
501
+ let title = null;
502
+ if (j > i && j < s.length && (s[j] === '"' || s[j] === "'" || s[j] === '(')) {
503
+ const close = s[j] === '(' ? ')' : s[j];
504
+ let k = j + 1;
505
+ for (; k < s.length; k++) {
506
+ if (s[k] === '\\') { k++; continue; }
507
+ if (s[k] === close) break;
508
+ }
509
+ if (k >= s.length) return null;
510
+ title = s.slice(j, k + 1);
511
+ j = k + 1;
512
+ while (j < s.length && (s[j] === ' ' || s[j] === '\t')) j++;
513
+ }
514
+ if (s[j] !== ')') return null;
515
+ const tail = s.slice(j + 1);
516
+ if (!/^[ \t]*$/.test(tail)) return null;
517
+ return { s, altStart, altEnd, destStart, destEnd, angle, title };
518
+ }
519
+
520
+ function parseImageParagraph(text) {
521
+ const f = scanImage(text);
522
+ if (!f) return null;
523
+ const rawAlt = f.s.slice(f.altStart, f.altEnd);
524
+ const rawDest = f.s.slice(f.destStart, f.destEnd);
525
+ return {
526
+ alt: rawAlt.replace(/\\([!-\/:-@\[-`{-~])/g, '$1'),
527
+ dest: f.angle ? rawDest.slice(1, -1) : rawDest,
528
+ title: f.title,
529
+ angle: f.angle,
530
+ };
531
+ }
532
+
533
+ // 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
536
+ // markdown-ready — an angle-bracketed original loses its brackets.
537
+ function rewriteImage(text, patch) {
538
+ const f = scanImage(text);
539
+ if (!f) return String(text == null ? '' : text);
540
+ const p = patch || {};
541
+ 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);
543
+ out += f.s.slice(f.altEnd, f.destStart);
544
+ out += typeof p.dest === 'string' ? p.dest : f.s.slice(f.destStart, f.destEnd);
545
+ out += f.s.slice(f.destEnd);
546
+ return out;
547
+ }
548
+
549
+ return { serializeInline, canWysiwyg, escapeText, escapeBlockStarts, normalizeHref, isPastedUrl, parseImageParagraph, rewriteImage };
446
550
  });
@@ -4,7 +4,7 @@ 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 } = require('../md2doc.js');
7
+ const { renderMarkdown, buildKatexStyleTag, diagramEnginePaths, renderDotSvg } = require('../md2doc.js');
8
8
 
9
9
  const LINEOPS_SRC = fs.readFileSync(path.join(__dirname, 'lineops.js'), 'utf8');
10
10
  const INLINE_MD_SRC = fs.readFileSync(path.join(__dirname, 'inline-md.js'), 'utf8');
@@ -29,6 +29,10 @@ const CONVERT_MD_SRC = fs.readFileSync(path.join(__dirname, 'convert-md.js'), 'u
29
29
  // skeletons). Pure, no dependency on any other editor module; it only has to
30
30
  // land before client.js reads window.md2docBlockCatalog.
31
31
  const BLOCK_CATALOG_SRC = fs.readFileSync(path.join(__dirname, 'block-catalog.js'), 'utf8');
32
+ // v3.17.0 T1: the pure core of the in-place code editor (fence parse/build,
33
+ // language list, Enter/Tab/Shift+Tab text edits, the edit's own undo history).
34
+ // No dependency on any other editor module; lands right after block-catalog.
35
+ const CODE_EDIT_SRC = fs.readFileSync(path.join(__dirname, 'code-edit.js'), 'utf8');
32
36
  // v3.2.0: the pure string-diff module behind incremental DOM patching —
33
37
  // stripBlockId()/patchmap() compare oldParts[i] against newParts[j] with only
34
38
  // data-block-id normalized (see the global constraint on that). Same "no
@@ -218,6 +222,9 @@ function diagramLibBody(name) {
218
222
  const close = tag.lastIndexOf('</style>');
219
223
  return { type: 'text/css; charset=utf-8', body: tag.slice(open, close) };
220
224
  }
225
+ if (name === 'highlight.js') {
226
+ return { type: 'application/javascript; charset=utf-8', body: require('../highlight.js').browserBundle() };
227
+ }
221
228
  return null;
222
229
  }
223
230
 
@@ -699,7 +706,7 @@ async function createEditorServer({
699
706
  // all: any page the user happens to be browsing while `md2doc --edit`
700
707
  // is running could fire a cross-site "simple" POST at every candidate
701
708
  // localhost port and write to disk.
702
- const STATE_CHANGING_POST_PATHS = new Set(['/api/render', '/api/save', '/api/ping', '/api/asset']);
709
+ const STATE_CHANGING_POST_PATHS = new Set(['/api/render', '/api/save', '/api/ping', '/api/asset', '/api/preview']);
703
710
  if (req.method === 'POST' && STATE_CHANGING_POST_PATHS.has(url.pathname)) {
704
711
  const contentType = String(req.headers['content-type'] || '');
705
712
  if (!/^application\/json\b/i.test(contentType)) {
@@ -768,6 +775,7 @@ async function createEditorServer({
768
775
  `<script>${INDENT_CLAMP_SRC}</script>\n` +
769
776
  `<script>${CONVERT_MD_SRC}</script>\n` +
770
777
  `<script>${BLOCK_CATALOG_SRC}</script>\n` +
778
+ `<script>${CODE_EDIT_SRC}</script>\n` +
771
779
  `<script>${PATCHMAP_SRC}</script>\n` +
772
780
  `<script>${SELECTION_SRC}</script>\n` +
773
781
  `<script>${CELL_RANGE_SRC}</script>\n` +
@@ -826,6 +834,26 @@ async function createEditorServer({
826
834
  return send(res, 200, { parts, blocks });
827
835
  }
828
836
 
837
+ // v3.17.0 (D11, R31): the stateless Graphviz preview for the diagram
838
+ // panel. Deliberately NOT /api/render: that route re-reads the document
839
+ // and records its draw.io references for the staleness heartbeat; a
840
+ // preview of an unsaved dot source must touch neither.
841
+ if (req.method === 'POST' && url.pathname === '/api/preview') {
842
+ const body = await readJson(req, 1024 * 1024);
843
+ const fileId = body ? body.fileId : undefined;
844
+ if (!Number.isInteger(fileId) || fileId < 0 || fileId >= absFiles.length) {
845
+ return send(res, 400, { error: 'fileId must be an index into the open file list' });
846
+ }
847
+ if (body.lang !== 'dot') return send(res, 400, { error: 'lang must be dot' });
848
+ if (typeof body.source !== 'string') return send(res, 400, { error: 'source must be a string' });
849
+ try {
850
+ return send(res, 200, { svg: await renderDotSvg(body.source) });
851
+ } catch (e) {
852
+ const first = String((e && e.message) || e).split('\n')[0];
853
+ return send(res, 422, { error: first });
854
+ }
855
+ }
856
+
829
857
  if (req.method === 'POST' && url.pathname === '/api/save') {
830
858
  const { fileId, content, baseMtimeMs } = await readJson(req);
831
859
  const file = absFiles[fileId];
@@ -2503,9 +2503,11 @@ function sameLane(a, b) {
2503
2503
  * Paint one cycle of one lane, addressed by display index.
2504
2504
  *
2505
2505
  * `ch` is a single character: a level, a value character, `|` for a gap, or `.`
2506
- * to make the cycle continue the one in front of it. Anything else — a longer
2507
- * string, a cycle outside the lane, a `.` or `|` at cycle 0 where there is
2508
- * nothing to continue or to draw over — answers the doc unchanged.
2506
+ * to make the cycle continue the one in front of it. A cycle past the lane's
2507
+ * end grows the lane first, continuing its last level up to that cycle (see
2508
+ * `paint`). Anything else — a longer string, a negative or fractional cycle, a
2509
+ * `.` or `|` at cycle 0 where there is nothing to continue or to draw over —
2510
+ * answers the doc unchanged.
2509
2511
  *
2510
2512
  * A newly painted value cell keeps the label of the value cell it replaced and
2511
2513
  * otherwise starts blank; a cell painted with something that is not a value
@@ -2542,12 +2544,20 @@ function paint(doc, laneIndex, from, to, ch, asRun) {
2542
2544
  lo = hi;
2543
2545
  hi = swap;
2544
2546
  }
2545
- if (!isIndex(lo, n) || !isIndex(hi, n)) return null;
2547
+ if (lo < 0) return null;
2546
2548
  // A repeater at cycle 0 has nothing to repeat, and the engine answers that
2547
2549
  // by drawing the run and the cycle after it as x. Refusing is the honest
2548
2550
  // reply to a brush that cannot mean anything where it was aimed.
2549
2551
  if (lo === 0 && (ch === HELD_CHAR || ch === GAP_CHAR)) return null;
2550
- const anchored = anchorAfter(model.cells, hi);
2552
+ // Past the lane's end: continue its last level up to `hi` first, then
2553
+ // paint. A lane shorter than the diagram is ordinary (新增訊號 writes one
2554
+ // cycle, hand-written lanes differ in length) and the editor's grid is as
2555
+ // wide as the whole diagram, so `geometry.cellAt` hands out these cells on
2556
+ // purpose; refusing them here left every click past a short lane's end
2557
+ // silently doing nothing. How far past is bounded by the caller (the
2558
+ // grid), not here.
2559
+ const grown = hi < n ? model.cells : model.cells.concat(fillerCells(model.cells, n, hi + 1 - n));
2560
+ const anchored = anchorAfter(grown, hi);
2551
2561
  const before = resolveCells(anchored);
2552
2562
  const heldLevel = lo > 0 ? before.levels[lo - 1] : '';
2553
2563
  const cells = anchored.slice();
@@ -1690,6 +1690,14 @@
1690
1690
  if (added) notify('notice', lead + where);
1691
1691
  return added;
1692
1692
  }
1693
+ /** The lane 新增訊號 writes: `x` across the whole diagram, not one cycle
1694
+ * of it. A one-cycle lane draws a single x and then nothing, which read
1695
+ * as a broken signal (user report 2026-10-28), and every cycle after it
1696
+ * had to be grown by painting before it showed anything. */
1697
+ function newSignalLane() {
1698
+ const cycles = layoutNow().cycles;
1699
+ return { name: '', wave: 'x' + '.'.repeat(Math.max(0, cycles - 1)) };
1700
+ }
1693
1701
  function addLaneFromToolbar(name, lane) {
1694
1702
  addLaneAt(signalInsertAt(), name, lane);
1695
1703
  }
@@ -1705,7 +1713,7 @@
1705
1713
  const el = panels.menu('ed-wave-signal-pop', [
1706
1714
  { key: 'ed-wave-signal-add', label: '新增訊號',
1707
1715
  title: '在選取的 lane 後面加一條(沒有選取就加在最前面)—— 會落在' + where,
1708
- run: function () { closePopover(true); addLaneFromToolbar('add-lane', { name: '', wave: 'x' }); } },
1716
+ run: function () { closePopover(true); addLaneFromToolbar('add-lane', newSignalLane()); } },
1709
1717
  { key: 'ed-wave-signal-blank', label: '新增空白列',
1710
1718
  title: '在選取的 lane 後面插入一個空白列(WaveJSON 的 {},用來空出間距)—— 會落在' + where,
1711
1719
  run: function () { closePopover(true); addLaneFromToolbar('add-spacer', {}); } },
@@ -1950,7 +1958,7 @@
1950
1958
  const row = layoutNow().lanes[i];
1951
1959
  const actions = {
1952
1960
  copy: picked(function () { duplicateLaneAt(i); }),
1953
- addBelow: picked(function () { addLaneAt(i + 1, 'add-lane', { name: '', wave: 'x' }); }),
1961
+ addBelow: picked(function () { addLaneAt(i + 1, 'add-lane', newSignalLane()); }),
1954
1962
  blankBelow: picked(function () { addLaneAt(i + 1, 'add-spacer', {}); }),
1955
1963
  up: picked(function () { moveLaneBy(i, -1); }),
1956
1964
  down: picked(function () { moveLaneBy(i, 1); }),
@@ -2473,7 +2481,7 @@
2473
2481
  // signal at the end — the one insert position the ⠿ menus (which add
2474
2482
  // BELOW a row) and 訊號 (after the selection) do not make obvious.
2475
2483
  panels.toolButton('ed-wave-add-lane', '+ 新增訊號', '在最後面加一條訊號', function () {
2476
- addLaneAt(layoutNow().lanes.length, 'add-lane', { name: '', wave: 'x' });
2484
+ addLaneAt(layoutNow().lanes.length, 'add-lane', newSignalLane());
2477
2485
  }, stage);
2478
2486
  // One canvas per editor session (spec §3.2); `close()` destroys it.
2479
2487
  engineCanvas = opts.canvas.createCanvas({
package/lib/highlight.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // text rather than guessing.
6
6
  const hljs = require('highlight.js/lib/core');
7
7
 
8
- const LANGS = {
8
+ const LANG_DEFS = {
9
9
  verilog: require('highlight.js/lib/languages/verilog'),
10
10
  python: require('highlight.js/lib/languages/python'),
11
11
  bash: require('highlight.js/lib/languages/bash'),
@@ -19,9 +19,16 @@ const LANGS = {
19
19
  diff: require('highlight.js/lib/languages/diff'),
20
20
  plaintext: require('highlight.js/lib/languages/plaintext'),
21
21
  };
22
- for (const [name, def] of Object.entries(LANGS)) hljs.registerLanguage(name, def);
23
- hljs.registerAliases(['systemverilog', 'sv', 'v'], { languageName: 'verilog' });
24
- hljs.registerAliases(['sh', 'shell', 'zsh'], { languageName: 'bash' });
22
+ for (const [name, def] of Object.entries(LANG_DEFS)) hljs.registerLanguage(name, def);
23
+ // The two custom alias lists, flat: alias -> language. Registered below and
24
+ // replayed verbatim by browserBundle().
25
+ const ALIASES = {};
26
+ for (const [target, list] of [['verilog', ['systemverilog', 'sv', 'v']], ['bash', ['sh', 'shell', 'zsh']]]) {
27
+ hljs.registerAliases(list, { languageName: target });
28
+ for (const a of list) ALIASES[a] = target;
29
+ }
30
+ // The registered ids, in registration order (the editor's language menu).
31
+ const LANGS = Object.keys(LANG_DEFS);
25
32
 
26
33
  // `lang` is the fence's info string; only its first word names the language,
27
34
  // case-insensitively. Returns escaped, coloured HTML, or null when the
@@ -32,4 +39,38 @@ function highlightCode(code, lang) {
32
39
  return hljs.highlight(code, { language: name, ignoreIllegals: true }).value;
33
40
  }
34
41
 
35
- module.exports = { highlightCode };
42
+ // v3.17.0 D6: the same highlighter as one script for the edit page, served on
43
+ // demand at /api/lib/highlight.js. highlight.js core and each registered
44
+ // language wrapped in a CommonJS shim, then the same registration and the same
45
+ // highlightCode semantics. No closing script tag may appear in it (the page
46
+ // loads it as a script; a test guards that). Built once.
47
+ let bundle = null;
48
+ function browserBundle() {
49
+ if (bundle !== null) return bundle;
50
+ const fs = require('fs');
51
+ const wrap = (file) => '(function () { var module = { exports: {} }; var exports = module.exports;\n' +
52
+ fs.readFileSync(require.resolve(file), 'utf8') + '\nreturn module.exports; })()';
53
+ const parts = [
54
+ '(function () {',
55
+ 'var hljs = ' + wrap('highlight.js/lib/core') + ';',
56
+ 'var defs = {',
57
+ LANGS.map((n) => JSON.stringify(n) + ': ' + wrap('highlight.js/lib/languages/' + n)).join(',\n'),
58
+ '};',
59
+ 'var LANGS = ' + JSON.stringify(LANGS) + ';',
60
+ 'var ALIASES = ' + JSON.stringify(ALIASES) + ';',
61
+ 'LANGS.forEach(function (n) { hljs.registerLanguage(n, defs[n]); });',
62
+ 'Object.keys(ALIASES).forEach(function (a) { hljs.registerAliases([a], { languageName: ALIASES[a] }); });',
63
+ 'function highlightCode(code, lang) {',
64
+ " var name = String(lang || '').trim().split(/\\s+/)[0].toLowerCase();",
65
+ ' if (!name || !hljs.getLanguage(name)) return null;',
66
+ " return hljs.highlight(code, { language: name, ignoreIllegals: true }).value;",
67
+ '}',
68
+ "var root = typeof window !== 'undefined' ? window : self;",
69
+ 'root.md2docHljs = { highlightCode: highlightCode, LANGS: LANGS };',
70
+ '})();',
71
+ ];
72
+ bundle = parts.join('\n');
73
+ return bundle;
74
+ }
75
+
76
+ module.exports = { highlightCode, LANGS, ALIASES, browserBundle };