@helping-ai-workflow/md2doc 3.3.0 → 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.
@@ -115,29 +115,128 @@
115
115
  unsupported.push('A');
116
116
  return '';
117
117
  }
118
- const label = walkChildren(node.childNodes, unsupported);
118
+ const label = walkChildren(node.childNodes, unsupported).md;
119
119
  return '[' + label + '](' + (href || '') + ')';
120
120
  }
121
121
 
122
+ // v3.4.0 / backlog 3 (Ruling T6-7, then a fix-round regression): STRONG
123
+ // and EM each emit a fixed, context-blind '*'-based delimiter for their
124
+ // own tag — '**'/'**' or '*'/'*' — with no awareness of what the
125
+ // accumulated sibling output already ends with. CommonMark scans the
126
+ // *raw character stream*, not the DOM/AST, and a "delimiter run" is a
127
+ // maximal span of identical adjacent characters: when one node's closing
128
+ // '*' is directly followed (no separating character) by the next
129
+ // sibling's opening '*', the two independent single-char delimiter
130
+ // EVENTS silently fuse into one length-2+ RUN in the emitted text, which
131
+ // marked's own flanking/pairing algorithm then matches on its own terms
132
+ // — not necessarily the pairing the DOM implied. Measured (task-8
133
+ // report): DOM `<em><code>code</code></em><em>text</em>` serialized to
134
+ // `*`code`**text*` — the merged `**` is grabbed by marked as its own
135
+ // delimiter run, and the FIRST <em> is dropped entirely, reappearing as
136
+ // two literal '*' characters around the code span on reload.
137
+ //
138
+ // The first version of this fix guarded on ANY trailing '*', which
139
+ // regressed a real journey scenario: `<em>it</em>` immediately followed
140
+ // by `<strong><em>al</em> bold</strong>` — EM's closing 1 star touching
141
+ // STRONG's opening 2+1 stars — serializes WITHOUT any guard to
142
+ // `*it****al* bold**`, which marked already parses correctly as-is
143
+ // (`<em>it</em><strong><em>al</em> bold</strong>`); inserting a comment
144
+ // there changed already-correct bytes for no reason, and
145
+ // editor-journey.test.js caught it. A 100-context sweep (reviewer-built:
146
+ // 5 front contexts × 5 back contexts × the 4 direct EM/STRONG pairings)
147
+ // measured the actual split: SAME-tag adjacency (EM-then-EM,
148
+ // STRONG-then-STRONG) breaks 50/50; CROSS-tag adjacency (EM-then-STRONG,
149
+ // STRONG-then-EM) is correct 50/50 — `*a*` immediately followed by
150
+ // `**b**` is exactly 3 touching stars, which CommonMark's own delimiter
151
+ // matching splits correctly without any help.
152
+ //
153
+ // Fix (task-8 report has the re-measured matrix): guard on the TAG of
154
+ // whichever STRONG/EM most recently closed at THIS sibling level, not on
155
+ // the trailing character. `lastMarkTag` (below) tracks it; a STRONG/EM
156
+ // node inserts `<!-- -->` only when `lastMarkTag` equals its OWN tag.
157
+ // `<!-- -->` contains no '*', so when it does fire it always ends the
158
+ // preceding run and starts a fresh one, regardless of what characters
159
+ // sit on either side — unlike switching to `_`/`__`, which was tried and
160
+ // rejected: `_`-based emphasis carries CommonMark's intraword
161
+ // restriction, and a switched closing `_` glued directly to a following
162
+ // word character (no space) fails to close at all (verified:
163
+ // `*`code`*_text_word` leaves `_text_word` unparsed, while the
164
+ // HTML-comment form parses `text` as EM in the same shape). The comment
165
+ // is inert on read-back too: `walkChildren` only handles nodeType 3
166
+ // (text) and 1 (element) and silently skips anything else (comment nodes
167
+ // are nodeType 8), so it never leaks into canWysiwyg's `unsupported`
168
+ // list or into a later re-serialize — a reparsed document with the
169
+ // comment still in the DOM just re-triggers this same guard on the next
170
+ // save, it does not accumulate.
171
+ //
172
+ // `lastMarkTag` also fixes review Minor 1 (a false trigger on a literal,
173
+ // already-escaped asterisk) as a side effect, with no regex needed: a
174
+ // TEXT node resets `lastMarkTag` to null whenever it contributes any
175
+ // characters at all, because escapeText() unconditionally escapes every
176
+ // literal '*' a user types — a text node's own trailing character is
177
+ // therefore NEVER a live, still-open '*' that a following STRONG/EM
178
+ // could collide with, so there is nothing to compare tags against. The
179
+ // text "a*" (typed literally, no italics intent) followed by an EM was a
180
+ // MORE common false trigger than the real bug under the old last-char
181
+ // check: it inserted a needless `<!-- -->` even though "a\*<em>x</em>"
182
+ // already round-trips correctly on its own.
183
+ //
184
+ // Every OTHER node type that contributes real, non-empty output (CODE,
185
+ // A, BR, DEL/S, U) also resets `lastMarkTag` to null: none of them ever
186
+ // end their own output in a live '*' (backtick, ')'/']]', '>', '~~'
187
+ // respectively), so nothing after them can be touching a star run.
188
+ // Nodes that contribute NOTHING to `out` (an empty text node, a
189
+ // `<span>` carrying attributes, an unrecognized tag pushed to
190
+ // `unsupported`) leave `lastMarkTag` untouched — correctly preserving
191
+ // whatever WAS touching before them, since they added no separating
192
+ // character.
193
+ //
194
+ // DIV and transparent (attribute-less) SPAN are genuine passthroughs —
195
+ // browsers wrap plain runs in bare <span>s, and a top-level <div> line
196
+ // splices its children inline rather than nesting — so `EM` immediately
197
+ // followed by `<span><em>x</em></span>` (or wrapped in a <div>) must
198
+ // still be treated as touching, same-tag EM adjacency: this is exactly
199
+ // the "透明節點覆蓋一點都沒少" requirement the coordinator called out.
200
+ // Both branches therefore propagate the RECURSIVE call's own
201
+ // `lastMarkTag` outward whenever that recursive call actually
202
+ // contributed characters (`inner.md.length`); if it contributed nothing,
203
+ // the DIV/SPAN itself contributed nothing either (SPAN) or only its own
204
+ // leading `<br>` (DIV, non-first segment) — a `<br>` is real, `*`-free
205
+ // output, so it resets to null; truly nothing added at all leaves the
206
+ // incoming `lastMarkTag` untouched, same as any other zero-output node.
207
+
122
208
  // Walk a sibling list. A top-level <div> is a contenteditable line-break
123
209
  // artifact (browsers split lines with <div> rather than <br>), so each
124
210
  // <div> boundary after the first emits a <br> and its children are
125
- // spliced inline, never nested further.
211
+ // spliced inline, never nested further. Returns `{ md, lastMarkTag }` —
212
+ // `lastMarkTag` is this call's OWN trailing-star-boundary state (see
213
+ // above), needed by DIV/SPAN passthrough and by nothing else: a caller
214
+ // that wraps its own delimiter around a recursive call's content (EM,
215
+ // STRONG, DEL/S, U, the citation label) only ever needs `.md`, because
216
+ // its own outer delimiter is a fixed, known tag regardless of what's
217
+ // nested inside.
126
218
  function walkChildren(nodes, unsupported) {
127
219
  let out = '';
128
220
  let firstSegment = true;
221
+ let lastMarkTag = null;
129
222
  for (let i = 0; i < nodes.length; i++) {
130
223
  const node = nodes[i];
131
224
  if (node.nodeType === 3) {
132
- out += escapeText(node.textContent);
225
+ const text = escapeText(node.textContent);
226
+ out += text;
227
+ if (text.length) lastMarkTag = null;
133
228
  firstSegment = false;
134
229
  continue;
135
230
  }
136
231
  if (node.nodeType !== 1) continue;
137
232
  const name = node.nodeName;
138
233
  if (name === 'DIV') {
139
- if (!firstSegment) out += '<br>';
140
- out += walkChildren(node.childNodes, unsupported);
234
+ let added = '';
235
+ if (!firstSegment) added += '<br>';
236
+ const inner = walkChildren(node.childNodes, unsupported);
237
+ added += inner.md;
238
+ out += added;
239
+ if (added.length) lastMarkTag = inner.md.length ? inner.lastMarkTag : null;
141
240
  firstSegment = false;
142
241
  continue;
143
242
  }
@@ -145,18 +244,24 @@
145
244
  if (spanHasAttributes(node)) {
146
245
  unsupported.push(name);
147
246
  } else {
148
- out += walkChildren(node.childNodes, unsupported);
247
+ const inner = walkChildren(node.childNodes, unsupported);
248
+ out += inner.md;
249
+ if (inner.md.length) lastMarkTag = inner.lastMarkTag;
149
250
  }
150
251
  firstSegment = false;
151
252
  continue;
152
253
  }
153
254
  if (name === 'STRONG' || name === 'B') {
154
- out += '**' + walkChildren(node.childNodes, unsupported) + '**';
255
+ out += (lastMarkTag === 'STRONG' ? '<!-- -->' : '') +
256
+ '**' + walkChildren(node.childNodes, unsupported).md + '**';
257
+ lastMarkTag = 'STRONG';
155
258
  firstSegment = false;
156
259
  continue;
157
260
  }
158
261
  if (name === 'EM' || name === 'I') {
159
- out += '*' + walkChildren(node.childNodes, unsupported) + '*';
262
+ out += (lastMarkTag === 'EM' ? '<!-- -->' : '') +
263
+ '*' + walkChildren(node.childNodes, unsupported).md + '*';
264
+ lastMarkTag = 'EM';
160
265
  firstSegment = false;
161
266
  continue;
162
267
  }
@@ -166,7 +271,8 @@
166
271
  // accepted on input (some contenteditable/paste paths produce it)
167
272
  // but the toolbar/serializer always speak DEL, matching what marked
168
273
  // itself emits.
169
- out += '~~' + walkChildren(node.childNodes, unsupported) + '~~';
274
+ out += '~~' + walkChildren(node.childNodes, unsupported).md + '~~';
275
+ lastMarkTag = null;
170
276
  firstSegment = false;
171
277
  continue;
172
278
  }
@@ -181,17 +287,20 @@
181
287
  // backtick, literal `<u>` typed as plain text is already escaped by
182
288
  // the `<` -> `&lt;` rule above, so it can never collide with a real
183
289
  // toolbar-made underline.
184
- out += '<u>' + walkChildren(node.childNodes, unsupported) + '</u>';
290
+ out += '<u>' + walkChildren(node.childNodes, unsupported).md + '</u>';
291
+ lastMarkTag = null;
185
292
  firstSegment = false;
186
293
  continue;
187
294
  }
188
295
  if (name === 'CODE') {
189
296
  out += serializeCode(node);
297
+ lastMarkTag = null;
190
298
  firstSegment = false;
191
299
  continue;
192
300
  }
193
301
  if (name === 'A') {
194
302
  out += serializeAnchor(node, unsupported);
303
+ lastMarkTag = null;
195
304
  firstSegment = false;
196
305
  continue;
197
306
  }
@@ -221,18 +330,19 @@
221
330
  const hardBreak = typeof node.getAttribute === 'function' &&
222
331
  node.getAttribute('data-hard-break') === '1';
223
332
  out += hardBreak ? '\\\n' : '<br>';
333
+ lastMarkTag = null;
224
334
  firstSegment = false;
225
335
  continue;
226
336
  }
227
337
  unsupported.push(name);
228
338
  firstSegment = false;
229
339
  }
230
- return out;
340
+ return { md: out, lastMarkTag };
231
341
  }
232
342
 
233
343
  function serializeInline(rootEl) {
234
344
  const unsupported = [];
235
- const md = walkChildren(rootEl.childNodes, unsupported);
345
+ const md = walkChildren(rootEl.childNodes, unsupported).md;
236
346
  return { md, unsupported };
237
347
  }
238
348
 
@@ -23,12 +23,48 @@
23
23
  );
24
24
  }
25
25
 
26
+ // ── v3.4.0 batch3 Task 7 fix 2: what "saved" is allowed to mean ──────────
27
+ //
28
+ // `_savedDepth` marks WHERE ON `_done` the bytes that are on disk were
29
+ // reached. It was an unconditional index, and an index into a stack that can
30
+ // be rewound is not a fact about the document — it is a second record of one,
31
+ // and it drifts.
32
+ //
33
+ // MEASURED on the shipped v3.3.0 behaviour, with no wave editor anywhere near
34
+ // it: type into a paragraph, commit, Ctrl+S, Ctrl+Z, then make one more
35
+ // ordinary edit. `_done.length` is 1 again and `_savedDepth` is still 1, so
36
+ // `dirtyDepth` is 0 — `documentIsDirty()` answers false, the title dot goes
37
+ // out, the save button greys, `beforeunload` does not fire, and the conflict
38
+ // banner's Reload discards BOTH the undone save and the new edit without
39
+ // asking. The depth arithmetic said "you are standing where you saved"; the
40
+ // user was standing somewhere else entirely, because the branch that led to
41
+ // the save point had been replaced.
42
+ //
43
+ // The invariant this restores is the only one that is worth anything here:
44
+ // **"not dirty" means what is in memory equals what is on disk.** A counter
45
+ // that can pass through zero from below cannot express it, so the marker is
46
+ // INVALIDATED — set to null — the moment history diverges past it, which is
47
+ // exactly the moment a `push()` happens below it. From then on the saved
48
+ // state is not reachable anywhere on this stack and the honest answer to "are
49
+ // we standing on it" is no, until `markSaved()` establishes a new one.
50
+ //
51
+ // Undo alone does NOT invalidate: undo then redo puts the same ops back and
52
+ // the marker still names the same state, which is why the rewind itself is
53
+ // not the divergence — the push that replaces the rewound branch is.
26
54
  function UndoStack() {
27
55
  this._done = [];
28
56
  this._undone = [];
57
+ // null = "the saved state is not on this stack any more". Callers must not
58
+ // read it as a number; ask `isDirty()`.
29
59
  this._savedDepth = 0;
30
60
  }
31
61
  UndoStack.prototype.push = function (op) {
62
+ if (this._savedDepth !== null && this._done.length < this._savedDepth) {
63
+ // History has been rewound past the save point and is now growing a
64
+ // different branch: the ops that produced the saved bytes are gone from
65
+ // this stack and no sequence of undo/redo can get back to them.
66
+ this._savedDepth = null;
67
+ }
32
68
  this._done.push(op);
33
69
  this._undone.length = 0;
34
70
  };
@@ -60,11 +96,162 @@
60
96
  const span = { startLine: op.startLine, endLine: op.startLine + op.after.length - 1 };
61
97
  return { lines: replaceLines(lines, span.startLine, span.endLine, op.before).lines, op };
62
98
  };
99
+ // v3.4.0 batch3 Task 7 fix 1 (F3) — read and write the REDO tail.
100
+ //
101
+ // `push()` clears `_undone`, and that is correct for an ordinary edit: once
102
+ // the document has moved forward, the branch that was undone is gone. It is
103
+ // NOT correct for an edit that is later withdrawn as if it had never
104
+ // happened — `discardTop()` can put the bytes and the depth back, but it has
105
+ // no way to put the branch back, so a caller that discards its own ops
106
+ // silently consumes a redo the user had before it started. MEASURED on the
107
+ // wave editor: type into a paragraph, commit, Ctrl+Z (the edit is redoable),
108
+ // open the wave editor, paint, Escape — and Ctrl+Y then brings nothing back,
109
+ // over a document that is byte-identical to the one that had the redo.
110
+ //
111
+ // So the tail is a value a caller can take before it starts and hand back
112
+ // when it withdraws. Deliberately two plain accessors rather than a
113
+ // "transaction" API: the only caller is `finishWaveSession()`, the copies
114
+ // keep `_undone` unaliased, and an ordinary push still clears it exactly as
115
+ // before.
116
+ UndoStack.prototype.redoTail = function () {
117
+ return this._undone.slice();
118
+ };
119
+ UndoStack.prototype.setRedoTail = function (tail) {
120
+ this._undone = Array.isArray(tail) ? tail.slice() : [];
121
+ };
122
+ // Does memory differ from disk? The one question the product actually asks,
123
+ // and a boolean rather than a distance precisely because a distance is what
124
+ // walked through zero. `documentIsDirty()` in client.js reads this.
125
+ UndoStack.prototype.isDirty = function () {
126
+ return this._savedDepth === null || this._done.length !== this._savedDepth;
127
+ };
128
+ // How far the stack has moved since the save point, for diagnostics and for
129
+ // tests that want to see the shape of a sequence. When the marker has been
130
+ // invalidated there IS no distance — the state it named is not on the stack —
131
+ // so this answers with a value that is deliberately never 0, and callers that
132
+ // only want the yes/no must ask `isDirty()` instead of comparing this.
63
133
  Object.defineProperty(UndoStack.prototype, 'dirtyDepth', {
64
- get() { return this._done.length - this._savedDepth; },
134
+ get() {
135
+ if (this._savedDepth === null) return this._done.length + 1;
136
+ return this._done.length - this._savedDepth;
137
+ },
138
+ });
139
+ // How many ops are on the done-stack. Exposed so a caller that pushed N ops
140
+ // can check that the top N are still its own — a question about the STACK,
141
+ // with no premise about the save marker, which is what makes it composable
142
+ // with `savedDepth` below rather than entangled with it.
143
+ Object.defineProperty(UndoStack.prototype, 'depth', {
144
+ get() { return this._done.length; },
145
+ });
146
+ // WHERE the marker is, or null when the saved state is not on this stack any
147
+ // more. A caller that needs to reason about a range of the stack — "would
148
+ // removing my own N ops destroy the state disk was written from?" — needs the
149
+ // position, not the distance. It is deliberately nullable rather than a
150
+ // sentinel number: arithmetic on it is exactly the mistake this whole rework
151
+ // is about, and `null` makes that arithmetic loud instead of plausible.
152
+ Object.defineProperty(UndoStack.prototype, 'savedDepth', {
153
+ get() { return this._savedDepth; },
65
154
  });
66
- UndoStack.prototype.markSaved = function () {
67
- this._savedDepth = this._done.length;
155
+ /**
156
+ * A receipt for "these are the bytes I am about to write to disk".
157
+ *
158
+ * Taken BEFORE the save request leaves, and handed back to `markSaved()` when
159
+ * the response lands. See markSaved() for the measured race this closes.
160
+ *
161
+ * The receipt is a depth plus the IDENTITY of the op at that depth's
162
+ * boundary. The depth alone is not enough: the user can undo and re-edit
163
+ * during a round trip, and the stack is then back at the same depth holding a
164
+ * different op — measured, that combination marked bytes as saved that disk
165
+ * had never held. Op objects are created fresh by each commit, so identity is
166
+ * an exact fingerprint and costs a reference.
167
+ *
168
+ * ── Why ONE boundary op is enough (fix 4 / R5) ─────────────────────────
169
+ *
170
+ * It looks like an under-check: the receipt fingerprints a single op, yet it
171
+ * is trusted to mean "the whole prefix `_done[0 .. depth-1]` is unchanged".
172
+ * It holds, and the argument is worth having in one place rather than
173
+ * reconstructed from four:
174
+ *
175
+ * 1. The timeline is only ever edited at its TOP. `push()` appends,
176
+ * `undo()` moves the top entry onto `_undone`, `redo()` moves it back,
177
+ * and `discardTop()` drops it. Nothing in this file writes at an
178
+ * interior index — there is no splice, no sort, no assignment to
179
+ * `_done[i]`. So a prefix can only change by first being exposed, i.e.
180
+ * by everything above it coming off.
181
+ * 2. Therefore, if the op at index `depth-1` is still that same object,
182
+ * nothing ever popped BELOW `depth-1` in between — had it, that op would
183
+ * have come off too, and the only way an identical object returns is
184
+ * `redo()`, which also restores every entry below it unchanged (it pops
185
+ * `_undone` in the exact reverse order `undo()` pushed it).
186
+ * 3. Identity, not equality: a commit builds a fresh `{startLine, endLine,
187
+ * before, after}` literal every time, so two different edits can never
188
+ * share an object, and a re-edit that happens to produce the same bytes
189
+ * is still a different object — which is the conservative direction.
190
+ * 4. `setRedoTail()` is the one place `_undone` is replaced wholesale, and
191
+ * it is handed back exactly the array `redoTail()` copied out, so the
192
+ * objects in it are the same objects. A wave session's withdrawal
193
+ * therefore restores the timeline, it does not fabricate one.
194
+ *
195
+ * The check is conservative where it is imperfect: anything it cannot prove
196
+ * unchanged reads as changed, and a refused receipt leaves the document
197
+ * DIRTY. Its failure direction is over-warning.
198
+ */
199
+ UndoStack.prototype.saveToken = function () {
200
+ return {
201
+ depth: this._done.length,
202
+ op: this._done.length === 0 ? null : this._done[this._done.length - 1],
203
+ };
204
+ };
205
+
206
+ /**
207
+ * Where on the timeline `token.depth - 1` now lives, whether it has since been
208
+ * undone or not — `undefined` when that index is off the end of both stacks.
209
+ *
210
+ * An op that was undone during a save round trip is still the same op and can
211
+ * still be redone back into place, so it must NOT read as a divergence; it
212
+ * has merely moved from `_done` onto `_undone`, which is a LIFO, hence the
213
+ * mirrored index.
214
+ */
215
+ function timelineOpAt(stack, index) {
216
+ if (index < 0) return null;
217
+ if (index < stack._done.length) return stack._done[index];
218
+ const j = stack._done.length + stack._undone.length - 1 - index;
219
+ if (j < 0 || j >= stack._undone.length) return undefined;
220
+ return stack._undone[j];
221
+ }
222
+
223
+ /**
224
+ * Record that disk now holds the bytes the given receipt describes.
225
+ *
226
+ * MEASURED before the receipt existed, with no wave editor involved: type,
227
+ * commit, Ctrl+S, then press Ctrl+Z while the request is still in flight. The
228
+ * 200 arrived and marked whatever depth happened to exist AT THAT MOMENT —
229
+ * the post-undo one — as the saved point, so `isDirty()` answered false over a
230
+ * document that differs from disk. All three nets failed together there: the
231
+ * title dot, the unload guard, AND the conflict check (the response had just
232
+ * advanced `mtimeMs`, so the next save would not 409 either). Closing the tab
233
+ * left on disk an edit the user had explicitly undone.
234
+ *
235
+ * With a receipt the marker is established against the depth the bytes were
236
+ * taken from, not the depth the reply happened to find — and only if the
237
+ * timeline at that boundary is still the same one. Where it is not, the saved
238
+ * state is not reachable on this stack at all and the marker is invalidated,
239
+ * which is the same answer `push()` gives for the same reason.
240
+ *
241
+ * No argument still means "right here, right now", which is what every
242
+ * synchronous caller and every test wants.
243
+ */
244
+ UndoStack.prototype.markSaved = function (token) {
245
+ if (token === undefined || token === null) {
246
+ this._savedDepth = this._done.length;
247
+ return true;
248
+ }
249
+ if (timelineOpAt(this, token.depth - 1) !== token.op) {
250
+ this._savedDepth = null;
251
+ return false;
252
+ }
253
+ this._savedDepth = token.depth;
254
+ return true;
68
255
  };
69
256
 
70
257
  return { replaceLines, insertLines, shiftBlocks, UndoStack };