@helping-ai-workflow/md2doc 3.3.0 → 3.4.2

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.
@@ -304,23 +377,23 @@ function buildBlockMap(mdText) {
304
377
  let cursor = 1; // current line number of the token's first character
305
378
  const nextId = { v: 0 };
306
379
  for (const t of tokens) {
307
- const rawNewlines = (t.raw.match(/\n/g) || []).length;
380
+ const info = topLevelLineInfo(t);
308
381
  if (t.type === 'list') {
309
382
  pushListItemBlocks(t, cursor, 0, blocks, nextId);
310
383
  } else if (t.type !== 'space') {
311
- const content = t.raw.replace(/\n+$/, '');
312
- const span = content === '' ? 1 : content.split('\n').length;
313
384
  blocks.push({
314
385
  id: nextId.v++,
315
386
  type: t.type,
316
387
  startLine: cursor,
317
- endLine: cursor + span - 1,
388
+ endLine: cursor + info.span - 1,
318
389
  });
319
390
  }
320
- // 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.
321
394
  // If raw has no trailing newline (EOF), the next token doesn't exist,
322
395
  // so the off-by-one is unobservable.
323
- cursor += rawNewlines;
396
+ cursor += info.rawNewlines;
324
397
  }
325
398
  return { blocks, lineCount: mdText.split('\n').length };
326
399
  }