@helping-ai-workflow/md2doc 3.2.0 → 3.3.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.
@@ -63,35 +63,123 @@ function indexOfAtLineStart(content, needle, from) {
63
63
  return at;
64
64
  }
65
65
 
66
+ // The search above is a byte-for-byte `indexOf`, and marked does not hand back
67
+ // bytes that match byte-for-byte: a nested list token's `raw` has the trailing
68
+ // spaces stripped off its LAST line while the enclosing `item.text` keeps them.
69
+ // MEASURED on this repo's marked, for '# H\n\n- alpha\n - beta\n - \n- gamma\n':
70
+ //
71
+ // item.text "alpha\n- beta\n- " <- trailing space kept
72
+ // child list tk.raw "- beta\n-\n" <- trailing space gone, \n added
73
+ //
74
+ // so `content.indexOf(tk.raw)` was -1, and the guessed offset 0 that followed
75
+ // shifted every descendant's startLine UP one line. That is not a corner: the
76
+ // markdown the CLIENT serialises mid-session contains the shape:
77
+ // '# H\n\n- alpha\n - beta\n - \n- gamma\n' was captured coming back from
78
+ // client.js during a test/editor-journey.test.js run and mapped to an inverted
79
+ // range under the old search. Ordinary authored markdown does it too — an
80
+ // everyday flat-plus-nested list corrupts the moment a trailing space lands on
81
+ // its nested line. Neither of those two strings is itself a fixture — they came
82
+ // out of a capture harness and a mutation sweep that are not in the repo. What
83
+ // IS pinned, and what to read instead, is the shape FAMILY — grep 'trailing
84
+ // space' in test/blockmap.test.js for the range assertions, and 'eats the item
85
+ // above' in test/editor-journey.test.js for the gestures that used to do
86
+ // exactly that.
87
+ //
88
+ // Both sides are put through the same normalisation before the search: trailing
89
+ // spaces and tabs are dropped from every line. Two properties are relied on,
90
+ // and they are different from each other:
91
+ //
92
+ // LINE OFFSETS SURVIVE. The transform only ever removes characters that are
93
+ // not '\n', so it cannot change a line count — a character offset found in
94
+ // the normalised copy names the same LINE offset it would have named in the
95
+ // original, and a line offset is the only thing this function returns.
96
+ //
97
+ // SUBSTRINGS SURVIVE. If the needle occurred in the content before, the
98
+ // normalised needle occurs in the normalised content. Every '\n' inside the
99
+ // needle is the same '\n' in the content, so a space run sitting before it is
100
+ // stripped identically on both sides; the two can only disagree at the
101
+ // needle's own END, where the needle may lose a trailing run the content
102
+ // keeps. That only makes the needle SHORTER, and a prefix of a substring is
103
+ // still a substring. (Byte identity of two equal strings under a shared
104
+ // transform is the wrong lemma here — it says nothing about a needle whose
105
+ // surroundings differ from its own end.)
106
+ //
107
+ // The needle additionally loses its trailing newlines (`\n+$`), because the raw
108
+ // of a token that ends the item carries a terminator `item.text` does not have.
109
+ // That leaves `pos` on the newline (or newlines) that ended the consumed token
110
+ // rather than after them, so both searches below start earlier than they used
111
+ // to and a match could in principle land on one of those bytes. One argument
112
+ // rules that out for both searches at once, so it is the only one stated here:
113
+ // to match at a position occupied by a newline, the NEEDLE would have to begin
114
+ // with a newline. MEASURED over the journey capture plus a synthetic sweep of
115
+ // nested lists (blank lines, fenced code, blockquotes, tab indents, ordered and
116
+ // task markers): no nested list token's `raw` began with '\n'. Labelled
117
+ // MEASURED and not invariant — nothing downstream may assume it.
118
+ function stripTrailingLineSpace(s) {
119
+ return s.replace(/[ \t]+$/gm, '');
120
+ }
121
+
122
+ // Returns { offsets, unlocatable }. `offsets` keeps its old contract exactly:
123
+ // one entry per child list token, always, in order. `unlocatable` is true when
124
+ // at least one of those entries is a GUESS rather than a found position —
125
+ // see pushListItemBlocks(), which is what acts on it.
66
126
  function childListStartOffsets(item) {
67
- const content = typeof item.text === 'string' ? item.text : '';
127
+ const content = stripTrailingLineSpace(
128
+ typeof item.text === 'string' ? item.text : ''
129
+ );
68
130
  const offsets = [];
131
+ let unlocatable = false;
69
132
  let pos = 0;
70
133
  for (const tk of item.tokens || []) {
134
+ const needle = stripTrailingLineSpace(typeof tk.raw === 'string' ? tk.raw : '')
135
+ .replace(/\n+$/, '');
71
136
  if (tk.type === 'list') {
72
- let at = indexOfAtLineStart(content, tk.raw, pos);
73
- if (at < 0) at = content.indexOf(tk.raw, pos);
137
+ // An empty needle would make `indexOf` answer `pos` for any content, i.e.
138
+ // report a find that was never made. Treated as not-found instead.
139
+ let at = needle === '' ? -1 : indexOfAtLineStart(content, needle, pos);
140
+ if (at < 0 && needle !== '') at = content.indexOf(needle, pos);
74
141
  // Unlocatable (a shape this walk does not model): fall back to the line
75
142
  // the cursor is already on. Never skip — a missing block desynchronises
76
143
  // the render walk and takes the whole document down.
144
+ if (at < 0) unlocatable = true;
77
145
  offsets.push(at < 0 ? lineOffsetAt(content, pos) : lineOffsetAt(content, at));
78
- if (at >= 0) pos = at + tk.raw.length;
146
+ if (at >= 0) pos = at + needle.length;
79
147
  continue;
80
148
  }
81
149
  // `text` raws are synthesised; everything else is a faithful slice and is
82
150
  // consumed so later searches start beyond it.
83
151
  if (tk.type === 'text') continue;
84
- const at = content.indexOf(tk.raw, pos);
85
- if (at >= 0) pos = at + tk.raw.length;
152
+ // BEHAVIOUR CHANGE, small and deliberate: before the trailing-newline trim
153
+ // above, a `space` token (a blank-line run inside the item) had a needle of
154
+ // pure newlines and this branch consumed it, advancing `pos` past the blank
155
+ // run. Trimmed, that needle is '' — and `''.indexOf` answers `pos` for any
156
+ // content, i.e. a find that was never made — so the token is skipped and
157
+ // `pos` stays where it was, which means every later search starts earlier
158
+ // than it used to.
159
+ //
160
+ // What establishes that this is harmless is an ABLATION, not an argument: a
161
+ // copy of this function that still advances `pos` past an empty needle
162
+ // (using the untrimmed raw) produced block maps identical to these on every
163
+ // document of the journey capture, of its trailing-space mutations, and of a
164
+ // synthetic nested-list sweep. The same sweeps also show `space` was the
165
+ // only token type whose needle ever collapsed to '' — which is the only type
166
+ // that CAN, its raw being whitespace by construction.
167
+ if (needle === '') continue;
168
+ const at = content.indexOf(needle, pos);
169
+ if (at >= 0) pos = at + needle.length;
86
170
  }
87
- return offsets;
171
+ return { offsets, unlocatable };
88
172
  }
89
173
 
90
- function pushListItemBlocks(listToken, cursor, indent, blocks, nextId) {
174
+ // `inheritedUnlocatable` is how an item degrades its whole subtree: see the
175
+ // `degraded` note below the block literal.
176
+ function pushListItemBlocks(listToken, cursor, indent, blocks, nextId, inheritedUnlocatable) {
91
177
  for (const item of listToken.items) {
92
178
  const childListTokens = item.tokens.filter((t) => t.type === 'list');
93
179
  const totalSpan = trimmedLineCount(item.raw);
94
- const childLineOffsets = childListStartOffsets(item);
180
+ const located = childListStartOffsets(item);
181
+ const childLineOffsets = located.offsets;
182
+ const degraded = !!inheritedUnlocatable || located.unlocatable;
95
183
 
96
184
  // The item's OWN block covers its contiguous leading lines only — up to its
97
185
  // first child.
@@ -136,12 +224,58 @@ function pushListItemBlocks(listToken, cursor, indent, blocks, nextId) {
136
224
  // is never editable and no commit path can start on it. If you add a
137
225
  // consumer that walks block ranges, either honour that emptiness or check
138
226
  // for it; do not assume `startLine <= endLine`.
227
+ //
228
+ // DEGRADED SUBTREE. When childListStartOffsets() could not FIND a child
229
+ // list and had to guess its line, every line number computed from that
230
+ // guess is fiction, and the fiction does not stop at the parent: the guess
231
+ // is the offset each descendant's cursor is built from, so an entire nested
232
+ // run gets addresses belonging to the lines ABOVE it. That is not an
233
+ // abstract risk — it is the shape a user is in between typing a nested
234
+ // marker and typing its first character, and the observed result was that
235
+ // client.js's bystanderCarryOver() replayed the WRONG source line over a
236
+ // sibling: '- alpha\n - beta\n - \n' committed back as
237
+ // '- alpha\n - alpha\n-\n', with beta gone from the file.
238
+ //
239
+ // So the whole subtree — this item and, via `inheritedUnlocatable`, every
240
+ // item below it — is given an EMPTY range (endLine === startLine - 1)
241
+ // rather than a wrong one. Empty is a shape this codebase already defines
242
+ // and already defends: client.js's blockOwnsNoLine() is `endLine <
243
+ // startLine`, and the functions that call it directly are
244
+ // canWysiwygForLi() (arming), openRawEditor() (the raw textarea),
245
+ // insertBlockBelow() (the + paths, twice — anchor and subtree tail),
246
+ // resolveGutterOperands() (twice — where the ⠿ menu's 刪除 and every other
247
+ // grip operation resolves its targets, reached from deleteBlockViaGutter()
248
+ // through deleteBlockViaGutterBody()) and bystanderCarryOver() (the replay
249
+ // of untouched siblings). So an empty range refuses at every arming and
250
+ // commit boundary instead of writing somebody else's line.
251
+ //
252
+ // Every block is still PUSHED. lib/md2doc.js's edit-mode render walk
253
+ // consumes blocks[] in lockstep and throws on `biRef.v !== blocks.length`,
254
+ // so a skipped block takes the whole document down; an empty range costs
255
+ // that check nothing, and server.js's assertBlockRangesFit() only rejects
256
+ // `endLine > lines.length`, which a shortened range can never trip.
257
+ //
258
+ // `unlocatable` marks these records so the client can say WHY it refused.
259
+ // Same-line nesting ('- - a') produces an empty range too, and it is a
260
+ // legitimate, fully-modelled shape — it does not get the flag, and it
261
+ // keeps its own existing wording.
262
+ //
263
+ // ACCEPTED RESIDUAL: the degraded subtree's source lines end up owned by no
264
+ // block at all. lib/editor/selection.js's `ownsALine()` is
265
+ // `b.endLine >= b.startLine`, and membersOf() filters on it, so those lines
266
+ // fall out of every block selection — a drag across them selects the blocks
267
+ // either side and nothing in between. That is the price of emptying the
268
+ // subtree instead of corrupting it, and it is the right trade: a wrong
269
+ // range writes over somebody else's line, an absent one only cannot be
270
+ // selected. Unreachable as this is written (no markdown found so far makes
271
+ // the search fail); recorded here so whoever first reaches it meets this
272
+ // in the code rather than in the field.
139
273
  const ownSpan = childLineOffsets.length ? childLineOffsets[0] : totalSpan;
140
274
  const block = {
141
275
  id: nextId.v++,
142
276
  type: 'li',
143
277
  startLine: cursor,
144
- endLine: cursor + ownSpan - 1,
278
+ endLine: degraded ? cursor - 1 : cursor + ownSpan - 1,
145
279
  // Two independent axes (RULING F-N): GFM allows `1. [ ] a`, so
146
280
  // ordered-ness and task-ness cannot share one field. `listType` is the
147
281
  // LIST's type; `task` is the ITEM's.
@@ -150,12 +284,13 @@ function pushListItemBlocks(listToken, cursor, indent, blocks, nextId) {
150
284
  indent,
151
285
  };
152
286
  if (item.task) block.checked = !!item.checked;
287
+ if (degraded) block.unlocatable = true;
153
288
  blocks.push(block);
154
289
  // Every child is emitted, unconditionally: blocks[] is consumed in lockstep
155
290
  // by lib/md2doc.js's render walk, so a skipped child desynchronises the two
156
291
  // and the render runs off the end of the array.
157
292
  childListTokens.forEach((ct, k) => {
158
- pushListItemBlocks(ct, cursor + childLineOffsets[k], indent + 1, blocks, nextId);
293
+ pushListItemBlocks(ct, cursor + childLineOffsets[k], indent + 1, blocks, nextId, degraded);
159
294
  });
160
295
  // advance cursor by full item raw newlines; fall back to totalSpan if raw
161
296
  // has no trailing newline (EOF item).
package/lib/editor/cli.js CHANGED
@@ -5,11 +5,28 @@ const { createEditorServer } = require('./server.js');
5
5
 
6
6
  const CLIENT_SRC = fs.readFileSync(path.join(__dirname, 'client.js'), 'utf8');
7
7
 
8
- async function startEditSession({ files, port = null, open = true, quiet = false }) {
8
+ // F13: each exit path prints its own line. The tab-closed (idle) and
9
+ // never-opened lines come from a listener on server.js's
10
+ // 'md2doc-auto-close' event, since that side is what decides why the
11
+ // server is closing. The SIGINT line is printed here instead, because the
12
+ // signal lands on the process itself — server.js has no way to observe it.
13
+ const AUTO_CLOSE_MESSAGES = {
14
+ idle: 'md2doc: no browser activity for a while — closing this editor session.\n',
15
+ 'never-opened': 'md2doc: the editor link was never opened — closing this editor session.\n',
16
+ };
17
+
18
+ async function startEditSession({
19
+ files, port = null, open = true, quiet = false, idleTimeoutMs, neverOpenedGraceMs,
20
+ }) {
9
21
  const srv = await createEditorServer({
10
22
  files,
11
23
  clientJs: CLIENT_SRC,
12
24
  listenPort: port === null ? 0 : port,
25
+ // Included when the caller supplies a value; otherwise omitted so
26
+ // createEditorServer's own default applies, rather than a duplicated
27
+ // default here that could drift out of sync with it.
28
+ ...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
29
+ ...(neverOpenedGraceMs === undefined ? {} : { neverOpenedGraceMs }),
13
30
  });
14
31
  const urls = files.map((f) => srv.urlFor(f));
15
32
  for (const u of urls) process.stdout.write(u + '\n');
@@ -17,6 +34,38 @@ async function startEditSession({ files, port = null, open = true, quiet = false
17
34
  const { openViewer } = require('./open.js');
18
35
  for (const u of urls) openViewer(u);
19
36
  }
37
+ srv.server.on('md2doc-auto-close', (reason) => {
38
+ const msg = AUTO_CLOSE_MESSAGES[reason];
39
+ if (msg) process.stdout.write(msg);
40
+ });
41
+ // SIGINT previously had no handler: Node's default action kills the
42
+ // process before srv.server's 'close' listener below ever runs. Driven
43
+ // directly by spawning a child and sending it SIGINT before this fix:
44
+ // it exited with signal=SIGINT, code=null, and stdout held only the
45
+ // earlier URL line — no closing message. Printing here and driving the
46
+ // close through srv.close() (rather than letting the signal's default
47
+ // action finish the job) routes this exit through the same 'close'
48
+ // listener that already handles the idle and never-opened exits.
49
+ //
50
+ // T21 item 3: this handler REPLACES Node's default action, and the default
51
+ // action was the escape route — with a handler installed, a Ctrl+C that
52
+ // does not lead to an exit leaves the user with no way out but another
53
+ // terminal. Two things answer that here. srv.close() now lands the open
54
+ // sockets rather than waiting on them (see shutdownServer() in server.js
55
+ // for the preconnect measurement that made the default invocation hang
56
+ // where --no-open did not), and a SECOND SIGINT exits on the spot. The
57
+ // second one does not reprint the line: driven before this guard existed,
58
+ // two Ctrl+C's printed 'interrupted' twice and the process was still alive
59
+ // 12 s after the first. 130 is the conventional code for a process ending
60
+ // on SIGINT; the ordinary path still exits 0 through the 'close' listener
61
+ // below.
62
+ let interrupted = false;
63
+ process.on('SIGINT', () => {
64
+ if (interrupted) process.exit(130);
65
+ interrupted = true;
66
+ process.stdout.write('md2doc: interrupted — closing this editor session.\n');
67
+ srv.close();
68
+ });
20
69
  srv.server.on('close', () => process.exit(0));
21
70
  return srv;
22
71
  }