@helping-ai-workflow/md2doc 3.0.0 → 3.0.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/lib/editor/client.js +370 -102
- package/lib/editor/table-md.js +411 -24
- package/lib/md2doc.js +123 -78
- package/package.json +2 -2
package/lib/editor/table-md.js
CHANGED
|
@@ -29,10 +29,38 @@
|
|
|
29
29
|
// nothing to do with text alignment. Reading them as alignment would be a
|
|
30
30
|
// bug; they are deliberately never consulted below.
|
|
31
31
|
//
|
|
32
|
-
// ── Emission form (spec §4 / task-5 brief)
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
32
|
+
// ── Emission form (spec §4 / task-5 brief, amended by v3.0.1) ──────────
|
|
33
|
+
// There are TWO emission forms, and exactly one gate selects between them:
|
|
34
|
+
// whether serializeTable() was handed an `originalMd` it could turn into a
|
|
35
|
+
// usable width source (columnWidthsOf() parsed it, AND the greedy
|
|
36
|
+
// header-TEXT walk in serializeTable() matched EVERY original column, in
|
|
37
|
+
// order — the all-or-nothing gate). No second argument, an unparseable or
|
|
38
|
+
// ragged original, a renamed header, a dropped column: all of them land on
|
|
39
|
+
// form A.
|
|
40
|
+
//
|
|
41
|
+
// A. MINIMAL form (pre-v3.0.1, unchanged, and still what
|
|
42
|
+
// `serializeTable(tableEl)` with no second argument emits — byte for
|
|
43
|
+
// byte): '| a | b |' rows with single-space padding; a '|---|'
|
|
44
|
+
// separator row with no padding around the dashes, ':' alignment
|
|
45
|
+
// variants (':---' / '---:' / ':---:'), dashes never stretched to
|
|
46
|
+
// content width, always at least 3 of them.
|
|
47
|
+
//
|
|
48
|
+
// B. WIDTH-PRESERVING form (v3.0.1 欄寬保留): every row of a column,
|
|
49
|
+
// header and body alike, is padded out to that column's preserved
|
|
50
|
+
// width, and the separator's dash run IS stretched to the same width
|
|
51
|
+
// — with NO 3-dash floor, because a legal hand-aligned '|:-:|' must
|
|
52
|
+
// not be widened (see sepCellFor() for that floor's exact scope). A
|
|
53
|
+
// preserved width only exists for a column whose original fields were
|
|
54
|
+
// the same width on every row AND wider than 3; a matched column that
|
|
55
|
+
// fails that test still reuses its ORIGINAL separator field's width
|
|
56
|
+
// rather than collapsing to the minimal 3. On top of that, F1: when a
|
|
57
|
+
// matched column's original separator field already expresses the
|
|
58
|
+
// column's current alignment and is already exactly the target width,
|
|
59
|
+
// that field is re-emitted VERBATIM — padding and all — so the padded
|
|
60
|
+
// separators Prettier and VS Code emit ('| ----- | --- |') survive a
|
|
61
|
+
// no-op instead of being rebuilt flush ('|-------|-----|').
|
|
62
|
+
//
|
|
63
|
+
// Independent of the form: a literal '|' inside
|
|
36
64
|
// a cell -> '|'; a cell newline is the literal '<br>' inline-md.js's
|
|
37
65
|
// serializeInline() already emits for both a real <br> node and a
|
|
38
66
|
// contenteditable <div> line-split boundary — AND (final-review Finding 1
|
|
@@ -121,6 +149,116 @@
|
|
|
121
149
|
return false;
|
|
122
150
|
}
|
|
123
151
|
|
|
152
|
+
// ── v3.0.1 欄寬保留 ────────────────────────────────────────────────────
|
|
153
|
+
// A cell FIELD is the text between two '|' delimiters, PADDING INCLUDED:
|
|
154
|
+
// in '| a | b |' the fields are ' a ' and ' b '. A column only counts
|
|
155
|
+
// as a deliberately hand-aligned width source when its field is the SAME
|
|
156
|
+
// width on EVERY row of the original (header, separator, every body row)
|
|
157
|
+
// AND that width is greater than 3 — see columnWidthsOf() below for why
|
|
158
|
+
// both conditions are required (C2, 2026-09-02 review round 1: a uniform
|
|
159
|
+
// width of 3 is indistinguishable from this serializer's own minimal
|
|
160
|
+
// form, which is exactly 3 on every row by construction).
|
|
161
|
+
//
|
|
162
|
+
// A raw '|' never appears inside a cell in source THIS serializer produced
|
|
163
|
+
// (it emits '|'), but a hand-written table may use the '\|' escape, so
|
|
164
|
+
// the split must not break on an escaped delimiter.
|
|
165
|
+
function splitFields(line) {
|
|
166
|
+
const t = line.trim();
|
|
167
|
+
if (t.charAt(0) !== '|' || t.charAt(t.length - 1) !== '|' || t.length < 2) return null;
|
|
168
|
+
const inner = t.slice(1, -1);
|
|
169
|
+
const out = [];
|
|
170
|
+
let cur = '';
|
|
171
|
+
for (let i = 0; i < inner.length; i++) {
|
|
172
|
+
const c = inner.charAt(i);
|
|
173
|
+
if (c === '\\' && inner.charAt(i + 1) === '|') { cur += '\\|'; i += 1; continue; }
|
|
174
|
+
if (c === '|') { out.push(cur); cur = ''; continue; }
|
|
175
|
+
cur += c;
|
|
176
|
+
}
|
|
177
|
+
out.push(cur);
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// `{ widths, headerFields, sepFields }` describing the ORIGINAL table, or
|
|
182
|
+
// null when `originalMd` is absent or is not a table this can read. null
|
|
183
|
+
// means "no width source" and every caller below then emits minimal form
|
|
184
|
+
// — the pre-v3.0.1 behaviour, unchanged. `headerFields` (each original
|
|
185
|
+
// header field, trimmed) is what serializeTable() below uses to line a
|
|
186
|
+
// CURRENT column back up with the original column it corresponds to — see
|
|
187
|
+
// the comment at its call site for why index alone is not enough.
|
|
188
|
+
// `sepFields` (each original SEPARATOR field VERBATIM — padding, colons and
|
|
189
|
+
// all; always captured, independent of the `uniform`/`> 3` gate below)
|
|
190
|
+
// serves the separator emission site twice: its LENGTH is the fallback
|
|
191
|
+
// width for a matched column whose header/body rows aren't uniform (I1,
|
|
192
|
+
// 2026-09-02 review round 1), and the field ITSELF is re-emitted unchanged
|
|
193
|
+
// when nothing about that column's separator actually changed (F1,
|
|
194
|
+
// 2026-09-02 final review) — see sepFieldAlign() and the emission site.
|
|
195
|
+
//
|
|
196
|
+
// A column only counts as a width source when its field is the SAME width
|
|
197
|
+
// on EVERY row (header, separator, every body row) — that is what a
|
|
198
|
+
// deliberately hand-aligned column looks like — AND that width is > 3. A
|
|
199
|
+
// column whose original fields are NOT all equal was never hand-aligned to
|
|
200
|
+
// begin with: it's v3.0.0's un-padded minimal form, where each row's field
|
|
201
|
+
// is only as wide as that row's own content happens to be (e.g. header
|
|
202
|
+
// field 6, a body field 7, another body field 5 — three different
|
|
203
|
+
// numbers, no alignment to preserve). The `> 3` half of the gate catches
|
|
204
|
+
// the case that slips past the uniform check alone: a minimal-form table
|
|
205
|
+
// (single-char cells, say) reports the SAME width — exactly 3 — on every
|
|
206
|
+
// row, because ' a ' and '---' are both 3 characters; that is what THIS
|
|
207
|
+
// serializer's own minimal form always looks like, not evidence of a
|
|
208
|
+
// deliberate alignment (C2, review round 1). Without the `> 3` half, an
|
|
209
|
+
// ordinary minimal-form table would misread itself as "already aligned"
|
|
210
|
+
// and start re-widening on every edit — the header, the separator, AND
|
|
211
|
+
// every untouched row — even though v3.0.0 left all three byte-identical.
|
|
212
|
+
// Reporting `undefined` for such a column — rather than the width seen —
|
|
213
|
+
// means every caller's `widths[i] || 0` / `row[i] === undefined` checks
|
|
214
|
+
// fall through to independent per-row minimal form for it, exactly as if
|
|
215
|
+
// the column had no original at all.
|
|
216
|
+
function columnWidthsOf(originalMd) {
|
|
217
|
+
if (typeof originalMd !== 'string' || originalMd === '') return null;
|
|
218
|
+
const rows = originalMd.split('\n').filter((l) => l.trim() !== '');
|
|
219
|
+
if (rows.length < 2) return null;
|
|
220
|
+
const first = splitFields(rows[0]);
|
|
221
|
+
if (!first) return null;
|
|
222
|
+
const widths = first.map((f) => f.length);
|
|
223
|
+
const uniform = widths.map(() => true);
|
|
224
|
+
let sepFields = null;
|
|
225
|
+
for (let r = 1; r < rows.length; r++) {
|
|
226
|
+
const fields = splitFields(rows[r]);
|
|
227
|
+
// A ragged original is not a width source — bail rather than guess.
|
|
228
|
+
if (!fields || fields.length !== widths.length) return null;
|
|
229
|
+
if (r === 1) sepFields = fields.slice();
|
|
230
|
+
for (let c = 0; c < fields.length; c++) {
|
|
231
|
+
if (fields[c].length !== widths[c]) uniform[c] = false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
for (let c = 0; c < widths.length; c++) {
|
|
235
|
+
if (!uniform[c] || widths[c] <= 3) widths[c] = undefined;
|
|
236
|
+
}
|
|
237
|
+
return { widths, headerFields: first.map((f) => f.trim()), sepFields };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ' content ' right-padded out to `width`. NEVER truncates: a cell whose
|
|
241
|
+
// content grew past the original column simply widens that column, and a
|
|
242
|
+
// deliberately blank hand-aligned cell (e.g. `| |` kept wide on
|
|
243
|
+
// purpose) is reproduced at its full original width, never shrunk down to
|
|
244
|
+
// its own natural 2 chars — padding is a MINIMUM, not a maximum;
|
|
245
|
+
// truncating here would delete the user's alignment (or their text).
|
|
246
|
+
//
|
|
247
|
+
// (2026-09-02 review round 1, C2: an earlier version special-cased an
|
|
248
|
+
// EMPTY cell to always emit the natural 2-char field, to stop a freshly
|
|
249
|
+
// hover-inserted row/column from being padded out to its column's width.
|
|
250
|
+
// That was a misdiagnosis — the actual cause was columnWidthsOf() treating
|
|
251
|
+
// a uniform width of 3 (== this serializer's own minimal form) as a
|
|
252
|
+
// hand-aligned width source, so an ordinary just-inserted minimal-form
|
|
253
|
+
// column looked "aligned" and got synced. Fixing that at the source (the
|
|
254
|
+
// `> 3` gate above) made the empty-cell special case both unnecessary AND
|
|
255
|
+
// actively wrong — it silently discarded a genuinely blank, intentionally
|
|
256
|
+
// padded cell's width in an aligned table. Removed.)
|
|
257
|
+
function padField(content, width) {
|
|
258
|
+
const field = ' ' + content + ' ';
|
|
259
|
+
return field.length >= width ? field : field + ' '.repeat(width - field.length);
|
|
260
|
+
}
|
|
261
|
+
|
|
124
262
|
function cellAlign(cell) {
|
|
125
263
|
const style = cell.getAttribute('style');
|
|
126
264
|
if (!style) return null;
|
|
@@ -128,24 +266,65 @@
|
|
|
128
266
|
return m ? m[1] : null;
|
|
129
267
|
}
|
|
130
268
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
269
|
+
// The alignment an ORIGINAL separator field already expresses, in the same
|
|
270
|
+
// vocabulary cellAlign() above returns ('left' / 'right' / 'center' /
|
|
271
|
+
// null), so the two can be compared directly at the emission site.
|
|
272
|
+
// Returns `undefined` — which cellAlign() can never return — for anything
|
|
273
|
+
// that is not a bare separator field, so a field this can't fully account
|
|
274
|
+
// for can never be re-emitted verbatim. F1 (2026-09-02 final review).
|
|
275
|
+
function sepFieldAlign(field) {
|
|
276
|
+
const t = field.trim();
|
|
277
|
+
if (!/^:?-+:?$/.test(t)) return undefined;
|
|
278
|
+
const l = t.charAt(0) === ':';
|
|
279
|
+
const r = t.charAt(t.length - 1) === ':';
|
|
280
|
+
if (l && r) return 'center';
|
|
281
|
+
if (l) return 'left';
|
|
282
|
+
if (r) return 'right';
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The dash run is stretched to `width`, minus whatever the colons consume.
|
|
287
|
+
// The 3-dash floor belongs ONLY to the no-width path (`w === 0`): that is
|
|
288
|
+
// GFM's own minimum, and it's also exactly the minimal form ('---' /
|
|
289
|
+
// ':---' / '---:' / ':---:') a width of 0 produces — what makes "no
|
|
290
|
+
// original" and "minimal-form original" the same answer. A KNOWN width
|
|
291
|
+
// (`w > 0`) must NOT be floored up to 3: a narrow hand-aligned separator
|
|
292
|
+
// like '|:--:|' (width 4) or '|:-:|' (width 3) is legal GFM (marked lexes
|
|
293
|
+
// it as one table token with `align === 'center'`), and flooring it to
|
|
294
|
+
// '|:---:|' rewrites a line the user never touched — C1 (2026-09-02
|
|
295
|
+
// review round 1): every center-aligned column narrower than 5, and every
|
|
296
|
+
// left/right-aligned column narrower than 4, silently widened on an
|
|
297
|
+
// otherwise-unchanged table, which made commitEdit() write the file and
|
|
298
|
+
// push an undo entry on a plain click-in/click-away. Floor of 1 instead:
|
|
299
|
+
// a dash run can't go negative once the colon(s) are subtracted, but it's
|
|
300
|
+
// otherwise free to reproduce whatever width the original actually had.
|
|
301
|
+
function sepCellFor(align, width) {
|
|
302
|
+
const w = typeof width === 'number' ? width : 0;
|
|
303
|
+
const floor = w > 0 ? 1 : 3;
|
|
304
|
+
if (align === 'left') return ':' + '-'.repeat(Math.max(floor, w - 1));
|
|
305
|
+
if (align === 'right') return '-'.repeat(Math.max(floor, w - 1)) + ':';
|
|
306
|
+
if (align === 'center') return ':' + '-'.repeat(Math.max(floor, w - 2)) + ':';
|
|
307
|
+
return '-'.repeat(Math.max(floor, w));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// The cell's markdown content, WITHOUT padding. Rendered exactly once per
|
|
311
|
+
// cell (never twice — inlineMd.serializeInline() would double-report
|
|
312
|
+
// `unsupported` for the same cell), so width measurement and emission
|
|
313
|
+
// below both read from these same strings.
|
|
314
|
+
function cellContentOf(cell, unsupported) {
|
|
315
|
+
if (cellHasCodePipe(cell)) unsupported.push('CODE');
|
|
316
|
+
const res = inlineMd.serializeInline(cell);
|
|
317
|
+
res.unsupported.forEach((u) => unsupported.push(u));
|
|
318
|
+
return escapeNewlines(escapePipes(res.md));
|
|
136
319
|
}
|
|
137
320
|
|
|
138
|
-
function serializeRow(
|
|
139
|
-
const parts =
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
res.unsupported.forEach((u) => unsupported.push(u));
|
|
143
|
-
return escapeNewlines(escapePipes(res.md));
|
|
144
|
-
});
|
|
145
|
-
return '| ' + parts.join(' | ') + ' |';
|
|
321
|
+
function serializeRow(contents, widths) {
|
|
322
|
+
const parts = contents.map((content, i) =>
|
|
323
|
+
padField(content, widths ? (widths[i] || 0) : 0));
|
|
324
|
+
return '|' + parts.join('|') + '|';
|
|
146
325
|
}
|
|
147
326
|
|
|
148
|
-
function serializeTable(tableEl) {
|
|
327
|
+
function serializeTable(tableEl, originalMd) {
|
|
149
328
|
const unsupported = [];
|
|
150
329
|
const thead = firstChildNamed(tableEl, 'THEAD');
|
|
151
330
|
const tbody = firstChildNamed(tableEl, 'TBODY');
|
|
@@ -165,14 +344,222 @@
|
|
|
165
344
|
return { md: '', unsupported: ['TABLE_RAGGED'] };
|
|
166
345
|
}
|
|
167
346
|
|
|
347
|
+
// Render every cell's content up front (same order the pre-v3.0.1 code
|
|
348
|
+
// walked: header first, then each body row left-to-right), so the width
|
|
349
|
+
// pass below and the emission pass after it share one rendering.
|
|
350
|
+
const headerContents = headerCells.map((c) => cellContentOf(c, unsupported));
|
|
351
|
+
const bodyContentRows = bodyRows.map((tr) =>
|
|
352
|
+
elementChildren(tr).filter(isCell).map((c) => cellContentOf(c, unsupported)));
|
|
353
|
+
|
|
354
|
+
// Read the ORIGINAL widths first, then line each CURRENT header column
|
|
355
|
+
// up with the original column it corresponds to BY TEXT, not by raw
|
|
356
|
+
// index. A column inserted in the MIDDLE (insertColumn() in client.js)
|
|
357
|
+
// shifts every later column's index by one without changing what that
|
|
358
|
+
// column actually is — matching by index alone would hand the shifted
|
|
359
|
+
// column a stale neighbour's width, and the freshly-inserted empty
|
|
360
|
+
// column would inherit whatever width used to sit at its new index (a
|
|
361
|
+
// plain-index version of this walk is byte-identical to this one for a
|
|
362
|
+
// trailing/mid-table INSERT — padField()'s natural fallback already
|
|
363
|
+
// covers the inserted column either way — but it is NOT for a column
|
|
364
|
+
// MOVE: test/editor-client-runtime.test.js:7876-7927 drags "Detail"
|
|
365
|
+
// from the last position to the first and requires ALL THREE widths
|
|
366
|
+
// dropped even though "Name" and "Note" still sit at index-shifted but
|
|
367
|
+
// TEXT-unchanged positions; only text-matching, combined with the
|
|
368
|
+
// all-or-nothing gate below, gets that right).
|
|
369
|
+
//
|
|
370
|
+
// v3.0.2 superseded the 2026-09-02 review round-1 ruling that a renamed
|
|
371
|
+
// header or a deleted column must discard the whole table's width memory:
|
|
372
|
+
// the forward scan below skips an original column that no longer appears,
|
|
373
|
+
// and only a genuine column REORDER still takes the whole-table refusal.
|
|
374
|
+
// What is still NOT handled — deliberately — is a table with DUPLICATE
|
|
375
|
+
// header names: the scan pairs by TEXT, so after a delete a survivor can
|
|
376
|
+
// inherit the same-named other column's width. Telling the instances
|
|
377
|
+
// apart needs identity matching, which is a larger change than this
|
|
378
|
+
// patch release carries. See test/table-width.test.js cases 14, 15, 20,
|
|
379
|
+
// 21, 23 and 24, which pin every branch of the above.
|
|
380
|
+
const original = columnWidthsOf(originalMd);
|
|
381
|
+
let widths = null;
|
|
382
|
+
// Set for a column that falls off the END of the original header list
|
|
383
|
+
// (every original column has already been matched by the time this one
|
|
384
|
+
// is reached) — a genuinely NEW, appended column, e.g. test case 6's
|
|
385
|
+
// 'City'. Its separator still tracks its OWN header's natural width
|
|
386
|
+
// below. This exists to reconcile the brief's OWN case 6: the brief's
|
|
387
|
+
// Step 5 code unconditionally used `widths[i] || 0` for the separator
|
|
388
|
+
// (which is 0, i.e. the bare 3-dash minimal, for any unmatched column),
|
|
389
|
+
// but the brief's own expected string for case 6 has 'City' getting a
|
|
390
|
+
// 6-dash separator — 'City''s own rendered width, not 3 dashes. No
|
|
391
|
+
// pre-existing test forces this; it exists purely to make the brief's
|
|
392
|
+
// Step 5 code and the brief's own case 6 expectation agree with each
|
|
393
|
+
// other. Any OTHER mismatch (a renamed header, or a row-drag promotion)
|
|
394
|
+
// gets no special separator treatment: it falls all the way back to the
|
|
395
|
+
// 3-dash minimal, same as `widths[i]` itself already falls back to
|
|
396
|
+
// natural per-row padding for it — moot in practice, since the
|
|
397
|
+
// all-or-nothing gate below discards `widths` entirely for those cases
|
|
398
|
+
// anyway, so this array is never even consulted for them.
|
|
399
|
+
const sepFallbackNatural = new Array(headerContents.length).fill(false);
|
|
400
|
+
// The ORIGINAL separator row's own field, VERBATIM, for each MATCHED
|
|
401
|
+
// column. Used two ways at the emission site below:
|
|
402
|
+
//
|
|
403
|
+
// • its LENGTH is the fallback width for a matched column whose
|
|
404
|
+
// header/body rows are not themselves uniform (`original.widths[j]`
|
|
405
|
+
// is `undefined` there — see columnWidthsOf()). I1 (review round 1):
|
|
406
|
+
// without this, such a column's header/body rows fell back to their
|
|
407
|
+
// own natural content width (which happens to reproduce the original
|
|
408
|
+
// bytes when nothing changed), but the separator fell back to the
|
|
409
|
+
// bare 3-dash minimal — rewriting a separator line the user never
|
|
410
|
+
// touched the moment ANY other cell in the table was edited.
|
|
411
|
+
// • the FIELD ITSELF is re-emitted unchanged when the column's
|
|
412
|
+
// separator did not actually change (F1, final review) — see the
|
|
413
|
+
// verbatim gate at the emission site.
|
|
414
|
+
const matchedSepFields = new Array(headerContents.length);
|
|
415
|
+
if (original) {
|
|
416
|
+
const matched = new Array(headerContents.length);
|
|
417
|
+
// v3.0.2: a FORWARD SCAN, not the pre-v3.0.2 lockstep compare. The
|
|
418
|
+
// lockstep version left `j` parked on the first original column that
|
|
419
|
+
// did not match, so it could never recover from one mismatch — and
|
|
420
|
+
// "some original column was never consumed" is the signature of a
|
|
421
|
+
// DELETE and a RENAME just as much as of the column REORDER the gate
|
|
422
|
+
// below actually exists to refuse. Scanning forward lets a delete or
|
|
423
|
+
// a rename skip the original it no longer has, and moves the refusal
|
|
424
|
+
// into an explicit test (`moved`) instead of a side effect of `j`.
|
|
425
|
+
const unmatchedNames = [];
|
|
426
|
+
let j = 0;
|
|
427
|
+
for (let i = 0; i < headerContents.length; i++) {
|
|
428
|
+
const name = headerContents[i].trim();
|
|
429
|
+
let k = -1;
|
|
430
|
+
for (let q = j; q < original.headerFields.length; q++) {
|
|
431
|
+
if (original.headerFields[q].trim() === name) { k = q; break; }
|
|
432
|
+
}
|
|
433
|
+
if (k >= 0) {
|
|
434
|
+
matched[i] = original.widths[k];
|
|
435
|
+
matchedSepFields[i] = original.sepFields[k];
|
|
436
|
+
j = k + 1;
|
|
437
|
+
} else {
|
|
438
|
+
matched[i] = undefined;
|
|
439
|
+
// The EMPTY name is exempt from the reorder test below, and the
|
|
440
|
+
// exemption is load-bearing, not defensive: insertColumn()
|
|
441
|
+
// (lib/editor/client.js:6448-6465) creates a th (or td) with no
|
|
442
|
+
// content at all — the element itself at line 6451 — so every
|
|
443
|
+
// inserted column is named ''. A table that
|
|
444
|
+
// already holds one blank header would therefore see the new
|
|
445
|
+
// column's '' "found elsewhere in the original" and take the
|
|
446
|
+
// whole-table refusal — a straight regression against v3.0.1 on
|
|
447
|
+
// the ordinary insert-a-column-twice flow.
|
|
448
|
+
if (name !== '') unmatchedNames.push(name);
|
|
449
|
+
if (j >= original.headerFields.length) sepFallbackNatural[i] = true;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// A column that could not find itself in what remains of the original,
|
|
453
|
+
// yet whose name IS somewhere in the original, has MOVED relative to
|
|
454
|
+
// the others. That is the one shape a per-column width guess gets
|
|
455
|
+
// silently wrong ("a column move must carry its alignment with it"),
|
|
456
|
+
// so it still discards the width source for the WHOLE table. A name
|
|
457
|
+
// that is absent from the original entirely is an insert or a rename:
|
|
458
|
+
// nothing stale to carry, so its neighbours keep their widths.
|
|
459
|
+
const moved = unmatchedNames.some((n) =>
|
|
460
|
+
original.headerFields.some((f) => f.trim() === n));
|
|
461
|
+
if (!moved) {
|
|
462
|
+
widths = matched;
|
|
463
|
+
// A matched column is then widened, for every row of that column
|
|
464
|
+
// at once (header + separator + every body row), to whatever the
|
|
465
|
+
// grown content now needs — the padding floor rises but never
|
|
466
|
+
// falls, and never truncates (test case 5): "a cell whose content
|
|
467
|
+
// grew past the original column widens that COLUMN", not just its
|
|
468
|
+
// own line. An unmatched column's width stays `undefined` here —
|
|
469
|
+
// comparing a number against `undefined` is always false, so this
|
|
470
|
+
// loop leaves it alone and padField()'s `|| 0` fallback below
|
|
471
|
+
// gives it independent per-row minimal form instead.
|
|
472
|
+
const allRows = [headerContents].concat(bodyContentRows);
|
|
473
|
+
for (let i = 0; i < widths.length; i++) {
|
|
474
|
+
let needed = widths[i];
|
|
475
|
+
allRows.forEach((row) => {
|
|
476
|
+
if (row[i] === undefined) return;
|
|
477
|
+
const natural = row[i].length + 2; // 1-space padding, both sides
|
|
478
|
+
if (natural > needed) needed = natural;
|
|
479
|
+
});
|
|
480
|
+
widths[i] = needed;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
168
485
|
const lines = [];
|
|
169
|
-
lines.push(serializeRow(
|
|
170
|
-
lines.push('|' + headerCells.map((c) =>
|
|
486
|
+
lines.push(serializeRow(headerContents, widths));
|
|
487
|
+
lines.push('|' + headerCells.map((c, i) => {
|
|
488
|
+
// Preference order for a column's separator width:
|
|
489
|
+
// 1. widths[i] — this column is a full hand-aligned width source
|
|
490
|
+
// (matched, uniform, and > 3). Same width the header/body rows
|
|
491
|
+
// already use.
|
|
492
|
+
// 2. matchedSepFields[i].length — this column WAS matched to an
|
|
493
|
+
// original column, just not a uniform-width one (I1: reproduces
|
|
494
|
+
// the ORIGINAL separator's own field width instead of collapsing
|
|
495
|
+
// to the bare 3-dash minimal on an edit elsewhere in the table).
|
|
496
|
+
// 3. headerContents[i].length + 2, but ONLY for a column that fell
|
|
497
|
+
// off the END of the original header list (sepFallbackNatural —
|
|
498
|
+
// see above): a genuinely new, appended column still gets a
|
|
499
|
+
// separator matching its own header's natural width (case 6:
|
|
500
|
+
// 'City' has no original width, but its separator is 6 dashes),
|
|
501
|
+
// unlike the header/body rows above, whose padField() `|| 0`
|
|
502
|
+
// deliberately leaves such a column unsynced.
|
|
503
|
+
// 4. 0 (the plain 3-dash minimal) — no original table at all
|
|
504
|
+
// (`widths` itself is null: cases 1 and 7 stay byte-identical to
|
|
505
|
+
// the pre-v3.0.1 minimal form), or a mismatch that is neither a
|
|
506
|
+
// matched-but-non-uniform column nor a trailing new one (a
|
|
507
|
+
// renamed header, or a row-drag promotion). Since v3.0.2 the
|
|
508
|
+
// gate above no longer discards `widths` for a rename, so this
|
|
509
|
+
// branch is now the live path for a renamed column: its header
|
|
510
|
+
// and body rows take padField()'s `|| 0` natural width and its
|
|
511
|
+
// separator takes the bare 3-dash minimal. A row-drag promotion
|
|
512
|
+
// ONLY leaves every column unmatched — and so only produces the
|
|
513
|
+
// same all-undefined `widths`, byte-for-byte the `null` output,
|
|
514
|
+
// because every preference above falls through to this 0 — when
|
|
515
|
+
// none of the promoted row's cells collides with an ORIGINAL
|
|
516
|
+
// header name. A collision re-enters the by-text match above (the
|
|
517
|
+
// `k >= 0` branch), so that column inherits the OLD column's
|
|
518
|
+
// width and the row emits half-aligned rather than fully minimal
|
|
519
|
+
// (measured: promoting a 'Note' / 'zzz' row past an original
|
|
520
|
+
// 'Name | Note' header keeps column 0 at the original Note
|
|
521
|
+
// column's width while column 1 falls to 3-dash minimal). The two
|
|
522
|
+
// promotion scenarios this suite covers are both collision-free.
|
|
523
|
+
const origField = matchedSepFields[i];
|
|
524
|
+
const w = widths
|
|
525
|
+
? (widths[i] !== undefined ? widths[i]
|
|
526
|
+
: origField !== undefined ? origField.length
|
|
527
|
+
: (sepFallbackNatural[i] ? headerContents[i].length + 2 : 0))
|
|
528
|
+
: 0;
|
|
529
|
+
const align = cellAlign(c);
|
|
530
|
+
// F1 (2026-09-02 final review, Critical): once the width is settled,
|
|
531
|
+
// prefer the ORIGINAL field's own BYTES over rebuilding them. Every
|
|
532
|
+
// preference above only ever agreed on the WIDTH of the separator
|
|
533
|
+
// field; sepCellFor() then rebuilt its contents as a dash run flush
|
|
534
|
+
// against both delimiters. For the padded separator row Prettier,
|
|
535
|
+
// prettier-plugin-markdown and VS Code's table formatter all emit —
|
|
536
|
+
// '| ----- | --- |', the most common machine-aligned markdown table
|
|
537
|
+
// in the wild — that is the same width but different bytes, so a pure
|
|
538
|
+
// no-op (click into the table, click away) came back as
|
|
539
|
+
// '|-------|-----|'. commitRangeEdit() short-circuits only on a
|
|
540
|
+
// byte-identical result, so that was a REAL op: an undo entry pushed,
|
|
541
|
+
// `lines` replaced, and the next save writing a line the user never
|
|
542
|
+
// touched.
|
|
543
|
+
//
|
|
544
|
+
// Re-emit the original field verbatim only when BOTH hold, so this can
|
|
545
|
+
// never smuggle a stale separator past a genuine change:
|
|
546
|
+
// (a) the field already expresses the alignment this column now has
|
|
547
|
+
// (sepFieldAlign() returns `undefined` for anything that isn't a
|
|
548
|
+
// plain separator field, which cellAlign() can never equal, so an
|
|
549
|
+
// unparseable field always falls through); and
|
|
550
|
+
// (b) the target width `w` is exactly that field's own length — any
|
|
551
|
+
// width GROWTH (a cell that outgrew its column, cases 5 and 12)
|
|
552
|
+
// lands here with w > origField.length and falls through.
|
|
553
|
+
// Everything that falls through takes today's sepCellFor() path,
|
|
554
|
+
// unchanged.
|
|
555
|
+
if (widths && origField !== undefined &&
|
|
556
|
+
origField.length === w && sepFieldAlign(origField) === align) {
|
|
557
|
+
return origField;
|
|
558
|
+
}
|
|
559
|
+
return sepCellFor(align, w);
|
|
560
|
+
}).join('|') + '|');
|
|
171
561
|
|
|
172
|
-
|
|
173
|
-
const cells = elementChildren(tr).filter(isCell);
|
|
174
|
-
lines.push(serializeRow(cells, unsupported));
|
|
175
|
-
});
|
|
562
|
+
bodyContentRows.forEach((contents) => lines.push(serializeRow(contents, widths)));
|
|
176
563
|
|
|
177
564
|
return { md: lines.join('\n'), unsupported };
|
|
178
565
|
}
|