@helping-ai-workflow/md2doc 2.10.1 → 2.11.1

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.
@@ -14,6 +14,15 @@ const TABLE_MD_SRC = fs.readFileSync(path.join(__dirname, 'table-md.js'), 'utf8'
14
14
  // right after it to keep the two sibling serializers grouped together.
15
15
  const LIST_MD_SRC = fs.readFileSync(path.join(__dirname, 'list-md.js'), 'utf8');
16
16
  const HISTORY_SRC = fs.readFileSync(path.join(__dirname, 'history.js'), 'utf8');
17
+ // Task 6: spec §3.4's shift-then-clamp, a pure data transform with no
18
+ // dependency on any other editor module — order among these is irrelevant, it
19
+ // only has to land before client.js reads window.md2docIndentClamp.
20
+ const INDENT_CLAMP_SRC = fs.readFileSync(path.join(__dirname, 'indent-clamp.js'), 'utf8');
21
+ // S2 spec §3.2/§4.3: the pure marker stripper/emitter behind the 轉換成
22
+ // submenu. Same "no dependency on any other editor module" property as
23
+ // indent-clamp.js above — it only has to land before client.js reads
24
+ // window.md2docConvertMd.
25
+ const CONVERT_MD_SRC = fs.readFileSync(path.join(__dirname, 'convert-md.js'), 'utf8');
17
26
 
18
27
  function readJson(req, limitBytes = 50 * 1024 * 1024) {
19
28
  return new Promise((resolve, reject) => {
@@ -42,6 +51,25 @@ function send(res, status, obj) {
42
51
  res.end(body);
43
52
  }
44
53
 
54
+ // The one invariant that ties the two halves of the payload together: every
55
+ // block's line range must address a line that actually EXISTS in `lines`.
56
+ // blockmap.js derives ranges from marked's own tokenisation while `lines`
57
+ // comes from a regex split here, so the two can only agree while both use the
58
+ // SAME definition of a line terminator — and when they disagree the failure is
59
+ // silent and destructive (lineops.replaceLines() splices past the end of the
60
+ // array, deleting every line the block map thought was there). Throwing here
61
+ // turns that into the server route's 500 + the client's error banner, which is
62
+ // a document that will not open rather than a document that opens and then
63
+ // eats its own tail.
64
+ function assertBlockRangesFit(blocks, lines) {
65
+ let maxEnd = 0;
66
+ for (const b of blocks || []) if (b.endLine > maxEnd) maxEnd = b.endLine;
67
+ if (maxEnd > lines.length) {
68
+ throw new Error('block map is out of range: endLine ' + maxEnd +
69
+ ' > ' + lines.length + ' lines (line-terminator handling disagrees with marked)');
70
+ }
71
+ }
72
+
45
73
  async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '', listenPort = 0 }) {
46
74
  const absFiles = files.map((f) => path.resolve(f));
47
75
  let idleTimer = null;
@@ -84,6 +112,14 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
84
112
  // EOL 偵測與拆行:lines 內部一律不含 \r(spec §3.11)。只有
85
113
  // /api/save 會把它接回檔案原本的 EOL;/api/render 一律用 \n。
86
114
  //
115
+ // 三種終止符,不是兩種(T7):marked 的 preprocess 把裸 \r 正規化成
116
+ // \n(實測 marked 14:'# H\rpara\r' → heading + paragraph 兩個
117
+ // token),所以 blockmap 會給出「第 2 行」這種行號;而 /\r\n|\n/
118
+ // 不拆裸 \r,`lines` 只有一個元素。行號與 lines 脫鉤之後,任何
119
+ // commit 的 replaceLines() 都會把 startLine 之後的內容整段吃掉——
120
+ // 實測 '# H\rpara\r' 編輯第一個 block 之後 'para' 直接消失。
121
+ // 拆行規則必須跟 marked 的換行定義一致。
122
+ //
87
123
  // 多數決,不是「有 CRLF 就算 CRLF」(final review I3):save 會把
88
124
  // `lines` 全部用同一個 eol 接回去,所以一萬行的 LF 檔裡混進一行
89
125
  // CRLF,舊式偵測會在第一次存檔時把一萬行全部改寫成 CRLF ——
@@ -92,12 +128,27 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
92
128
  // 用「\n 總數 − CRLF 數」算裸 LF,而不是 /(^|[^\r])\n/g:後者是
93
129
  // non-overlapping 比對,連續空行的第二個 \n 會被前一次比對吃掉的
94
130
  // 字元擋掉而漏數。減法沒有這個誤差。
131
+ //
132
+ // 裸 \r 也進多數決,理由跟上一段同一條:既然現在會拆它,一個純
133
+ // CR 檔(classic Mac)就會在第一次存檔時被整份改寫成 LF——正是
134
+ // §3.11 第 4 點禁止的事。平手一律 LF。
95
135
  const lfTotal = (mdText.match(/\n/g) || []).length;
136
+ const crTotal = (mdText.match(/\r/g) || []).length;
96
137
  const crlfCount = (mdText.match(/\r\n/g) || []).length;
97
- const eol = crlfCount > (lfTotal - crlfCount) ? '\r\n' : '\n';
138
+ const bareLf = lfTotal - crlfCount;
139
+ const bareCr = crTotal - crlfCount;
140
+ // Strict > on every comparison, so ANY tie falls through to LF — which
141
+ // is what the paragraph above promises. `>=` against bareCr handed a
142
+ // CR/CRLF tie to CRLF and contradicted it. Nothing else moves: a
143
+ // pure-CRLF file has bareCr === 0.
144
+ const eol = (crlfCount > bareLf && crlfCount > bareCr) ? '\r\n'
145
+ : (bareCr > bareLf && bareCr > crlfCount) ? '\r'
146
+ : '\n';
98
147
  const { html, blocks } = await renderMarkdown(mdText, file, { editMode: true });
148
+ const lines = mdText.split(/\r\n|\r|\n/);
149
+ assertBlockRangesFit(blocks, lines);
99
150
  const payload = JSON.stringify({
100
- fileId, mtimeMs, eol, lines: mdText.split(/\r\n|\n/), blocks,
151
+ fileId, mtimeMs, eol, lines, blocks,
101
152
  });
102
153
  const inject =
103
154
  `<script>window.__ED__ = ${payload.replace(/</g, '\\u003c')}</script>\n` +
@@ -106,6 +157,8 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
106
157
  `<script>${TABLE_MD_SRC}</script>\n` +
107
158
  `<script>${LIST_MD_SRC}</script>\n` +
108
159
  `<script>${HISTORY_SRC}</script>\n` +
160
+ `<script>${INDENT_CLAMP_SRC}</script>\n` +
161
+ `<script>${CONVERT_MD_SRC}</script>\n` +
109
162
  `<script>${clientJs}</script>\n`;
110
163
  // Splice at the LAST "</body>" — the document's real closing tag.
111
164
  // The first occurrence can sit inside an inlined diagram bundle's JS
@@ -201,4 +254,4 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
201
254
  };
202
255
  }
203
256
 
204
- module.exports = { createEditorServer };
257
+ module.exports = { createEditorServer, assertBlockRangesFit };