@helping-ai-workflow/md2doc 3.2.1 → 3.4.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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @helping-ai-workflow/md2doc
2
2
 
3
- > Markdown → HTML / PDF renderer with WaveDrom, Mermaid, and Graphviz support.
3
+ > Markdown → HTML / PDF renderer with WaveDrom, Mermaid, Graphviz, and draw.io support.
4
4
 
5
5
  A single global CLI (`md2doc`) you can call from any directory.
6
6
 
@@ -8,7 +8,9 @@ A single global CLI (`md2doc`) you can call from any directory.
8
8
 
9
9
  Requires Node.js 18 or higher. The first install pulls puppeteer (≈ 170 MB Chromium download); subsequent installs reuse it.
10
10
 
11
- Chromium is only needed for **PDF export** and the optional `--bake-svg` flag. HTML diagram rendering needs nothing extra — Graphviz runs in-process via WebAssembly, and Mermaid / WaveDrom are bundled and inlined, so diagrams render **offline with no system Graphviz and no CDN**.
11
+ Chromium is needed for **PDF export**, for the optional `--bake-svg` flag, and for **`.drawio` diagrams**, which are always rendered to SVG at generation time. Everything else needs nothing extra — Graphviz runs in-process via WebAssembly, and Mermaid / WaveDrom are bundled and inlined.
12
+
13
+ Nothing is fetched at render time or at read time: every engine, including the vendored draw.io viewer, runs locally, so documents render and read **offline with no system Graphviz, no draw.io install and no CDN**.
12
14
 
13
15
  ### Recommended: install via nvm
14
16
 
@@ -100,8 +102,8 @@ in several places grows the HTML accordingly. PDF output is unaffected.
100
102
 
101
103
  ### Viewing diagrams
102
104
 
103
- Click any image, Mermaid, Graphviz or WaveDrom graphic in the rendered HTML to open it
104
- full-screen.
105
+ Click any image, Mermaid, Graphviz, draw.io or WaveDrom graphic in the rendered HTML to
106
+ open it full-screen.
105
107
 
106
108
  | Input | Action |
107
109
  |---|---|
@@ -280,7 +282,7 @@ version-control-friendly.
280
282
  ### Degraded blocks: code, diagrams, images, math
281
283
 
282
284
  Blocks containing content WYSIWYG cannot represent (code fences, Mermaid/Graphviz/
283
- WaveDrom diagrams, images, LaTeX math, or unstyled HTML) automatically degrade to
285
+ WaveDrom/draw.io diagrams, images, LaTeX math, or unstyled HTML) automatically degrade to
284
286
  raw-edit mode: click the block to open the raw Markdown source in a textarea, make
285
287
  your changes, then press `Ctrl+Enter` to commit or `Esc` to cancel.
286
288
 
@@ -372,9 +374,19 @@ digraph G { A -> B }
372
374
  ```
373
375
  ````
374
376
 
375
- All three render directly in the output (HTML or PDF), **offline and with no system dependencies**: Graphviz `dot` runs in-process via WebAssembly (no system `dot` binary required), and Mermaid / WaveDrom are bundled and inlined (no CDN). Each engine's runtime is embedded only when the document actually uses that diagram type.
377
+ A fourth engine, **draw.io**, is referenced with image syntax rather than a code fence, because a `.drawio` file is an external artifact rather than inline source:
378
+
379
+ ```markdown
380
+ ![Architecture overview](arch.drawio)
381
+ ![Just the Flow sheet](arch.drawio#Flow)
382
+ ![Just the second sheet](arch.drawio#2)
383
+ ```
384
+
385
+ A `.xml` file is treated as a diagram only when its root element really is `<mxfile>` or `<mxGraphModel>`; anything else falls through to the ordinary image path unchanged. `#Name` selects a sheet by name, `#N` by 1-based position (a name wins over a number). The alt text becomes the rendered box's accessible name. Multi-sheet files get a small sheet bar on hover — every sheet is baked into the output, so switching sheets involves no engine.
386
+
387
+ All four render directly in the output (HTML or PDF), **offline and with no system dependencies**: Graphviz `dot` runs in-process via WebAssembly (no system `dot` binary required), Mermaid / WaveDrom are bundled and inlined (no CDN), and `.drawio` files are rendered by a vendored draw.io viewer running in a build-time headless Chromium. Each engine's runtime is embedded only when the document actually uses that diagram type — and the draw.io viewer never is, at any size, because `.drawio` diagrams are fully pre-rendered (a multi-sheet document carries only a few lines of sheet-switching script, not an engine).
376
388
 
377
- By default, Mermaid and WaveDrom render in the browser when the HTML is opened; pass `--bake-svg` to pre-render them to inert SVG at generation time instead (Graphviz is always pre-rendered to SVG).
389
+ By default, Mermaid and WaveDrom render in the browser when the HTML is opened; pass `--bake-svg` to pre-render them to inert SVG at generation time instead (Graphviz and draw.io are always pre-rendered to SVG).
378
390
 
379
391
  ## Why a global CLI
380
392
 
@@ -382,4 +394,17 @@ Multiple repos used to ship copies of this script. They drifted. This package ce
382
394
 
383
395
  ## Licence
384
396
 
385
- MIT.
397
+ md2doc's own source is **MIT** — see [`LICENSE`](LICENSE).
398
+
399
+ The published package is not MIT-only, because it ships one vendored
400
+ third-party file: `vendor/drawio/viewer-static.min.js`, the draw.io /
401
+ diagrams.net viewer (v31.3.2, ~4 MB), under the **Apache License 2.0**.
402
+ Its licence text and a per-component NOTICE travel with it, in
403
+ [`vendor/drawio/LICENSE`](vendor/drawio/LICENSE) and
404
+ [`vendor/drawio/NOTICE`](vendor/drawio/NOTICE).
405
+
406
+ **Running a licence scan? Start at
407
+ [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).** It lists everything
408
+ redistributed inside the tarball, including the components bundled inside
409
+ the draw.io viewer itself (DOMPurify, pako, spin.js, Rough.js), and says
410
+ how each finding was established.
@@ -0,0 +1,44 @@
1
+ # Third-party notices
2
+
3
+ md2doc's own source code is licensed under the MIT License — see
4
+ [`LICENSE`](LICENSE). `"license": "MIT"` in `package.json` refers to that
5
+ code and only to that code.
6
+
7
+ **The published npm package also contains code that is not md2doc's and
8
+ is not MIT.** If you are running a licence scan, this is the file you
9
+ want; the per-component detail is one level down, next to the artifact it
10
+ describes.
11
+
12
+ ## Vendored at rest (inside the published tarball)
13
+
14
+ | Path | What | Licence | Detail |
15
+ |---|---|---|---|
16
+ | `vendor/drawio/viewer-static.min.js` | draw.io / diagrams.net viewer, v31.3.2, 4,151,717 bytes | **Apache License 2.0** | [`vendor/drawio/LICENSE`](vendor/drawio/LICENSE), [`vendor/drawio/NOTICE`](vendor/drawio/NOTICE) |
17
+
18
+ That single 4 MB file is a verbatim copy of
19
+ `src/main/webapp/js/viewer-static.min.js` from
20
+ [jgraph/drawio](https://github.com/jgraph/drawio) at tag `v31.3.2`
21
+ (sha256 `2fabaaa3e28d5f80f943285a2ce19c22cf870857203255f1e0347ef93693a297`).
22
+ md2doc executes it only inside a build-time headless Chromium, to turn
23
+ `.drawio` references into inline SVG; it is never emitted into output
24
+ HTML and never fetched over the network.
25
+
26
+ The Apache-2.0 text ships **inside `vendor/drawio/`**, next to the file it
27
+ covers, rather than only at the repository root — so it survives being
28
+ copied, vendored or extracted along with the artifact.
29
+
30
+ That bundle is itself not licence-homogeneous. It contains DOMPurify
31
+ 3.4.13 (Apache-2.0 OR MPL-2.0), pako 2.2.0 (MIT AND Zlib), spin.js 2.0.0
32
+ (MIT) and Rough.js 4.6.6 (MIT).
33
+ [`vendor/drawio/NOTICE`](vendor/drawio/NOTICE) records each one's version,
34
+ licence, where inside the bundle it sits, **how that was established**,
35
+ and which of those findings rest on provenance rather than on a
36
+ declaration in the artifact itself.
37
+
38
+ ## Runtime dependencies (installed by npm, not vendored)
39
+
40
+ md2doc's `dependencies` (marked, mermaid, wavedrom, katex, puppeteer,
41
+ turndown, `@hpcc-js/wasm-graphviz`) are ordinary npm packages: they are
42
+ resolved into your `node_modules` by your own installer, carry their own
43
+ licence files there, and are not redistributed inside md2doc's tarball.
44
+ They are out of scope for this file.
package/lib/drawio.js ADDED
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+
6
+ // drawio (.drawio / .xml) 來源的判別與頁面解析。
7
+ //
8
+ // 這一支刻意不碰 marked、不碰瀏覽器 —— 判別與頁名是純字串處理,可以用純函式
9
+ // 測;真的需要 headless Chromium 的只有 bakeDrawioSvg()(見同檔下方)。
10
+ //
11
+ // 為什麼不用 XML parser:md2doc 不引進新的 runtime 依賴,而這裡要回答的問題
12
+ // (根元素是什麼、有哪些 <diagram name>)用受控的 regex 就夠。輸入是本機檔案
13
+ // 而不是網路內容,且判別失敗的後果是「當成普通圖片、原樣留著」——不是安全邊界。
14
+
15
+ // 開頭的 XML 宣告、DOCTYPE、註解、空白都要跳過,才看得到真正的根元素。
16
+ const LEADING_NOISE = /^(?:\s|<\?[\s\S]*?\?>|<!--[\s\S]*?-->|<!DOCTYPE[^>]*>)*/;
17
+
18
+ // 根元素判別:兩處(isDrawioXml / pageNamesOf)共用同一顆函式,避免各自維護
19
+ // 一份「跳過前導雜訊」的 regex 而在 DOCTYPE / 自我封閉標籤上悄悄長歪。
20
+ //
21
+ // 標籤後面接的字元用 `[\s\/>]` 而不是 `[\s>]`:自我封閉的 `<mxfile/>` 或
22
+ // `<mxGraphModel/>`(沒有任何屬性、也沒有子節點)在真實檔案裡少見,但一旦
23
+ // 出現,`[\s>]` 會因為下一個字元是 `/` 而漏判成「不是 drawio」——用
24
+ // `foo(root, 'not a diagram')` 這種輸入實測會踩到。同時要擋掉
25
+ // `<mxfilex>` / `<mxGraphModelFoo>` 這種前綴相同、但其實是別的標籤的輸入,
26
+ // 所以字元類仍然是白名單而不是「以 mxfile 開頭就算」。
27
+ function rootKindOf(text) {
28
+ const rest = String(text || '').replace(LEADING_NOISE, '');
29
+ if (/^<mxfile[\s\/>]/.test(rest)) return 'mxfile';
30
+ if (/^<mxGraphModel[\s\/>]/.test(rest)) return 'mxGraphModel';
31
+ return null;
32
+ }
33
+
34
+ function isDrawioXml(text) {
35
+ return rootKindOf(text) !== null;
36
+ }
37
+
38
+ function pageNamesOf(text) {
39
+ const s = String(text || '');
40
+ if (rootKindOf(s) !== 'mxfile') {
41
+ // <mxGraphModel> 直接當一頁,沒有名字。
42
+ return [''];
43
+ }
44
+ const names = [];
45
+ const re = /<diagram\b([^>]*)>/g;
46
+ let m;
47
+ while ((m = re.exec(s)) !== null) {
48
+ const nameAttr = /\bname\s*=\s*"([^"]*)"/.exec(m[1]);
49
+ names.push(nameAttr ? nameAttr[1] : '');
50
+ }
51
+ return names.length ? names : [''];
52
+ }
53
+
54
+ function resolvePageIndex(names, fragment) {
55
+ const frag = String(fragment == null ? '' : fragment);
56
+ if (!frag) return 0;
57
+ const byName = names.indexOf(frag);
58
+ if (byName >= 0) return byName;
59
+ if (/^\d+$/.test(frag)) {
60
+ const n = Number(frag);
61
+ if (n >= 1 && n <= names.length) return n - 1;
62
+ }
63
+ return 0;
64
+ }
65
+
66
+ // v3.4.0 batch2 Task 6 fix round 1 (review F7) — the identity of a referenced
67
+ // `.drawio`/`.xml` file's bytes AS THIS PROCESS LAST SAW THEM.
68
+ //
69
+ // (mtimeMs, size) rather than mtimeMs alone: this project's own environment
70
+ // notes document a filesystem (WSL DrvFs over /mnt/c) that degrades and
71
+ // behaves oddly around timestamps, and coarse-granularity mtimes are also the
72
+ // norm on SMB shares — a rewrite landing inside the same tick as the recorded
73
+ // one compares equal and is never reported. Size costs nothing extra (the
74
+ // Stats object is already in hand) and closes that hole for every edit that
75
+ // changes the file's length.
76
+ //
77
+ // Returns null when the file cannot be stat'd at all. That deliberately
78
+ // collapses "deleted" and "unreadable" (EACCES, an editor's atomic-replace
79
+ // window) into one value: for staleness they are the same answer — "the bytes
80
+ // you baked are no longer the bytes on disk" — and the caller has no repair
81
+ // action that would differ between them. The cost is diagnostic only.
82
+ function drawioStampOf(absPath) {
83
+ try {
84
+ const st = fs.statSync(absPath);
85
+ return String(st.mtimeMs) + '/' + String(st.size);
86
+ } catch (e) {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ const VIEWER_PATH = path.join(__dirname, '..', 'vendor', 'drawio', 'viewer-static.min.js');
92
+
93
+ // 每一頁烤成一份 SVG。
94
+ //
95
+ // 為什麼是 headless Chromium 而不是 Node 裡的某個函式庫:drawio 的版面
96
+ // 計算(自動換行、文字度量、stencil 形狀)依賴真正的瀏覽器排版引擎,
97
+ // 沒有等價的 Node 實作。md2doc 已經有 puppeteer(PDF 匯出在用),所以這
98
+ // 不是新依賴。
99
+ //
100
+ // 遠端路徑預設值必須在 viewer 載入之前就改寫掉。viewer 自己的寫法是
101
+ // `window.PROXY_URL = window.PROXY_URL || "https://viewer.diagrams.net/proxy"`
102
+ // 這種形式 —— `||` 代表先設好的值會勝出,所以 addScriptTag 之前先 evaluate。
103
+ //
104
+ // 數字要講清楚,因為下面那份清單看起來很像「全部」:viewer 裡這種形狀的
105
+ // 遠端預設值共有 14 個(11 個指向 diagrams.net、3 個指向 github/gitlab),
106
+ // 下面只中和了靜態渲染路徑真的會讀到的 8 個,外加 2 個載入旗標。沒中和的
107
+ // 6 個(EXPORT_URL、VSS_CONVERT_URL、DRAWIO_LIGHTBOX_URL、DRAWIO_GITHUB_URL、
108
+ // DRAWIO_GITHUB_API_URL、DRAWIO_GITLAB_URL)屬於匯出/格式轉換/git 整合功能,
109
+ // GraphViewer.createViewerForElement() 這條路不會叫到它們。
110
+ //
111
+ // 所以「輸出離線可用」這個保證**不是**靠這份清單完整,而是靠 test/drawio.test.js
112
+ // 對產出斷言沒有任何遠端 URL。要加功能而動到 viewer 的其他能力時,先重新量,
113
+ // 不要假設這 8 個就是全部。
114
+ async function bakeDrawioSvg(xmlText, opts) {
115
+ const o = opts || {};
116
+ const names = pageNamesOf(xmlText);
117
+ let browser = o.browser;
118
+ let ownBrowser = false;
119
+ if (!browser) {
120
+ const puppeteer = require('puppeteer');
121
+ browser = await puppeteer.launch({
122
+ headless: 'new',
123
+ args: ['--no-sandbox', '--disable-setuid-sandbox',
124
+ '--disable-crash-reporter', '--disable-dev-shm-usage'],
125
+ });
126
+ ownBrowser = true;
127
+ }
128
+ const page = await browser.newPage();
129
+ try {
130
+ await page.setContent('<!doctype html><html><body></body></html>');
131
+ await page.evaluate(() => {
132
+ // 全部指向 about:blank 之下的相對路徑:viewer 取不到就用內建的
133
+ // 基本形狀,而不是去打網路。md2doc 的輸出必須離線可用。
134
+ window.PROXY_URL = '';
135
+ window.STYLE_PATH = '';
136
+ window.SHAPES_PATH = '';
137
+ window.STENCIL_PATH = '';
138
+ window.DRAW_MATH_URL = '';
139
+ window.GRAPH_IMAGE_PATH = '';
140
+ window.mxImageBasePath = '';
141
+ window.mxBasePath = '';
142
+ window.mxLoadStylesheets = false;
143
+ window.mxLoadResources = false;
144
+ });
145
+ await page.addScriptTag({ path: VIEWER_PATH });
146
+
147
+ const out = [];
148
+ for (let i = 0; i < names.length; i++) {
149
+ const svg = await page.evaluate((xml, pageIndex) => {
150
+ const el = document.createElement('div');
151
+ el.className = 'mxgraph';
152
+ // width 不設會讓 GraphViewer 把圖縮到容器寬度。
153
+ el.setAttribute('data-mxgraph', JSON.stringify({
154
+ xml: xml, page: pageIndex, highlight: '#0000ff',
155
+ toolbar: null, resize: true, nav: false,
156
+ }));
157
+ document.body.appendChild(el);
158
+ window.GraphViewer.createViewerForElement(el);
159
+ const svgEl = el.querySelector('svg');
160
+ const html = svgEl ? svgEl.outerHTML : null;
161
+ el.remove();
162
+ return html;
163
+ }, xmlText, i);
164
+
165
+ if (!svg) {
166
+ throw new Error('drawio viewer produced no SVG for page ' + (i + 1) +
167
+ ' (' + (names[i] || 'unnamed') + ')');
168
+ }
169
+ out.push(svg);
170
+ }
171
+ return out;
172
+ } finally {
173
+ await page.close();
174
+ if (ownBrowser) await browser.close();
175
+ }
176
+ }
177
+
178
+ module.exports = { isDrawioXml, pageNamesOf, resolvePageIndex, bakeDrawioSvg, drawioStampOf };
@@ -12,6 +12,79 @@ function trimmedLineCount(raw) {
12
12
  return content === '' ? 1 : content.split('\n').length;
13
13
  }
14
14
 
15
+ // v3.4.0 (backlog #13, closing the "改動前後相同" observation for real):
16
+ // MEASURED on marked 14.1.4, a top-level 'paragraph' token's `raw` can carry
17
+ // an internal newline that names NO real blank line in the source. Minimal
18
+ // case: 'a\n\tb\n---\n- x\n- y\n\ntail\n' — the paragraph's raw comes back as
19
+ // 'a\n\n\tb\n' (3 newlines) for what is 2 real source lines ('a', a TAB- or
20
+ // >=4-space-over-indented lazy continuation 'b'); every block from the '---'
21
+ // onward then names the line ABOVE its real one. This is not "only literal
22
+ // '---'" — a table SEPARATOR row immediately after the same over-indented
23
+ // continuation ('|---|', ':---') reproduces it too, and the trigger tracks
24
+ // the count of over-indented continuation lines, not any single terminator
25
+ // (see runs/task11-item13-scope-hunt*.js: 195+ cases across continuation
26
+ // width x repeat-count x fifteen terminators — corruption only where the
27
+ // line right after the continuation is dash/equals-shaped, matching
28
+ // whatever internal check marked runs to decide a following line MIGHT be a
29
+ // setext-heading underline; every other terminator tried — the other hr
30
+ // markers, headings, blockquotes, fences, header-led tables, lists, a
31
+ // genuine setext '===' — left raw uncorrupted in every case). A 'heading'
32
+ // token was TRIED as a second covered type (it goes through the same marked
33
+ // code path — setext heading construction IS this ambiguity's positive
34
+ // case) and REJECTED: test/blockmap.test.js's own first fixture ('# Title\n\n
35
+ // Para one.\n\n…') broke immediately, because ATX headings legitimately FOLD
36
+ // a trailing blank-line run into their own raw when nothing else does (raw
37
+ // '# Title\n\n' for a heading followed by one blank line, with no separate
38
+ // 'space' token) — exactly the behaviour this file's own header comment
39
+ // documents ("A token's raw may include trailing newlines … folded in").
40
+ // `t.text` for a heading is just its own single line of prose ("Title", 0
41
+ // newlines) and has no way to reconstruct how many blank lines got folded
42
+ // after it, so text-based counting silently DROPS that fold and lands the
43
+ // next block one line early. Paragraphs do not fold trailing blanks this way
44
+ // (MEASURED: a blank-line run after a paragraph always comes back as its own
45
+ // separate 'space' token, at EOF and mid-document alike — see
46
+ // runs/task11-item13-parafold.js), which is exactly why paragraphs are safe
47
+ // to cover here and headings are not.
48
+ //
49
+ // This file's own item-level code already named the general family: a lazy
50
+ // continuation's `text`-token raw is SYNTHESISED ("a lazy continuation comes
51
+ // back as \"x\n\ncont\n\" (three newlines) for two source lines" — see
52
+ // childListStartOffsets()'s header comment), which is why that code never
53
+ // trusts a `text` token's raw for cursor advancement. This is the same
54
+ // defence at buildBlockMap()'s TOP level, where a top-level 'paragraph' is
55
+ // marked's equivalent of an in-item lazy continuation — the top level had no
56
+ // corresponding defence because the header comment's own stated basis for
57
+ // trusting top-level raws ("at top level the raws are faithful source
58
+ // slices") is exactly the assumption this case disproves.
59
+ //
60
+ // The fix does not detect corruption and fall back conditionally; it simply
61
+ // never counts newlines out of a 'paragraph' token's `raw` at all. `t.text`
62
+ // — marked's own dedented join of the token's real content lines, e.g.
63
+ // "a\nb" for the fixture above — was MEASURED to stay correct in every
64
+ // corrupted case found in the sweep: its newline count always equals (real
65
+ // source lines consumed - 1), carrying none of the synthetic blank lines
66
+ // `raw` picks up. And on every UNCORRUPTED case, `text`-based and
67
+ // `raw`-based counting agree exactly — MEASURED via a direct before/after
68
+ // diff of buildBlockMap()'s own OUTPUT (not just the raw newline count) over
69
+ // every fixture already in test/blockmap.test.js plus a 60000-document
70
+ // random line-soup fuzz corpus (see runs/task11-item13-fuzz-oracle.js and
71
+ // the report) — so there is no accuracy cost paid on the common path.
72
+ //
73
+ // Every OTHER top-level token type is left on the original raw-based
74
+ // computation, unconditionally: the sweep found no corruption outside
75
+ // 'paragraph', and 'space' tokens in particular NEED their raw's FULL,
76
+ // untrimmed newline count (to skip a multi-blank-line run) — trimmedLineCount()
77
+ // or text-based counting would get that case wrong on purpose, not by
78
+ // oversight.
79
+ function topLevelLineInfo(t) {
80
+ if (t.type === 'paragraph' && typeof t.text === 'string') {
81
+ const contentNewlines = (t.text.match(/\n/g) || []).length;
82
+ const endsWithNewline = t.raw.charAt(t.raw.length - 1) === '\n';
83
+ return { span: contentNewlines + 1, rawNewlines: contentNewlines + (endsWithNewline ? 1 : 0) };
84
+ }
85
+ return { span: trimmedLineCount(t.raw), rawNewlines: (t.raw.match(/\n/g) || []).length };
86
+ }
87
+
15
88
  // Recursively emit one `li` block per list item at every depth.
16
89
  // Recursion order (mirrored by Task 4's render walk):
17
90
  // 1. Push the item's own block.
@@ -63,35 +136,123 @@ function indexOfAtLineStart(content, needle, from) {
63
136
  return at;
64
137
  }
65
138
 
139
+ // The search above is a byte-for-byte `indexOf`, and marked does not hand back
140
+ // bytes that match byte-for-byte: a nested list token's `raw` has the trailing
141
+ // spaces stripped off its LAST line while the enclosing `item.text` keeps them.
142
+ // MEASURED on this repo's marked, for '# H\n\n- alpha\n - beta\n - \n- gamma\n':
143
+ //
144
+ // item.text "alpha\n- beta\n- " <- trailing space kept
145
+ // child list tk.raw "- beta\n-\n" <- trailing space gone, \n added
146
+ //
147
+ // so `content.indexOf(tk.raw)` was -1, and the guessed offset 0 that followed
148
+ // shifted every descendant's startLine UP one line. That is not a corner: the
149
+ // markdown the CLIENT serialises mid-session contains the shape:
150
+ // '# H\n\n- alpha\n - beta\n - \n- gamma\n' was captured coming back from
151
+ // client.js during a test/editor-journey.test.js run and mapped to an inverted
152
+ // range under the old search. Ordinary authored markdown does it too — an
153
+ // everyday flat-plus-nested list corrupts the moment a trailing space lands on
154
+ // its nested line. Neither of those two strings is itself a fixture — they came
155
+ // out of a capture harness and a mutation sweep that are not in the repo. What
156
+ // IS pinned, and what to read instead, is the shape FAMILY — grep 'trailing
157
+ // space' in test/blockmap.test.js for the range assertions, and 'eats the item
158
+ // above' in test/editor-journey.test.js for the gestures that used to do
159
+ // exactly that.
160
+ //
161
+ // Both sides are put through the same normalisation before the search: trailing
162
+ // spaces and tabs are dropped from every line. Two properties are relied on,
163
+ // and they are different from each other:
164
+ //
165
+ // LINE OFFSETS SURVIVE. The transform only ever removes characters that are
166
+ // not '\n', so it cannot change a line count — a character offset found in
167
+ // the normalised copy names the same LINE offset it would have named in the
168
+ // original, and a line offset is the only thing this function returns.
169
+ //
170
+ // SUBSTRINGS SURVIVE. If the needle occurred in the content before, the
171
+ // normalised needle occurs in the normalised content. Every '\n' inside the
172
+ // needle is the same '\n' in the content, so a space run sitting before it is
173
+ // stripped identically on both sides; the two can only disagree at the
174
+ // needle's own END, where the needle may lose a trailing run the content
175
+ // keeps. That only makes the needle SHORTER, and a prefix of a substring is
176
+ // still a substring. (Byte identity of two equal strings under a shared
177
+ // transform is the wrong lemma here — it says nothing about a needle whose
178
+ // surroundings differ from its own end.)
179
+ //
180
+ // The needle additionally loses its trailing newlines (`\n+$`), because the raw
181
+ // of a token that ends the item carries a terminator `item.text` does not have.
182
+ // That leaves `pos` on the newline (or newlines) that ended the consumed token
183
+ // rather than after them, so both searches below start earlier than they used
184
+ // to and a match could in principle land on one of those bytes. One argument
185
+ // rules that out for both searches at once, so it is the only one stated here:
186
+ // to match at a position occupied by a newline, the NEEDLE would have to begin
187
+ // with a newline. MEASURED over the journey capture plus a synthetic sweep of
188
+ // nested lists (blank lines, fenced code, blockquotes, tab indents, ordered and
189
+ // task markers): no nested list token's `raw` began with '\n'. Labelled
190
+ // MEASURED and not invariant — nothing downstream may assume it.
191
+ function stripTrailingLineSpace(s) {
192
+ return s.replace(/[ \t]+$/gm, '');
193
+ }
194
+
195
+ // Returns { offsets, unlocatable }. `offsets` keeps its old contract exactly:
196
+ // one entry per child list token, always, in order. `unlocatable` is true when
197
+ // at least one of those entries is a GUESS rather than a found position —
198
+ // see pushListItemBlocks(), which is what acts on it.
66
199
  function childListStartOffsets(item) {
67
- const content = typeof item.text === 'string' ? item.text : '';
200
+ const content = stripTrailingLineSpace(
201
+ typeof item.text === 'string' ? item.text : ''
202
+ );
68
203
  const offsets = [];
204
+ let unlocatable = false;
69
205
  let pos = 0;
70
206
  for (const tk of item.tokens || []) {
207
+ const needle = stripTrailingLineSpace(typeof tk.raw === 'string' ? tk.raw : '')
208
+ .replace(/\n+$/, '');
71
209
  if (tk.type === 'list') {
72
- let at = indexOfAtLineStart(content, tk.raw, pos);
73
- if (at < 0) at = content.indexOf(tk.raw, pos);
210
+ // An empty needle would make `indexOf` answer `pos` for any content, i.e.
211
+ // report a find that was never made. Treated as not-found instead.
212
+ let at = needle === '' ? -1 : indexOfAtLineStart(content, needle, pos);
213
+ if (at < 0 && needle !== '') at = content.indexOf(needle, pos);
74
214
  // Unlocatable (a shape this walk does not model): fall back to the line
75
215
  // the cursor is already on. Never skip — a missing block desynchronises
76
216
  // the render walk and takes the whole document down.
217
+ if (at < 0) unlocatable = true;
77
218
  offsets.push(at < 0 ? lineOffsetAt(content, pos) : lineOffsetAt(content, at));
78
- if (at >= 0) pos = at + tk.raw.length;
219
+ if (at >= 0) pos = at + needle.length;
79
220
  continue;
80
221
  }
81
222
  // `text` raws are synthesised; everything else is a faithful slice and is
82
223
  // consumed so later searches start beyond it.
83
224
  if (tk.type === 'text') continue;
84
- const at = content.indexOf(tk.raw, pos);
85
- if (at >= 0) pos = at + tk.raw.length;
225
+ // BEHAVIOUR CHANGE, small and deliberate: before the trailing-newline trim
226
+ // above, a `space` token (a blank-line run inside the item) had a needle of
227
+ // pure newlines and this branch consumed it, advancing `pos` past the blank
228
+ // run. Trimmed, that needle is '' — and `''.indexOf` answers `pos` for any
229
+ // content, i.e. a find that was never made — so the token is skipped and
230
+ // `pos` stays where it was, which means every later search starts earlier
231
+ // than it used to.
232
+ //
233
+ // What establishes that this is harmless is an ABLATION, not an argument: a
234
+ // copy of this function that still advances `pos` past an empty needle
235
+ // (using the untrimmed raw) produced block maps identical to these on every
236
+ // document of the journey capture, of its trailing-space mutations, and of a
237
+ // synthetic nested-list sweep. The same sweeps also show `space` was the
238
+ // only token type whose needle ever collapsed to '' — which is the only type
239
+ // that CAN, its raw being whitespace by construction.
240
+ if (needle === '') continue;
241
+ const at = content.indexOf(needle, pos);
242
+ if (at >= 0) pos = at + needle.length;
86
243
  }
87
- return offsets;
244
+ return { offsets, unlocatable };
88
245
  }
89
246
 
90
- function pushListItemBlocks(listToken, cursor, indent, blocks, nextId) {
247
+ // `inheritedUnlocatable` is how an item degrades its whole subtree: see the
248
+ // `degraded` note below the block literal.
249
+ function pushListItemBlocks(listToken, cursor, indent, blocks, nextId, inheritedUnlocatable) {
91
250
  for (const item of listToken.items) {
92
251
  const childListTokens = item.tokens.filter((t) => t.type === 'list');
93
252
  const totalSpan = trimmedLineCount(item.raw);
94
- const childLineOffsets = childListStartOffsets(item);
253
+ const located = childListStartOffsets(item);
254
+ const childLineOffsets = located.offsets;
255
+ const degraded = !!inheritedUnlocatable || located.unlocatable;
95
256
 
96
257
  // The item's OWN block covers its contiguous leading lines only — up to its
97
258
  // first child.
@@ -136,12 +297,58 @@ function pushListItemBlocks(listToken, cursor, indent, blocks, nextId) {
136
297
  // is never editable and no commit path can start on it. If you add a
137
298
  // consumer that walks block ranges, either honour that emptiness or check
138
299
  // for it; do not assume `startLine <= endLine`.
300
+ //
301
+ // DEGRADED SUBTREE. When childListStartOffsets() could not FIND a child
302
+ // list and had to guess its line, every line number computed from that
303
+ // guess is fiction, and the fiction does not stop at the parent: the guess
304
+ // is the offset each descendant's cursor is built from, so an entire nested
305
+ // run gets addresses belonging to the lines ABOVE it. That is not an
306
+ // abstract risk — it is the shape a user is in between typing a nested
307
+ // marker and typing its first character, and the observed result was that
308
+ // client.js's bystanderCarryOver() replayed the WRONG source line over a
309
+ // sibling: '- alpha\n - beta\n - \n' committed back as
310
+ // '- alpha\n - alpha\n-\n', with beta gone from the file.
311
+ //
312
+ // So the whole subtree — this item and, via `inheritedUnlocatable`, every
313
+ // item below it — is given an EMPTY range (endLine === startLine - 1)
314
+ // rather than a wrong one. Empty is a shape this codebase already defines
315
+ // and already defends: client.js's blockOwnsNoLine() is `endLine <
316
+ // startLine`, and the functions that call it directly are
317
+ // canWysiwygForLi() (arming), openRawEditor() (the raw textarea),
318
+ // insertBlockBelow() (the + paths, twice — anchor and subtree tail),
319
+ // resolveGutterOperands() (twice — where the ⠿ menu's 刪除 and every other
320
+ // grip operation resolves its targets, reached from deleteBlockViaGutter()
321
+ // through deleteBlockViaGutterBody()) and bystanderCarryOver() (the replay
322
+ // of untouched siblings). So an empty range refuses at every arming and
323
+ // commit boundary instead of writing somebody else's line.
324
+ //
325
+ // Every block is still PUSHED. lib/md2doc.js's edit-mode render walk
326
+ // consumes blocks[] in lockstep and throws on `biRef.v !== blocks.length`,
327
+ // so a skipped block takes the whole document down; an empty range costs
328
+ // that check nothing, and server.js's assertBlockRangesFit() only rejects
329
+ // `endLine > lines.length`, which a shortened range can never trip.
330
+ //
331
+ // `unlocatable` marks these records so the client can say WHY it refused.
332
+ // Same-line nesting ('- - a') produces an empty range too, and it is a
333
+ // legitimate, fully-modelled shape — it does not get the flag, and it
334
+ // keeps its own existing wording.
335
+ //
336
+ // ACCEPTED RESIDUAL: the degraded subtree's source lines end up owned by no
337
+ // block at all. lib/editor/selection.js's `ownsALine()` is
338
+ // `b.endLine >= b.startLine`, and membersOf() filters on it, so those lines
339
+ // fall out of every block selection — a drag across them selects the blocks
340
+ // either side and nothing in between. That is the price of emptying the
341
+ // subtree instead of corrupting it, and it is the right trade: a wrong
342
+ // range writes over somebody else's line, an absent one only cannot be
343
+ // selected. Unreachable as this is written (no markdown found so far makes
344
+ // the search fail); recorded here so whoever first reaches it meets this
345
+ // in the code rather than in the field.
139
346
  const ownSpan = childLineOffsets.length ? childLineOffsets[0] : totalSpan;
140
347
  const block = {
141
348
  id: nextId.v++,
142
349
  type: 'li',
143
350
  startLine: cursor,
144
- endLine: cursor + ownSpan - 1,
351
+ endLine: degraded ? cursor - 1 : cursor + ownSpan - 1,
145
352
  // Two independent axes (RULING F-N): GFM allows `1. [ ] a`, so
146
353
  // ordered-ness and task-ness cannot share one field. `listType` is the
147
354
  // LIST's type; `task` is the ITEM's.
@@ -150,12 +357,13 @@ function pushListItemBlocks(listToken, cursor, indent, blocks, nextId) {
150
357
  indent,
151
358
  };
152
359
  if (item.task) block.checked = !!item.checked;
360
+ if (degraded) block.unlocatable = true;
153
361
  blocks.push(block);
154
362
  // Every child is emitted, unconditionally: blocks[] is consumed in lockstep
155
363
  // by lib/md2doc.js's render walk, so a skipped child desynchronises the two
156
364
  // and the render runs off the end of the array.
157
365
  childListTokens.forEach((ct, k) => {
158
- pushListItemBlocks(ct, cursor + childLineOffsets[k], indent + 1, blocks, nextId);
366
+ pushListItemBlocks(ct, cursor + childLineOffsets[k], indent + 1, blocks, nextId, degraded);
159
367
  });
160
368
  // advance cursor by full item raw newlines; fall back to totalSpan if raw
161
369
  // has no trailing newline (EOF item).
@@ -169,23 +377,23 @@ function buildBlockMap(mdText) {
169
377
  let cursor = 1; // current line number of the token's first character
170
378
  const nextId = { v: 0 };
171
379
  for (const t of tokens) {
172
- const rawNewlines = (t.raw.match(/\n/g) || []).length;
380
+ const info = topLevelLineInfo(t);
173
381
  if (t.type === 'list') {
174
382
  pushListItemBlocks(t, cursor, 0, blocks, nextId);
175
383
  } else if (t.type !== 'space') {
176
- const content = t.raw.replace(/\n+$/, '');
177
- const span = content === '' ? 1 : content.split('\n').length;
178
384
  blocks.push({
179
385
  id: nextId.v++,
180
386
  type: t.type,
181
387
  startLine: cursor,
182
- endLine: cursor + span - 1,
388
+ endLine: cursor + info.span - 1,
183
389
  });
184
390
  }
185
- // advance by full raw (including folded trailing blank lines).
391
+ // advance by full raw (including folded trailing blank lines) for every
392
+ // type except 'paragraph'/'heading', which use topLevelLineInfo()'s
393
+ // text-based count instead — see its own comment for why.
186
394
  // If raw has no trailing newline (EOF), the next token doesn't exist,
187
395
  // so the off-by-one is unobservable.
188
- cursor += rawNewlines;
396
+ cursor += info.rawNewlines;
189
397
  }
190
398
  return { blocks, lineCount: mdText.split('\n').length };
191
399
  }