@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 +33 -8
- package/THIRD-PARTY-NOTICES.md +44 -0
- package/lib/drawio.js +178 -0
- package/lib/editor/blockmap.js +225 -17
- package/lib/editor/cli.js +50 -1
- package/lib/editor/client.js +5376 -173
- package/lib/editor/inline-md.js +122 -12
- package/lib/editor/lineops.js +190 -3
- package/lib/editor/server.js +398 -6
- package/lib/editor/toolbar-model.js +118 -20
- package/lib/editor/wave-codec.js +2631 -0
- package/lib/editor/wave-geometry.js +320 -0
- package/lib/editor/wave-store.js +950 -0
- package/lib/editor/wave-ui.js +2124 -0
- package/lib/md2doc.js +874 -32
- package/package.json +14 -6
- package/vendor/drawio/LICENSE +201 -0
- package/vendor/drawio/NOTICE +109 -0
- package/vendor/drawio/README.md +25 -0
- package/vendor/drawio/viewer-static.min.js +8596 -0
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+

|
|
381
|
+

|
|
382
|
+

|
|
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
|
|
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 };
|
package/lib/editor/blockmap.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
73
|
-
|
|
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 +
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|