@helping-ai-workflow/md2doc 3.2.1 → 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.
- package/lib/editor/blockmap.js +146 -11
- package/lib/editor/cli.js +50 -1
- package/lib/editor/client.js +2236 -105
- package/lib/editor/server.js +75 -3
- package/lib/editor/toolbar-model.js +75 -14
- package/lib/md2doc.js +199 -20
- package/package.json +1 -1
package/lib/editor/blockmap.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
73
|
-
|
|
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 +
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|