@helping-ai-workflow/md2doc 3.16.0 → 3.17.0

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];
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 };