@helping-ai-workflow/md2doc 2.10.0 → 2.11.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.
@@ -236,9 +236,20 @@
236
236
  n++;
237
237
  lines.push((indentPrefix + marker + itemMd).replace(/[ \t]+$/, ''));
238
238
 
239
- // childIndentPrefix derives from marker.length task item '- [ ] '
240
- // (6 chars) gives a 6-space nested indent for free, no extra logic.
241
- const childIndentPrefix = indentPrefix + new Array(marker.length + 1).join(' ');
239
+ // childIndentPrefix derives from BULLET.length, NOT marker.length. The
240
+ // '[ ] ' checkbox is a GFM construct parsed out of the item's CONTENT,
241
+ // not part of the CommonMark list marker, so a child's content column
242
+ // is where the BULLET ends: a '- [ ] ' item is still a 2-column parent
243
+ // and '1. [ ] ' a 3-column one. Measured against this repo's marked,
244
+ // the child-indent acceptance windows are '- ' 2..5, '1. ' 3..6,
245
+ // '10. ' 4..7, '- [ ] ' 2..5, '1. [ ] ' 3..6 — so the old
246
+ // marker.length (6 under '- [ ] ') fell outside the window and marked
247
+ // absorbed the child into the parent item as literal text, whose
248
+ // remaining 4-space indent then reads as an indented CODE BLOCK. That
249
+ // was silent data corruption on a single commit, not a formatting nit.
250
+ // (An earlier revision of spec §3.4 said '- [ ] ' was 6 columns; it was
251
+ // wrong and now carries an errata table. See test case 27.)
252
+ const childIndentPrefix = indentPrefix + new Array(bullet.length + 1).join(' ');
242
253
  nestedLists.forEach((nl) => {
243
254
  serializeListNode(nl, childIndentPrefix, unsupported, unsupportedByLi).forEach((l) => lines.push(l));
244
255
  });
@@ -254,5 +265,490 @@
254
265
  return { md: lines.join('\n'), unsupported, unsupportedByLi };
255
266
  }
256
267
 
257
- return { serializeList };
268
+ // ── S1: flat block serializer ─────────────────────────────────────
269
+ // Additive: serializeList() above is untouched and keeps every one of its
270
+ // callers and tests. serializeBlocks() walks a LINEAR run of
271
+ // `.ed-block[data-block-type="li"]` elements instead of a DOM tree, which
272
+ // is the shape Task 3's flat renderer emits (no nested <ul>/<li> at all).
273
+ //
274
+ // Each blockEl must carry data-block-type="li", data-list-type="ul"|"ol",
275
+ // data-task="0"|"1", data-indent="K" and data-block-id, and contain one
276
+ // .ed-li-text; optionally one .ed-li-check[data-checked] and one
277
+ // .ed-li-marker (plus, from Task 4, .ed-handle / .ed-insert gutter chrome).
278
+ //
279
+ // INDENT: nesting depth is a data attribute now, but the emitted indent
280
+ // must still be the ACCUMULATED WIDTH of every ancestor item's own BULLET
281
+ // ('- ' 2, '1. ' 3, '10. ' 4) — a flat "two spaces per level" de-nests on
282
+ // re-parse. See the module header's INDENT ruling; `widths` below is that
283
+ // stack, rebuilt as the run is walked.
284
+ //
285
+ // The task checkbox is NOT part of that width. '[ ] ' is a GFM construct
286
+ // parsed out of the item's CONTENT, not part of the CommonMark list
287
+ // marker, so a child's content column is where the BULLET ends — a
288
+ // '- [ ] ' parent is still a 2-column parent, and '1. [ ] ' a 3-column
289
+ // one. (An earlier revision of spec §3.4 said '- [ ] ' was 6 columns; that
290
+ // was wrong and the spec now carries an errata table. Measured against
291
+ // this repo's own marked.lexer, the child-indent windows are: '- ' 2..5,
292
+ // '1. ' 3..6, '10. ' 4..7, '- [ ] ' 2..5, '1. [ ] ' 3..6. Emitting 6 under
293
+ // '- [ ] ' lands outside the window: marked absorbs the child into the
294
+ // parent item as literal text and the nested list is DESTROYED on commit.
295
+ // test/list-md.test.js case 23 round-trips both task shapes.)
296
+ //
297
+ // A BLOCK MAY OWN A CONTIGUOUS RANGE OF LINES. `lines` and `lineMeta` are
298
+ // pushed in lockstep — one lineMeta entry per emitted line, so
299
+ // `lineMeta.length === md.split('\n').length` always — and consecutive
300
+ // entries may share a blockId when an item is hard-wrapped. A caller maps a
301
+ // block to the INDEX RANGE of the entries bearing its id; assuming one line
302
+ // per block is what let the per-li degrade path address a different item's
303
+ // line. See the continuation-column note in the emission below.
304
+ //
305
+ // RUN (spec §3.8): a run breaks at a shallower li, at a same-depth li whose
306
+ // data-list-type differs, or at a non-li block. Deeper items never break
307
+ // the run they are nested under. Every run's ordinal restarts at 1 — hence
308
+ // `counters`, indexed by depth, with everything deeper than the current
309
+ // depth cleared whenever the walk comes back up.
310
+ //
311
+ // Rule (b) compares against the last block seen AT THAT DEPTH (`types`),
312
+ // NOT against the immediately previous block: with a deeper item sitting
313
+ // between two same-depth blocks, comparing against `prev` makes the
314
+ // list-type change invisible and the ordinal never restarts. The md then
315
+ // re-lexes as <ol start="3">, §3.8 discards `start`, and the next commit
316
+ // renumbers to '1.' — the document oscillates between two states forever.
317
+ //
318
+ // Same DOM-API constraint as the rest of this module, plus
319
+ // `classList.contains` (state classes are token-matched, never compared as
320
+ // whole strings: at runtime .ed-li-text also carries ed-wys-armed).
321
+ const LI_CHROME = ['ed-handle', 'ed-insert', 'ed-li-marker', 'ed-li-check', 'ed-li-text'];
322
+
323
+ function hasClass(node, name) {
324
+ return !!(node && node.nodeType === 1 && node.classList && node.classList.contains(name));
325
+ }
326
+
327
+ // blockEls may be a real NodeList (browser run scan) or a plain Array
328
+ // (node stubs) — index it manually rather than relying on Array methods.
329
+ function toArray(listLike) {
330
+ const out = [];
331
+ for (let i = 0; i < listLike.length; i++) out.push(listLike[i]);
332
+ return out;
333
+ }
334
+
335
+ function firstChildWithClass(el, name) {
336
+ const kids = allChildNodes(el);
337
+ for (let i = 0; i < kids.length; i++) {
338
+ if (hasClass(kids[i], name)) return kids[i];
339
+ }
340
+ return null;
341
+ }
342
+
343
+ // Deliberately NOT isBlankText(): that one requires a newline, because a
344
+ // bare ' ' text node BETWEEN INLINE NODES inside a tight <li> is meaningful
345
+ // spacing that serializeList must keep (test 4: '- a **bold** <br>line
346
+ // two'). Here we are looking at the direct children of a flat .ed-block,
347
+ // where the only text nodes are the template's own inter-element
348
+ // whitespace — which may be a single space with no newline. Widening
349
+ // isBlankText itself would regress serializeList, so the widening is
350
+ // scoped to this walk instead.
351
+ function isBlankBlockText(node) {
352
+ return node.nodeType === 3 && /^\s*$/.test(node.textContent || '');
353
+ }
354
+
355
+ // One source line's leading whitespace + every list marker standing on it,
356
+ // split away from the content that follows. A same-line nest ('- 1. b')
357
+ // carries one marker per level, so the walk repeats until it stops matching;
358
+ // the GFM task checkbox is stepped over as part of its own marker because
359
+ // physically it stands between that marker and the next one.
360
+ //
361
+ // `prefix` is what §3.4's colDelta measures the OLD side against, so it comes
362
+ // back as literal text rather than a count — a caller that finds a TAB in it
363
+ // knows the column arithmetic is not exact and can decline.
364
+ const SRC_MARKER_RE = /^([ \t]*)(?:[-*+]|\d{1,9}[.)])([ \t]+)(?:\[[ xX]\][ \t]+)?/;
365
+ function splitSourceMarkers(line) {
366
+ const text = typeof line === 'string' ? line : '';
367
+ let at = 0;
368
+ for (;;) {
369
+ const m = SRC_MARKER_RE.exec(text.slice(at));
370
+ if (!m) break;
371
+ at += m[0].length;
372
+ }
373
+ return { prefix: text.slice(0, at), content: text.slice(at) };
374
+ }
375
+
376
+ function spaces(n) {
377
+ return n > 0 ? new Array(n + 1).join(' ') : '';
378
+ }
379
+
380
+ // `opts.carryOver` (spec §3.4, 多行 li 的旁觀者規則): a map of block id →
381
+ // that block's ORIGINAL source lines. A hard-wrapped item named there is NOT
382
+ // re-serialized; its own bytes are replayed with the column difference of its
383
+ // new marker applied to every line it owns.
384
+ //
385
+ // This is not a fidelity nicety, it is the difference between Tab working on
386
+ // a real document and not working at all. Structural refusal is run-wide
387
+ // (RULING F-R) and MULTILINE feeds it, so before this a single hard-wrapped
388
+ // item anywhere in a run refused the whole run — and 80.6% of this repo's own
389
+ // CHANGELOG.md list items are hard-wrapped. Dropping MULTILINE from the gate
390
+ // is not the fix on its own either: the round trip through the inline
391
+ // serializer is LOSSY for a bystander (measured on CHANGELOG.md at v2.10.2 —
392
+ // '~5px' comes back as '\~5px', because escapeText() escapes a tilde marked
393
+ // never treated as markup). Replaying the source is what keeps an untouched
394
+ // item byte-identical, which is in turn what stops the commit's line-range
395
+ // replace from rewriting lines the user never looked at.
396
+ function serializeBlocks(blockEls, opts) {
397
+ const carryOver = (opts && opts.carryOver) || null;
398
+ const unsupported = [];
399
+ const unsupportedByLi = [];
400
+ const multiLineBlockIds = [];
401
+ const lineMeta = [];
402
+ const lines = [];
403
+ // widths[k] = the marker width (as a literal run of spaces) of the
404
+ // innermost item seen at depth k; counters[k] = the running ordinal of
405
+ // the run currently open at depth k.
406
+ const widths = [];
407
+ const counters = [];
408
+ // types[k] = the list type of the last block seen at depth k, so rule (b)
409
+ // survives a deeper item sitting between two same-depth blocks.
410
+ const types = [];
411
+ let prev = null; // { indent, listType }
412
+
413
+ // ── Round 6: a CONTENT-FREE TASK item cannot be given a line of its own ──
414
+ // marked only reads '[ ]' / '[x]' as a checkbox when content follows ON THE
415
+ // SAME LINE: '- [ ]\n - b' lexes as '<li>[ ]<ul>…' — the checkbox becomes
416
+ // literal text and its state stops being machine-readable. Same-line
417
+ // nesting ('- [ ] - b') is exactly that shape: the outer item's own content
418
+ // is empty because its child starts on its line.
419
+ //
420
+ // So such an item does not emit a line; it becomes a PREFIX carried onto
421
+ // the next emitted line, which restores the source's own same-line form.
422
+ // The columns it contributes to that line's indent prefix — its BULLET's
423
+ // width, checkbox excluded, the same rule `widths` uses — are already
424
+ // written by the prefix, so they come off the front of the line it joins.
425
+ //
426
+ // A PLAIN content-free item keeps its own line: '-\n - b' and '- - b' are
427
+ // the same tree to marked, and the canonical form is preferable because it
428
+ // gives the item a source line of its own (blockmap can then hand it a
429
+ // well-formed range). Only the checkbox forces the same-line form.
430
+ //
431
+ // The prefix is FLUSHED as its own line — degrading to literal '[ ]', which
432
+ // is what the source said anyway — whenever the next block is not deeper,
433
+ // i.e. when the item has no child to attach to. Merging it onto a SIBLING
434
+ // would invent nesting that the document never had.
435
+ let pending = null; // { text, cols, indent, blockId, indentPrefix, marker }
436
+
437
+ function flushPending() {
438
+ if (!pending) return;
439
+ lines.push(pending.text.replace(/[ \t]+$/, ''));
440
+ lineMeta.push({ blockId: pending.blockId, indentPrefix: pending.indentPrefix,
441
+ marker: pending.marker });
442
+ pending = null;
443
+ }
444
+
445
+ // Drops up to `n` leading SPACE columns — the ones `pending` has already
446
+ // physically written on this line.
447
+ function stripCols(line, n) {
448
+ let k = 0;
449
+ while (k < n && line.charAt(k) === ' ') k++;
450
+ return line.slice(k);
451
+ }
452
+
453
+ toArray(blockEls).forEach((blockEl) => {
454
+ const blockId = blockEl.getAttribute('data-block-id');
455
+
456
+ // §3.8 rule (c): a non-li block terminates a run. It has no marker and
457
+ // no .ed-li-text contract, so emitting a '- <text>' line for it would
458
+ // silently invent list structure. Flag it, never swallow it — the same
459
+ // principle the alien-child branch below follows. Names are uppercased
460
+ // to match the element-name convention the rest of `unsupported` uses.
461
+ const blockType = blockEl.getAttribute('data-block-type');
462
+ if (blockType !== 'li') {
463
+ unsupported.push(String(blockType || 'UNKNOWN').toUpperCase());
464
+ // A non-li block ends the run (§3.8 rule c), so a task prefix still
465
+ // open cannot have a child after it — put it back on its own line
466
+ // rather than letting it reach across the break.
467
+ flushPending();
468
+ return;
469
+ }
470
+
471
+ const indent = Number(blockEl.getAttribute('data-indent')) || 0;
472
+ // Only a DEEPER block is the pending item's child; anything else means it
473
+ // had none, so its marker goes back onto a line of its own.
474
+ if (pending && indent <= pending.indent) flushPending();
475
+ const listType = blockEl.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul';
476
+ const isTask = blockEl.getAttribute('data-task') === '1';
477
+ // §3.8 rule (d): this block is the first item of its own list TOKEN.
478
+ const isListStart = blockEl.getAttribute('data-list-start') === '1';
479
+
480
+ // Run bookkeeping. Going DEEPER opens a brand-new run at that depth
481
+ // (even when the type matches); a list-type change against the last
482
+ // block AT THIS DEPTH closes that run and opens another; coming back
483
+ // UP leaves this depth's run open (deeper items did not break it) but
484
+ // clears every deeper depth so the next descent restarts at 1.
485
+ //
486
+ // Rule (d) is the fourth reset, and rules (a)-(c) cannot derive it:
487
+ // ' 1. x' followed by ' 1) y' is TWO nested list tokens (a delimiter
488
+ // change starts a new list) at the SAME depth with the SAME type, so
489
+ // without this the second list is renumbered as a continuation of the
490
+ // first ('2. y') and a commit rewrites a list the user never touched.
491
+ // lib/md2doc.js stamps the attribute; only the renderer still knows
492
+ // where marked's token boundaries were.
493
+ if (isListStart || !prev || indent > prev.indent || types[indent] !== listType) {
494
+ counters[indent] = 0;
495
+ }
496
+ for (let k = counters.length - 1; k > indent; k--) {
497
+ counters[k] = 0;
498
+ types[k] = undefined;
499
+ }
500
+ types[indent] = listType;
501
+
502
+ const indentPrefix = widths.slice(0, indent).join('');
503
+ counters[indent] = (counters[indent] || 0) + 1;
504
+
505
+ const checkEl = firstChildWithClass(blockEl, 'ed-li-check');
506
+ const isChecked = checkEl ? checkEl.getAttribute('data-checked') === '1' : false;
507
+ // Two-part marker: the bullet (ordered ordinal or '- ') and an
508
+ // optional checkbox, kept independent so an ordered task list gets
509
+ // BOTH ('1. [ ] todo') — RULING F-N, same as serializeList above.
510
+ const bullet = listType === 'ol' ? (counters[indent] + '. ') : '- ';
511
+ const marker = isTask ? bullet + (isChecked ? '[x] ' : '[ ] ') : bullet;
512
+
513
+ const textEl = firstChildWithClass(blockEl, 'ed-li-text');
514
+ const innerUnsupported = [];
515
+ let itemMd = '';
516
+ if (textEl) {
517
+ // The surface's own children are filtered through isBlankText() —
518
+ // NOT isBlankBlockText() — before they reach the inline serializer,
519
+ // exactly as serializeListNode() above does for a tree <li>. A LOOSE
520
+ // item's surface is `\n<p>text</p>\n` (marked pretty-prints block-level
521
+ // output), and inline-md.js has no reason to treat a text node's "\n"
522
+ // specially: escapeText() emits it verbatim, so ONE list item turned
523
+ // into THREE physical lines — a direct violation of the gate's
524
+ // one-line-per-item contract, and worse, it desynchronised `lineMeta`
525
+ // from `md.split('\n')` so a caller committing a single item's line by
526
+ // index wrote a DIFFERENT item's line into it (observed: editing the
527
+ // item after a loose blank replaced its source line with ' - ').
528
+ // isBlankText() is the right predicate here and isBlankBlockText() is
529
+ // not: the artifact marked emits always contains a newline, while a
530
+ // bare ' ' BETWEEN INLINE NODES is meaningful spacing the item must
531
+ // keep. (isBlankBlockText() stays as-is for the block's own direct
532
+ // children, where the template's inter-element whitespace can legally
533
+ // be a single space with no newline.)
534
+ const inlineKids = [];
535
+ allChildNodes(textEl).forEach((c) => { if (!isBlankText(c)) inlineKids.push(c); });
536
+ const res = inlineMd.serializeInline({ childNodes: inlineKids });
537
+ itemMd = res.md;
538
+ res.unsupported.forEach((u) => innerUnsupported.push(u));
539
+ }
540
+
541
+ // Anything in the block that is neither chrome nor the text surface is
542
+ // content we cannot represent — flag it, never swallow it.
543
+ allChildNodes(blockEl).forEach((kid) => {
544
+ if (kid.nodeType === 3) {
545
+ if (!isBlankBlockText(kid)) unsupported.push('TEXT');
546
+ return;
547
+ }
548
+ if (kid.nodeType !== 1) return;
549
+ for (let i = 0; i < LI_CHROME.length; i++) {
550
+ if (hasClass(kid, LI_CHROME[i])) return;
551
+ }
552
+ unsupported.push(kid.nodeName);
553
+ });
554
+
555
+ // A block may own a contiguous RANGE of lines — and if it does, that is
556
+ // reported for STRUCTURAL operations only.
557
+ //
558
+ // A hard-wrapped ("lazy continuation") item is ordinary markdown: 22.4%
559
+ // of this repo's own 799 list items are one, every CHANGELOG bullet
560
+ // included. Its .ed-li-text holds a literal '\n', which inline-md.js's
561
+ // escapeText() emits verbatim. An earlier revision made such an item
562
+ // UNSUPPORTED outright and truncated it to one line, which kept `lines`
563
+ // and `lineMeta` parallel but turned a fifth of every real document
564
+ // read-only. Spec §4.1 asks for less than that: a multi-line li refuses
565
+ // structural operations as an operation TARGET, and 文字編輯不受影響.
566
+ //
567
+ // So MULTILINE goes to `unsupported` — which is what
568
+ // listRunSupportsStructuralEdit() gates on, and what forces the per-li
569
+ // partial commit path so no other item is re-emitted — and NOT to
570
+ // `unsupportedByLi`, which is the channel canWysiwygForLi() and
571
+ // resolveBurst()'s F-W text-edit refusal read. Same split the tree
572
+ // serializer has always used for loose 'P'. Callers that must ignore it
573
+ // for arming filter on STRUCTURAL_ONLY_UNSUPPORTED rather than
574
+ // hard-coding the name.
575
+ // `multiLineBlockIds` is the same fact with an OWNER attached. The flat
576
+ // `unsupported` array has no attribution, so a caller reading it can only
577
+ // ask "does this run contain a hard-wrapped item", which is exactly the
578
+ // question that made Tab useless on real documents. Spec §4.1 asks a
579
+ // narrower one — "is the block I am about to operate ON hard-wrapped" —
580
+ // and that needs the id.
581
+ // T7 — WHAT THIS FLAG IS AND IS NOT. It answers "does this item's
582
+ // SURFACE TEXT hold a newline", which catches a LAZY continuation and
583
+ // nothing else. It is NOT the answer to "does this block own more than
584
+ // one source line", and the difference is not academic: a markdown HARD
585
+ // BREAK is two trailing SPACES, which marked renders as <br>, so such an
586
+ // item's surface holds no '\n' at all and this flag calls it
587
+ // single-line. Measured: Enter on one used to be accepted and collapsed
588
+ // its two source lines into ONE bearing the literal text '<br>'
589
+ // ('- a \n b ~t' -> '- a<br>b \~t'), and as a BYSTANDER of somebody
590
+ // else's Tab it was rewritten the same way.
591
+ //
592
+ // Neither of those is fixable HERE, because this module sees a DOM and
593
+ // the answer lives in the file. Widening the test to '<br>' was tried
594
+ // and is wrong in the other direction: inline-md.js emits '<br>' for a
595
+ // real hard break, for a literal '<br>' the source spelled out by hand,
596
+ // AND for the placeholder <br> Chromium leaves behind when the user
597
+ // deletes an item's last character — so it made an EMPTIED item
598
+ // structurally frozen (Enter could no longer remove it).
599
+ // lib/editor/client.js owns both decisions instead, off `blocks`:
600
+ // listRunSupportsStructuralEdit() refuses a target whose range spans
601
+ // several lines, and bystanderCarryOver() replays every untouched
602
+ // block's own bytes. This flag stays as the narrow thing it always was.
603
+ const isMultiLine = /\n/.test(itemMd);
604
+ if (isMultiLine) {
605
+ unsupported.push('MULTILINE');
606
+ multiLineBlockIds.push(blockId);
607
+ }
608
+
609
+ // Per-li attribution, keyed by the getAttribute() string block id
610
+ // (compared against String(burst.blockId) by the caller).
611
+ if (innerUnsupported.length > 0) {
612
+ unsupportedByLi.push({ blockId: blockId, names: innerUnsupported.slice() });
613
+ }
614
+ innerUnsupported.forEach((u) => unsupported.push(u));
615
+
616
+ // BULLET, not `marker`: the '[ ] ' checkbox is content, not marker
617
+ // width — see the INDENT note above. Using marker.length here puts a
618
+ // task item's child outside marked's acceptance window and destroys it.
619
+ widths[indent] = new Array(bullet.length + 1).join(' ');
620
+ widths.length = indent + 1;
621
+
622
+ // Emission. Leading whitespace on the item's own first line is stripped
623
+ // for the same reason as in serializeListNode (a dropped leading element
624
+ // would leave a stray double space); trailing whitespace is never
625
+ // emitted, on any line.
626
+ //
627
+ // CONTINUATION COLUMN: every line after the first is re-indented to the
628
+ // item's CONTENT column — `indentPrefix + bullet width` — which is the
629
+ // same accumulated-width rule §3.4's errata pinned for a CHILD LIST's
630
+ // indent, and for the same reason: that column is where marked resumes
631
+ // the item's own paragraph. The checkbox is excluded from it ('- [ ] ' is
632
+ // a 2-column parent), exactly as `widths` above computes. One column too
633
+ // few and the continuation lexes as a sibling or a lazy line of the wrong
634
+ // item; four too many and it lexes as an INDENTED CODE BLOCK. The line's
635
+ // own leading whitespace is dropped first so the column is ours to state,
636
+ // not the DOM's to leak. test/list-md.test.js case 31(d) round-trips this
637
+ // through marked.lexer.
638
+ //
639
+ // lineMeta gets ONE ENTRY PER EMITTED LINE, each naming the block that
640
+ // owns it, so consecutive entries may share a blockId. The invariant is
641
+ // `lineMeta.length === md.split('\n').length`; violating it is precisely
642
+ // what let a caller's line index address a DIFFERENT item's line.
643
+ const ownLines = itemMd.replace(/^[ \t]+/, '').split('\n');
644
+ const contPrefix = indentPrefix + new Array(bullet.length + 1).join(' ');
645
+ // Round 6: join an open task prefix (above) to this line, dropping the
646
+ // columns it already wrote; then, if THIS item is itself a content-free
647
+ // task item, become the prefix instead of emitting.
648
+ let head = indentPrefix + marker;
649
+ // `indentPrefix` in lineMeta is the text PHYSICALLY standing before this
650
+ // line's own marker. Normally that is the accumulated width; on a line a
651
+ // task prefix has joined, it is that prefix — which is what a caller
652
+ // replaying the line (client.js's per-li commit) and §3.4's colDelta both
653
+ // need, and why this does not want a separate field.
654
+ let metaPrefix = indentPrefix;
655
+ if (pending) {
656
+ head = pending.text + stripCols(indentPrefix + marker, pending.cols);
657
+ metaPrefix = head.slice(0, head.length - marker.length);
658
+ pending = null;
659
+ }
660
+ if (isTask && ownLines.length === 1 && ownLines[0] === '') {
661
+ pending = { text: head, cols: indentPrefix.length + bullet.length, indent: indent,
662
+ blockId: blockId, indentPrefix: metaPrefix, marker: marker };
663
+ prev = { indent: indent, listType: listType };
664
+ return;
665
+ }
666
+ // ── §3.4 bystander replay ────────────────────────────────────────────
667
+ // The caller named this block's own source lines, so they are emitted
668
+ // instead of the round trip. Only the marker is re-stated (it has to be:
669
+ // an ordinal or an ancestor's width may have moved), and the resulting
670
+ // COLUMN difference is applied to every continuation line — applying it
671
+ // to the marker line alone is what turns a continuation into an indented
672
+ // code block.
673
+ //
674
+ // Declined, falling through to the ordinary path, when the arithmetic
675
+ // would be a guess rather than a measurement: no marker on the first
676
+ // source line (nothing to measure the old prefix against), or a TAB in
677
+ // that prefix (its column count depends on a tab stop this module does
678
+ // not get to choose).
679
+ //
680
+ // Which blocks are named is entirely the CALLER's decision, and it is
681
+ // not the same question as `multiLineBlockIds` above: client.js keys the
682
+ // map on each block's own SOURCE LINE RANGE, which is the only place the
683
+ // truth lives (this module sees a DOM, not a file), and names every
684
+ // block it did not itself mutate — not just the multi-line ones. A
685
+ // single-line bystander needs the replay too: escapeText() escapes a
686
+ // tilde marked never treats as markup, so '~5px' came back '\~5px' in an
687
+ // item the gesture never touched.
688
+ const carried = carryOver && blockId !== null && carryOver[blockId];
689
+ const carriedSplit = carried && carried.length ? splitSourceMarkers(carried[0]) : null;
690
+ const carriedOk = !!carriedSplit && carriedSplit.prefix !== '' &&
691
+ carriedSplit.prefix.indexOf('\t') === -1;
692
+ if (carriedOk) {
693
+ // No trailing-whitespace trim on a replayed line, unlike the
694
+ // re-serialized path below: these bytes are the file's own, and a
695
+ // markdown hard break IS two trailing spaces. Trimming would edit an
696
+ // item the operation was explicitly told not to touch.
697
+ lines.push(head + carriedSplit.content);
698
+ lineMeta.push({ blockId: blockId, indentPrefix: metaPrefix, marker: marker });
699
+ const colDelta = head.length - carriedSplit.prefix.length;
700
+ for (let k = 1; k < carried.length; k++) {
701
+ const raw = typeof carried[k] === 'string' ? carried[k] : '';
702
+ // colDelta 0 is the overwhelmingly common case (nothing about this
703
+ // item's marker moved), and it is emitted BYTE-FOR-BYTE — including
704
+ // any tab indentation, which §3.11(4) says only a re-serialized line
705
+ // may normalise.
706
+ if (colDelta === 0) {
707
+ lines.push(raw);
708
+ lineMeta.push({ blockId: blockId, indentPrefix: /^[ \t]*/.exec(raw)[0], marker: '' });
709
+ continue;
710
+ }
711
+ const lead = /^[ \t]*/.exec(raw)[0];
712
+ const body = raw.slice(lead.length);
713
+ const prefix = body === '' ? '' : spaces(Math.max(0, lead.length + colDelta));
714
+ lines.push(prefix + body);
715
+ lineMeta.push({ blockId: blockId, indentPrefix: prefix, marker: '' });
716
+ }
717
+ prev = { indent: indent, listType: listType };
718
+ return;
719
+ }
720
+ lines.push((head + ownLines[0]).replace(/[ \t]+$/, ''));
721
+ lineMeta.push({ blockId: blockId, indentPrefix: metaPrefix, marker: marker });
722
+ for (let k = 1; k < ownLines.length; k++) {
723
+ const body = ownLines[k].replace(/^[ \t]+/, '').replace(/[ \t]+$/, '');
724
+ // A blank continuation would emit a blank line, which re-lexes the
725
+ // whole list as LOOSE and changes every item's rendering. Content that
726
+ // genuinely contains a blank line arrives as separate <p> elements
727
+ // instead (the 'P' path), so dropping an empty segment here loses
728
+ // nothing; both `lines` and `lineMeta` skip it together, so they stay
729
+ // parallel.
730
+ if (body === '') continue;
731
+ lines.push(contPrefix + body);
732
+ lineMeta.push({ blockId: blockId, indentPrefix: contPrefix, marker: '' });
733
+ }
734
+ prev = { indent: indent, listType: listType };
735
+ });
736
+ // A task prefix still open at the end of the run had no child at all.
737
+ flushPending();
738
+
739
+ return {
740
+ md: lines.join('\n'),
741
+ unsupported: unsupported,
742
+ unsupportedByLi: unsupportedByLi,
743
+ multiLineBlockIds: multiLineBlockIds,
744
+ lineMeta: lineMeta,
745
+ };
746
+ }
747
+
748
+ // Names that appear in `unsupported` to gate STRUCTURAL operations but that
749
+ // must NOT stop a block being armed for text editing. Exported so the client
750
+ // and the serializer cannot drift apart on the distinction.
751
+ const STRUCTURAL_ONLY_UNSUPPORTED = ['MULTILINE'];
752
+
753
+ return { serializeList, serializeBlocks, STRUCTURAL_ONLY_UNSUPPORTED };
258
754
  });
@@ -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;
@@ -81,9 +109,46 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
81
109
  if (!file || !fs.existsSync(file)) return send(res, 404, { error: 'unknown file' });
82
110
  const mdText = fs.readFileSync(file, 'utf8');
83
111
  const mtimeMs = fs.statSync(file).mtimeMs;
112
+ // EOL 偵測與拆行:lines 內部一律不含 \r(spec §3.11)。只有
113
+ // /api/save 會把它接回檔案原本的 EOL;/api/render 一律用 \n。
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
+ //
123
+ // 多數決,不是「有 CRLF 就算 CRLF」(final review I3):save 會把
124
+ // `lines` 全部用同一個 eol 接回去,所以一萬行的 LF 檔裡混進一行
125
+ // CRLF,舊式偵測會在第一次存檔時把一萬行全部改寫成 CRLF ——
126
+ // 直接違反 spec §3.11 第 4 點「commit 範圍以外的行保留原位元組」。
127
+ // 多數決把損害限制在少數派那幾行。平手時取 LF(git / POSIX 預設)。
128
+ // 用「\n 總數 − CRLF 數」算裸 LF,而不是 /(^|[^\r])\n/g:後者是
129
+ // non-overlapping 比對,連續空行的第二個 \n 會被前一次比對吃掉的
130
+ // 字元擋掉而漏數。減法沒有這個誤差。
131
+ //
132
+ // 裸 \r 也進多數決,理由跟上一段同一條:既然現在會拆它,一個純
133
+ // CR 檔(classic Mac)就會在第一次存檔時被整份改寫成 LF——正是
134
+ // §3.11 第 4 點禁止的事。平手一律 LF。
135
+ const lfTotal = (mdText.match(/\n/g) || []).length;
136
+ const crTotal = (mdText.match(/\r/g) || []).length;
137
+ const crlfCount = (mdText.match(/\r\n/g) || []).length;
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';
84
147
  const { html, blocks } = await renderMarkdown(mdText, file, { editMode: true });
148
+ const lines = mdText.split(/\r\n|\r|\n/);
149
+ assertBlockRangesFit(blocks, lines);
85
150
  const payload = JSON.stringify({
86
- fileId, mtimeMs, lines: mdText.split('\n'), blocks,
151
+ fileId, mtimeMs, eol, lines, blocks,
87
152
  });
88
153
  const inject =
89
154
  `<script>window.__ED__ = ${payload.replace(/</g, '\\u003c')}</script>\n` +
@@ -92,6 +157,8 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
92
157
  `<script>${TABLE_MD_SRC}</script>\n` +
93
158
  `<script>${LIST_MD_SRC}</script>\n` +
94
159
  `<script>${HISTORY_SRC}</script>\n` +
160
+ `<script>${INDENT_CLAMP_SRC}</script>\n` +
161
+ `<script>${CONVERT_MD_SRC}</script>\n` +
95
162
  `<script>${clientJs}</script>\n`;
96
163
  // Splice at the LAST "</body>" — the document's real closing tag.
97
164
  // The first occurrence can sit inside an inlined diagram bundle's JS
@@ -187,4 +254,4 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
187
254
  };
188
255
  }
189
256
 
190
- module.exports = { createEditorServer };
257
+ module.exports = { createEditorServer, assertBlockRangesFit };
@@ -152,11 +152,23 @@
152
152
  const headerRow = thead ? firstChildNamed(thead, 'TR') : null;
153
153
  const headerCells = headerRow ? elementChildren(headerRow).filter(isCell) : [];
154
154
 
155
+ const bodyRows = tbody ? childrenNamed(tbody, 'TR') : [];
156
+ // degrade-never-lose:這兩種形狀序列化出去就回不來了。
157
+ // 空表頭會輸出 '| |' + '||',re-lex 成 paragraph(整張表消失);
158
+ // 比表頭寬的 body 列,重讀時多出來的欄會被直接丟掉。
159
+ if (headerCells.length === 0) {
160
+ return { md: '', unsupported: ['TABLE_NO_HEADER'] };
161
+ }
162
+ const ragged = bodyRows.some((tr) =>
163
+ elementChildren(tr).filter(isCell).length !== headerCells.length);
164
+ if (ragged) {
165
+ return { md: '', unsupported: ['TABLE_RAGGED'] };
166
+ }
167
+
155
168
  const lines = [];
156
169
  lines.push(serializeRow(headerCells, unsupported));
157
170
  lines.push('|' + headerCells.map((c) => sepCellFor(cellAlign(c))).join('|') + '|');
158
171
 
159
- const bodyRows = tbody ? childrenNamed(tbody, 'TR') : [];
160
172
  bodyRows.forEach((tr) => {
161
173
  const cells = elementChildren(tr).filter(isCell);
162
174
  lines.push(serializeRow(cells, unsupported));