@helping-ai-workflow/md2doc 3.9.1 → 3.11.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.
@@ -91,19 +91,24 @@ function stripMarker(sourceLines, blockKind) {
91
91
  if (blockKind === 'li') {
92
92
  const m = src[0].match(LI_MARKER);
93
93
  if (!m) return { content: [], ok: false };
94
- // A MULTI-LINE li refuses. The previous code flattened continuations with
95
- // replace(/^\s+/, ''), which is exactly the guess the code branch below
96
- // refuses to make for an indented code block -- and it silently destroys
97
- // whatever structure the continuation carried (a nested fence, an indented
98
- // sub-block). Today the \u00a74.1 gate refuses a multi-line li as an operation
99
- // target before this is ever reached, so this is defence in depth; dead
100
- // code in this repo has a history of later gaining a caller, and it must
101
- // not be a mine when it does.
102
- if (src.length > 1) return { content: [], ok: false };
103
94
  let first = src[0].slice(m[0].length);
104
95
  const box = first.match(TASK_BOX);
105
96
  if (box) first = first.slice(box[0].length);
106
- return { content: [first], ok: true };
97
+ // v3.11: a MULTI-LINE li (a hard-wrapped item, or a hard break) converts
98
+ // too. Its continuation lines belong to the item's own paragraph — any
99
+ // nested block would have made the run unsupported and refused before
100
+ // this — so each loses exactly the item's CONTENT column (the marker's
101
+ // width: anything deeper keeps its relative indent) or, on a lazy line
102
+ // indented less than that, all of its leading whitespace. That is not the
103
+ // old blanket replace(/^\s+/, '') the code branch below refuses to guess
104
+ // with: an over-indented line keeps its extra columns.
105
+ const col = m[0].length;
106
+ const rest = src.slice(1).map((l) => {
107
+ let k = 0;
108
+ while (k < col && l.charAt(k) === ' ') k++;
109
+ return k === col ? l.slice(col) : l.replace(/^\s+/, '');
110
+ });
111
+ return { content: [first].concat(rest), ok: true };
107
112
  }
108
113
 
109
114
  if (blockKind === 'heading') {
@@ -160,7 +165,9 @@ function emitAs(content, target, opts) {
160
165
  // A heading is one line by definition. Joining is the only lossless move
161
166
  // available; splitting would make one gesture produce two blocks.
162
167
  const hashes = '#'.repeat(Number(target.slice(1)));
163
- const text = body.join(' ');
168
+ // A multi-line body (a converted hard-wrapped item) becomes one heading
169
+ // line; trailing hard-break spaces would otherwise land mid-heading.
170
+ const text = body.length > 1 ? body.map((l) => l.trim()).join(' ') : body.join(' ');
164
171
  // An EMPTY heading emits no trailing space, or converting '##' to a
165
172
  // heading of the same level would rewrite the line to '## '.
166
173
  return [text === '' ? prefix + hashes : prefix + hashes + ' ' + text];
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+ /* Lucide icons v0.469.0 (ISC License, https://lucide.dev/license), only the ones
3
+ the editor draws. UMD like toolbar-model.js: require()-able in node for the
4
+ tests, injected into the edit page as window.md2docIcons (lib/editor/server.js). */
5
+ (function (root, factory) {
6
+ if (typeof module === 'object' && module.exports) module.exports = factory();
7
+ else root.md2docIcons = factory();
8
+ })(typeof self !== 'undefined' ? self : this, function () {
9
+ const BODY = {
10
+ "arrow-down-to-line": "<path d=\"M12 17V3\" /><path d=\"m6 11 6 6 6-6\" /><path d=\"M19 21H5\" />",
11
+ "arrow-up-to-line": "<path d=\"M5 3h14\" /><path d=\"m18 13-6-6-6 6\" /><path d=\"M12 7v14\" />",
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
+ "check": "<path d=\"M20 6 9 17l-5-5\" />",
14
+ "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
+ "code": "<polyline points=\"16 18 22 12 16 6\" /><polyline points=\"8 6 2 12 8 18\" />",
16
+ "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
+ "heading": "<path d=\"M6 12h12\" /><path d=\"M6 20V4\" /><path d=\"M18 20V4\" />",
18
+ "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\" />",
19
+ "indent-decrease": "<path d=\"M21 12H11\" /><path d=\"M21 18H11\" /><path d=\"M21 6H11\" /><path d=\"m7 8-4 4 4 4\" />",
20
+ "indent-increase": "<path d=\"M21 12H11\" /><path d=\"M21 18H11\" /><path d=\"M21 6H11\" /><path d=\"m3 8 4 4-4 4\" />",
21
+ "info": "<circle cx=\"12\" cy=\"12\" r=\"10\" /><path d=\"M12 16v-4\" /><path d=\"M12 8h.01\" />",
22
+ "italic": "<line x1=\"19\" x2=\"10\" y1=\"4\" y2=\"4\" /><line x1=\"14\" x2=\"5\" y1=\"20\" y2=\"20\" /><line x1=\"15\" x2=\"9\" y1=\"4\" y2=\"20\" />",
23
+ "link": "<path d=\"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71\" /><path d=\"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71\" />",
24
+ "list": "<path d=\"M3 12h.01\" /><path d=\"M3 18h.01\" /><path d=\"M3 6h.01\" /><path d=\"M8 12h13\" /><path d=\"M8 18h13\" /><path d=\"M8 6h13\" />",
25
+ "list-checks": "<path d=\"m3 17 2 2 4-4\" /><path d=\"m3 7 2 2 4-4\" /><path d=\"M13 6h8\" /><path d=\"M13 12h8\" /><path d=\"M13 18h8\" />",
26
+ "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
+ "minus": "<path d=\"M5 12h14\" />",
28
+ "panel-left": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" /><path d=\"M9 3v18\" />",
29
+ "plus": "<path d=\"M5 12h14\" /><path d=\"M12 5v14\" />",
30
+ "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
+ "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\" />",
32
+ "save": "<path d=\"M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z\" /><path d=\"M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7\" /><path d=\"M7 3v4a1 1 0 0 0 1 1h7\" />",
33
+ "square-code": "<path d=\"M10 9.5 8 12l2 2.5\" /><path d=\"m14 9.5 2 2.5-2 2.5\" /><rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" />",
34
+ "strikethrough": "<path d=\"M16 4H9a3 3 0 0 0-2.83 4\" /><path d=\"M14 12a4 4 0 0 1 0 8H6\" /><line x1=\"4\" x2=\"20\" y1=\"12\" y2=\"12\" />",
35
+ "table": "<path d=\"M12 3v18\" /><rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" /><path d=\"M3 9h18\" /><path d=\"M3 15h18\" />",
36
+ "underline": "<path d=\"M6 4v6a6 6 0 0 0 12 0V4\" /><line x1=\"4\" x2=\"20\" y1=\"20\" y2=\"20\" />",
37
+ "undo-2": "<path d=\"M9 14 4 9l5-5\" /><path d=\"M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11\" />",
38
+ "x": "<path d=\"M18 6 6 18\" /><path d=\"m6 6 12 12\" />"
39
+ };
40
+ const NAMES = Object.freeze(Object.keys(BODY));
41
+ function svg(name, size) {
42
+ const s = size || 16;
43
+ return '<svg class="ed-ico" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="' + s + '" height="' + s + '" ' +
44
+ 'fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" ' +
45
+ 'aria-hidden="true" focusable="false">' + BODY[name] + '</svg>';
46
+ }
47
+ return { NAMES, svg };
48
+ });
@@ -106,6 +106,14 @@
106
106
 
107
107
  function serializeAnchor(node, unsupported) {
108
108
  const href = node.getAttribute('href');
109
+ // A raw HTML anchor (`<a id="ref-x"></a>`, NO href attribute) is not a
110
+ // markdown link: emitting '[label]()' for it erased the anchor on the
111
+ // first edit of its block. Unsupported, so the block keeps its source.
112
+ // An empty href ('' — from '[x]()') is still a link.
113
+ if (href === null) {
114
+ unsupported.push('A');
115
+ return '';
116
+ }
109
117
  const text = node.textContent;
110
118
  // citation: <a href="#slug">[body]</a> -> [[body]]
111
119
  if (href && href.charAt(0) === '#' && /^\[.*\]$/.test(text)) {
@@ -450,6 +450,15 @@
450
450
  // replace from rewriting lines the user never looked at.
451
451
  function serializeBlocks(blockEls, opts) {
452
452
  const carryOver = (opts && opts.carryOver) || null;
453
+ // v3.11 (batch-2 §6): `opts.blankBefore(blockEl, prevItemEl)` — true when a
454
+ // blank line separates this item from the item emitted before it. The
455
+ // CALLER decides (client.js reads it off the file: a loose list keeps the
456
+ // blank lines it had, and a new neighbour takes the spacing of the list it
457
+ // joins). The blank line gets a lineMeta entry with blockId null and
458
+ // `blank: true`, so
459
+ // `lineMeta.length === md.split('\n').length` still holds.
460
+ const blankBefore = (opts && typeof opts.blankBefore === 'function') ? opts.blankBefore : null;
461
+ let prevItemEl = null;
453
462
  const unsupported = [];
454
463
  const unsupportedByLi = [];
455
464
  const multiLineBlockIds = [];
@@ -586,8 +595,16 @@
586
595
  // keep. (isBlankBlockText() stays as-is for the block's own direct
587
596
  // children, where the template's inter-element whitespace can legally
588
597
  // be a single space with no newline.)
589
- const inlineKids = [];
598
+ let inlineKids = [];
590
599
  allChildNodes(textEl).forEach((c) => { if (!isBlankText(c)) inlineKids.push(c); });
600
+ // v3.11 (batch-2 §6): a LOOSE item's surface is one <p> — its content
601
+ // is that paragraph's, and the blank lines that made the list loose
602
+ // are restated between items (opts.blankBefore below), not carried by
603
+ // the <p>. Two or more <p> (a multi-paragraph item) still reach the
604
+ // inline serializer whole and stay unsupported.
605
+ if (inlineKids.length === 1 && inlineKids[0].nodeType === 1 && inlineKids[0].nodeName === 'P') {
606
+ inlineKids = allChildNodes(inlineKids[0]);
607
+ }
591
608
  const res = inlineMd.serializeInline({ childNodes: inlineKids });
592
609
  itemMd = res.md;
593
610
  res.unsupported.forEach((u) => innerUnsupported.push(u));
@@ -709,6 +726,14 @@
709
726
  // Round 6: join an open task prefix (above) to this line, dropping the
710
727
  // columns it already wrote; then, if THIS item is itself a content-free
711
728
  // task item, become the prefix instead of emitting.
729
+ // A line joined onto an open task prefix is the same physical line, so
730
+ // it never takes a blank in front of it.
731
+ const blankHere = !pending && lines.length > 0 && !!blankBefore && !!blankBefore(blockEl, prevItemEl);
732
+ prevItemEl = blockEl;
733
+ if (blankHere) {
734
+ lines.push('');
735
+ lineMeta.push({ blockId: null, indentPrefix: '', marker: '', blank: true });
736
+ }
712
737
  let head = indentPrefix + marker;
713
738
  // `indentPrefix` in lineMeta is the text PHYSICALLY standing before this
714
739
  // line's own marker. Normally that is the accumulated width; on a line a
@@ -43,12 +43,12 @@ const SELECTION_SRC = fs.readFileSync(path.join(__dirname, 'selection.js'), 'utf
43
43
  // client.js reads window.md2docCellRange.
44
44
  const CELL_RANGE_SRC = fs.readFileSync(path.join(__dirname, 'cell-range.js'), 'utf8');
45
45
  // v3.1.0 Task E: the four Phase-1 modules behind the visible toolbar, the
46
- // paste pipeline, the image-drop asset endpoint and the whole-document
47
- // source escape hatch. Same "pure module, no dependency on any other editor
46
+ // paste pipeline and the image-drop asset endpoint (the whole-document
47
+ // source escape hatch went with v3.10.0). Same "pure module, no dependency on any other editor
48
48
  // module" property as indent-clamp.js / convert-md.js / selection.js above —
49
49
  // each only has to land before client.js reads its window global.
50
50
  const TOOLBAR_MODEL_SRC = fs.readFileSync(path.join(__dirname, 'toolbar-model.js'), 'utf8');
51
- const DOCSOURCE_SRC = fs.readFileSync(path.join(__dirname, 'docsource.js'), 'utf8');
51
+ const ICONS_SRC = fs.readFileSync(path.join(__dirname, 'icons.js'), 'utf8');
52
52
  const ASSET_SRC = fs.readFileSync(path.join(__dirname, 'asset.js'), 'utf8');
53
53
  // turndown's BROWSER build. node's `require('turndown')` resolves to
54
54
  // lib/turndown.cjs.js, which is not loadable as a plain <script> tag — the
@@ -736,8 +736,8 @@ async function createEditorServer({
736
736
  `<script>${PATCHMAP_SRC}</script>\n` +
737
737
  `<script>${SELECTION_SRC}</script>\n` +
738
738
  `<script>${CELL_RANGE_SRC}</script>\n` +
739
+ `<script>${ICONS_SRC}</script>\n` +
739
740
  `<script>${TOOLBAR_MODEL_SRC}</script>\n` +
740
- `<script>${DOCSOURCE_SRC}</script>\n` +
741
741
  `<script>${ASSET_SRC}</script>\n` +
742
742
  `<script>${TURNDOWN_SRC}</script>\n` +
743
743
  `<script>${PASTE_MD_SRC}</script>\n` +
@@ -982,6 +982,16 @@ async function createEditorServer({
982
982
  } catch (e) {
983
983
  fileId = undefined; drawioClientId = undefined; drawioAck = undefined;
984
984
  }
985
+ // v3.10.0: the client compares this with its own baseline to notice an
986
+ // edit made outside the browser. A header, not a body: the bare 204
987
+ // ("nothing stale") is part of the drawio contract and stays bodiless.
988
+ const pingFile = (typeof fileId === 'number') ? absFiles[fileId] : undefined;
989
+ // Async on purpose: this route fires every 10s from every open tab, and
990
+ // the drawio fast path below is pinned to ZERO fs.statSync calls for a
991
+ // document with no drawio references (test/editor-server.test.js (a)).
992
+ if (pingFile) {
993
+ try { res.setHeader('X-Md2doc-Mtime', String((await fs.promises.stat(pingFile)).mtimeMs)); } catch (e) { /* file gone: no header */ }
994
+ }
985
995
  // Ack FIRST, then re-check: a client that has just confirmed it
986
996
  // applied version N must get a 204 on this same beat unless the file
987
997
  // has moved on past N since.
@@ -11,53 +11,47 @@
11
11
  else root.md2docToolbarModel = factory();
12
12
  })(typeof self !== 'undefined' ? self : this, function () {
13
13
 
14
- // The 23-button roster, spec §4 table (`export▾` moved out of scope — no
15
- // browser-reachable export path) plus the v3.4.0 §3 `save` button. Seven
14
+ // The 22-button roster, spec §4 table (`export▾` moved out of scope — no
15
+ // browser-reachable export path) plus the v3.4.0 §3 `save` button.
16
+ // v3.10.0 removed the edit/source mode button with source mode itself. Seven
16
17
  // groups, in render order; group order here doubles as separator placement
17
18
  // for the caller (a separator sits between consecutive groups).
18
19
  const BUTTON_DEFS = [
19
20
  // file
20
- { id: 'save', group: 'file', label: '💾', icon: '💾', title: '儲存 (Ctrl+S)', toggle: false },
21
+ { id: 'save', group: 'file', label: '💾', icon: 'save', title: '儲存 (Ctrl+S)', toggle: false },
21
22
 
22
23
  // history
23
- { id: 'undo', group: 'history', label: 'Undo', icon: '↶', title: '復原 (Ctrl+Z)', toggle: false },
24
- { id: 'redo', group: 'history', label: 'Redo', icon: '↷', title: '重做 (Ctrl+Y)', toggle: false },
24
+ { id: 'undo', group: 'history', label: 'Undo', icon: 'undo-2', title: '復原 (Ctrl+Z)', toggle: false },
25
+ { id: 'redo', group: 'history', label: 'Redo', icon: 'redo-2', title: '重做 (Ctrl+Y)', toggle: false },
25
26
 
26
27
  // block
27
- { id: 'headings', group: 'block', label: 'H', icon: 'H', title: '標題', toggle: true },
28
- { id: 'quote', group: 'block', label: '❝', icon: '❝', title: '引用', toggle: true },
29
- { id: 'code', group: 'block', label: '</>', icon: '</>', title: '程式碼區塊', toggle: true },
30
- { id: 'list', group: 'block', label: '•', icon: '•', title: '項目符號列表', toggle: true },
31
- { id: 'ordered-list', group: 'block', label: '1.', icon: '1.', title: '編號列表', toggle: true },
32
- { id: 'check', group: 'block', label: '☑', icon: '☑', title: '待辦清單', toggle: false },
28
+ { id: 'headings', group: 'block', label: 'H', icon: 'heading', title: '標題', toggle: true },
29
+ { id: 'quote', group: 'block', label: '❝', icon: 'quote', title: '引用', toggle: true },
30
+ { id: 'code', group: 'block', label: '</>', icon: 'square-code', title: '程式碼區塊', toggle: true },
31
+ { id: 'list', group: 'block', label: '•', icon: 'list', title: '項目符號列表', toggle: true },
32
+ { id: 'ordered-list', group: 'block', label: '1.', icon: 'list-ordered', title: '編號列表', toggle: true },
33
+ { id: 'check', group: 'block', label: '☑', icon: 'list-checks', title: '待辦清單', toggle: false },
33
34
 
34
35
  // inline
35
- { id: 'bold', group: 'inline', label: 'B', icon: 'B', title: '粗體', toggle: true },
36
- { id: 'italic', group: 'inline', label: 'I', icon: 'I', title: '斜體', toggle: true },
37
- { id: 'strike', group: 'inline', label: 'S', icon: 'S', title: '刪除線', toggle: true },
38
- { id: 'inline-code', group: 'inline', label: '`', icon: '`', title: '行內程式碼', toggle: true },
39
- { id: 'link', group: 'inline', label: '🔗', icon: '🔗', title: '連結', toggle: true },
36
+ { id: 'bold', group: 'inline', label: 'B', icon: 'bold', title: '粗體', toggle: true },
37
+ { id: 'italic', group: 'inline', label: 'I', icon: 'italic', title: '斜體', toggle: true },
38
+ { id: 'strike', group: 'inline', label: 'S', icon: 'strikethrough', title: '刪除線', toggle: true },
39
+ { id: 'inline-code', group: 'inline', label: '`', icon: 'code', title: '行內程式碼', toggle: true },
40
+ { id: 'link', group: 'inline', label: '🔗', icon: 'link', title: '連結', toggle: true },
40
41
 
41
42
  // indent
42
- { id: 'outdent', group: 'indent', label: '⇤', icon: '⇤', title: '減少縮排', toggle: false },
43
- { id: 'indent', group: 'indent', label: '⇥', icon: '⇥', title: '增加縮排', toggle: false },
43
+ { id: 'outdent', group: 'indent', label: '⇤', icon: 'indent-decrease', title: '減少縮排', toggle: false },
44
+ { id: 'indent', group: 'indent', label: '⇥', icon: 'indent-increase', title: '增加縮排', toggle: false },
44
45
 
45
46
  // insert
46
- { id: 'table', group: 'insert', label: '⊞', icon: '⊞', title: '插入表格', toggle: false },
47
- { id: 'insert-before', group: 'insert', label: '⬆', icon: '⬆', title: '在上方插入區塊', toggle: false },
48
- { id: 'insert-after', group: 'insert', label: '⬇', icon: '⬇', title: '在下方插入區塊', toggle: false },
49
- { id: 'line', group: 'insert', label: '―', icon: '―', title: '插入分隔線', toggle: false },
50
- { id: 'image', group: 'insert', label: '🖼', icon: '🖼', title: '插入圖片', toggle: false },
47
+ { id: 'table', group: 'insert', label: '⊞', icon: 'table', title: '插入表格', toggle: false },
48
+ { id: 'insert-before', group: 'insert', label: '⬆', icon: 'arrow-up-to-line', title: '在上方插入區塊', toggle: false },
49
+ { id: 'insert-after', group: 'insert', label: '⬇', icon: 'arrow-down-to-line', title: '在下方插入區塊', toggle: false },
50
+ { id: 'line', group: 'insert', label: '―', icon: 'minus', title: '插入分隔線', toggle: false },
51
+ { id: 'image', group: 'insert', label: '🖼', icon: 'image', title: '插入圖片', toggle: false },
51
52
 
52
53
  // view
53
- { id: 'outline', group: 'view', label: '☰', icon: '☰', title: '大綱', toggle: false },
54
- // v3.2.1: 這顆是 edit <-> source 的兩態切換(preview 模式已移除)。id 保留
55
- // 為 'preview' 是刻意的 —— 每一支選擇器、journey 案例與 runtime 測試都指名
56
- // 它,改名只換得一個更好聽的字串。label / title 不是烘死值:deriveState()
57
- // 每次都依當前 mode 覆寫成【下一個】狀態的名字,這裡的值只是 edit 模式的
58
- // 起始值。toggle: false —— 兩態下 aria-pressed 講不出真話(按鈕講的是去處,
59
- // 不是目前所在),真話由會變的 label / title 承擔。
60
- { id: 'preview', group: 'view', label: 'M↓', icon: 'M↓', title: '切換到原始碼模式', toggle: false },
54
+ { id: 'outline', group: 'view', label: '☰', icon: 'panel-left', title: '大綱', toggle: false },
61
55
  ];
62
56
 
63
57
  const BUTTONS = Object.freeze(BUTTON_DEFS.map((b) => Object.freeze(Object.assign({}, b))));
@@ -77,7 +71,7 @@ const GROUPS = Object.freeze(['file', 'history', 'block', 'inline', 'indent', 'i
77
71
  // `!dirty` line happens to sit; if that line is ever moved above the
78
72
  // no-block loop, this set becomes the only thing still enforcing it. Do not
79
73
  // delete it as "dead code" without re-checking that ordering.
80
- const NO_BLOCK_ALLOWED = new Set(['undo', 'redo', 'outline', 'preview', 'save']);
74
+ const NO_BLOCK_ALLOWED = new Set(['undo', 'redo', 'outline', 'save']);
81
75
 
82
76
  // The mark element each inline button toggles. This module cannot ask the
83
77
  // DOM which marks a selection sits in, so the client measures that and hands
@@ -107,7 +101,7 @@ const MARK_IDS = Object.freeze(Object.keys(MARK_TAG_BY_ID));
107
101
  // 'inert' pressing the button would do nothing at all
108
102
 
109
103
  // ctx: {blockType, indent, headingDepth, inList, listOrdered, hasSelection,
110
- // mode, marks}
104
+ // marks, dirty}
111
105
  // -> { [id]: {active: bool, mixed: bool, disabled: bool, label?: string} }
112
106
  function deriveState(ctx) {
113
107
  const c = ctx || {};
@@ -116,7 +110,6 @@ function deriveState(ctx) {
116
110
  const headingDepth = c.headingDepth;
117
111
  const inList = !!c.inList;
118
112
  const listOrdered = !!c.listOrdered;
119
- const mode = c.mode;
120
113
  const dirty = !!c.dirty;
121
114
  const hasBlock = blockType !== null && blockType !== undefined;
122
115
 
@@ -125,19 +118,6 @@ function deriveState(ctx) {
125
118
  state[b.id] = { active: false, mixed: false, disabled: false };
126
119
  }
127
120
 
128
- // v3.2.1: the mode button names the NEXT state, never the current one.
129
- // Under the old tri-state cycle it was permanently labelled 「預覽」 and
130
- // reported aria-pressed=true in BOTH non-edit modes, so pressing it twice
131
- // landed in source with no grips and an interface that said nothing about
132
- // where you were — the reported "stuck" defect. It carries no `active`
133
- // now: with two states the honest signal is the label, and the current
134
- // mode is spelled out in .ed-toolbar-status by the client.
135
- // It stays enabled in source mode via the mode==='source' override below
136
- // (do not gate this on inList/hasBlock — it is the escape hatch OUT of
137
- // those states).
138
- state.preview.label = mode === 'source' ? '✎' : 'M↓';
139
- state.preview.title = mode === 'source' ? '切換到編輯模式' : '切換到原始碼模式';
140
-
141
121
  // headings: dropdown whose label tracks the current heading depth.
142
122
  state.headings.label = blockType === 'heading' ? 'H' + headingDepth : 'H';
143
123
  state.headings.active = blockType === 'heading';
@@ -231,41 +211,8 @@ function deriveState(ctx) {
231
211
  // single-sourced: client.js's `documentIsDirty()` — since v3.4.0 batch3
232
212
  // Task 7 that is `stack.isDirty() || burstHasUncommittedEdit() ||
233
213
  // waveUnwrittenEdit` — arrives as ctx.dirty and is not recomputed here.
234
- // This runs before the source-mode override below so that override can
235
- // still give save its own exemption.
236
214
  state.save.disabled = !dirty;
237
215
 
238
- // mode 'source' is the full-document raw-source escape hatch: everything
239
- // is disabled except the mode button that got you there (id 'preview',
240
- // which in v3.2.1 is the edit <-> source toggle), since T11-2 'outline'
241
- // (☰), and since v3.4.0 §3 'save'. The mode button is still the ONLY way
242
- // back to 'edit' — that has not changed. 'outline' stays reachable because
243
- // it is what opens the sidebar drawer (toggleOutlineSidebar() in
244
- // lib/editor/client.js), and the fixed `.sidebar-toggle` button that used
245
- // to reach the drawer another way is hidden in edit mode entirely (see the
246
- // T11-2 comment in lib/md2doc.js) — a disabled ☰ here would leave source
247
- // mode with no way to open that drawer at all. 'save' stays reachable
248
- // because source mode can still hold unsaved changes (the user can type
249
- // into the source textarea) — see the per-button comment below for the
250
- // exact shape of the exemption. This override still runs last and still
251
- // overrides every disabled flag computed above for every other button; do
252
- // not narrow it for anything but these three ids.
253
- if (mode === 'source') {
254
- for (const b of BUTTONS) {
255
- if (b.id === 'preview' || b.id === 'outline') { state[b.id].disabled = false; continue; }
256
- // v3.4.0 §3: save is a third exemption, for the same shape of reason
257
- // outline already had one — source mode can still hold unsaved
258
- // changes (the user can type into the source textarea), so forcing
259
- // the only save button grey here would make a mode that holds
260
- // unsaved work with no way out of it. The exemption is from the MODE
261
- // forcing it grey, not from being grey — a clean document still shows
262
- // it disabled in source mode (see the `state.save.disabled = !dirty`
263
- // line above, which already ran and is left untouched here).
264
- if (b.id === 'save') continue;
265
- state[b.id].disabled = true;
266
- }
267
- }
268
-
269
216
  return state;
270
217
  }
271
218
 
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+ // v3.10.0: syntax colour for fenced code, done once when the HTML is written —
3
+ // the reading page needs no script for it, and PDF gets it for free. Only the
4
+ // languages below are registered; anything else (or no language) stays plain
5
+ // text rather than guessing.
6
+ const hljs = require('highlight.js/lib/core');
7
+
8
+ const LANGS = {
9
+ verilog: require('highlight.js/lib/languages/verilog'),
10
+ python: require('highlight.js/lib/languages/python'),
11
+ bash: require('highlight.js/lib/languages/bash'),
12
+ c: require('highlight.js/lib/languages/c'),
13
+ cpp: require('highlight.js/lib/languages/cpp'),
14
+ json: require('highlight.js/lib/languages/json'),
15
+ yaml: require('highlight.js/lib/languages/yaml'),
16
+ tcl: require('highlight.js/lib/languages/tcl'),
17
+ makefile: require('highlight.js/lib/languages/makefile'),
18
+ javascript: require('highlight.js/lib/languages/javascript'),
19
+ diff: require('highlight.js/lib/languages/diff'),
20
+ plaintext: require('highlight.js/lib/languages/plaintext'),
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' });
25
+
26
+ // `lang` is the fence's info string; only its first word names the language,
27
+ // case-insensitively. Returns escaped, coloured HTML, or null when the
28
+ // language is missing or not registered.
29
+ function highlightCode(code, lang) {
30
+ const name = String(lang || '').trim().split(/\s+/)[0].toLowerCase();
31
+ if (!name || !hljs.getLanguage(name)) return null;
32
+ return hljs.highlight(code, { language: name, ignoreIllegals: true }).value;
33
+ }
34
+
35
+ module.exports = { highlightCode };