@helping-ai-workflow/md2doc 2.10.1 → 2.11.1

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,557 @@
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
+ // ── The setext-underline hazard on an EMPTY bulleted item ───────────────
381
+ // An empty item is emitted as a BARE marker ('-', no trailing space): '- '
382
+ // lexes as a PARAGRAPH, which is why lib/editor/client.js's
383
+ // BLOCK_SKELETONS.list is a bare marker too, and that reasoning is not being
384
+ // undone here. But CommonMark gives that same bare '-' a SECOND reading, and
385
+ // which one wins is decided by the line ABOVE it: an EMPTY list item may not
386
+ // interrupt a paragraph, and a line consisting of nothing but '-' standing
387
+ // at an open paragraph's own content column is a SETEXT H2 UNDERLINE.
388
+ //
389
+ // That is exactly the line an indent produces. `- beta` + Enter + Tab wrote
390
+ //
391
+ // - alpha
392
+ // - beta
393
+ // -
394
+ //
395
+ // and marked (14.1.4) reads it back as `<li><h2>beta</h2></li>`: the new
396
+ // item is gone and the parent's text has been re-typed as a heading. Two
397
+ // ordinary keystrokes, silent content destruction, measured on 2.11.0.
398
+ //
399
+ // The hazard is POSITIONAL, not a property of the marker. It exists only
400
+ // where the previously emitted line is a paragraph at this line's own
401
+ // column, which inside a run means precisely "this item is the FIRST item of
402
+ // a deeper nesting" (`prev.indent < indent`) — the parent's own text (or its
403
+ // lazy continuation) is then the line immediately above, and a child's
404
+ // marker column IS the parent's content column by construction. An empty
405
+ // item that follows a SAME-level sibling is safe (the line above is a marker
406
+ // line, so '-' can only be another marker there), and so is one whose
407
+ // predecessor is deeper.
408
+ //
409
+ // Every other shape is left byte-identical:
410
+ // * ordered — '1.' is not a run of dashes, so it is not a setext underline;
411
+ // * task — a content-free task item never emits a marker line at all
412
+ // (it becomes `pending`, a same-line prefix), which is what the
413
+ // `head === indentPrefix + marker` test below detects;
414
+ // * non-empty items, and every empty item at top level.
415
+ //
416
+ // The escape is a U+200B ZERO WIDTH SPACE: real, non-whitespace content to
417
+ // the block lexer (so the line is a list item, not an underline) and nothing
418
+ // at all to a reader. It is the same trade-off client.js already documents
419
+ // for BLOCK_SKELETONS.paragraph. It never becomes part of the user's text:
420
+ // lib/md2doc.js's edit-mode list renderer renders a U+200B-only item as an
421
+ // EMPTY surface, so the next keystroke lands in an empty item, and the
422
+ // itemMd normalisation above takes the character back off on the way out.
423
+ const SETEXT_ESCAPE = '\u200b';
424
+ const SETEXT_ESCAPE_RE = /^\u200b+$/;
425
+ function escapeSetextHazard(firstOwnLine, ctx) {
426
+ if (firstOwnLine !== '') return firstOwnLine;
427
+ if (ctx.listType !== 'ul' || ctx.isTask) return firstOwnLine;
428
+ // A `pending` task prefix has already been joined onto `head`, so the line
429
+ // is not a bare run of dashes and needs nothing.
430
+ if (ctx.head !== ctx.indentPrefix + ctx.marker) return firstOwnLine;
431
+ if (!ctx.prev || ctx.indent <= ctx.prev.indent) return firstOwnLine;
432
+ return SETEXT_ESCAPE;
433
+ }
434
+
435
+ // `opts.carryOver` (spec §3.4, 多行 li 的旁觀者規則): a map of block id →
436
+ // that block's ORIGINAL source lines. A hard-wrapped item named there is NOT
437
+ // re-serialized; its own bytes are replayed with the column difference of its
438
+ // new marker applied to every line it owns.
439
+ //
440
+ // This is not a fidelity nicety, it is the difference between Tab working on
441
+ // a real document and not working at all. Structural refusal is run-wide
442
+ // (RULING F-R) and MULTILINE feeds it, so before this a single hard-wrapped
443
+ // item anywhere in a run refused the whole run — and 80.6% of this repo's own
444
+ // CHANGELOG.md list items are hard-wrapped. Dropping MULTILINE from the gate
445
+ // is not the fix on its own either: the round trip through the inline
446
+ // serializer is LOSSY for a bystander (measured on CHANGELOG.md at v2.10.2 —
447
+ // '~5px' comes back as '\~5px', because escapeText() escapes a tilde marked
448
+ // never treated as markup). Replaying the source is what keeps an untouched
449
+ // item byte-identical, which is in turn what stops the commit's line-range
450
+ // replace from rewriting lines the user never looked at.
451
+ function serializeBlocks(blockEls, opts) {
452
+ const carryOver = (opts && opts.carryOver) || null;
453
+ const unsupported = [];
454
+ const unsupportedByLi = [];
455
+ const multiLineBlockIds = [];
456
+ const lineMeta = [];
457
+ const lines = [];
458
+ // widths[k] = the marker width (as a literal run of spaces) of the
459
+ // innermost item seen at depth k; counters[k] = the running ordinal of
460
+ // the run currently open at depth k.
461
+ const widths = [];
462
+ const counters = [];
463
+ // types[k] = the list type of the last block seen at depth k, so rule (b)
464
+ // survives a deeper item sitting between two same-depth blocks.
465
+ const types = [];
466
+ let prev = null; // { indent, listType }
467
+
468
+ // ── Round 6: a CONTENT-FREE TASK item cannot be given a line of its own ──
469
+ // marked only reads '[ ]' / '[x]' as a checkbox when content follows ON THE
470
+ // SAME LINE: '- [ ]\n - b' lexes as '<li>[ ]<ul>…' — the checkbox becomes
471
+ // literal text and its state stops being machine-readable. Same-line
472
+ // nesting ('- [ ] - b') is exactly that shape: the outer item's own content
473
+ // is empty because its child starts on its line.
474
+ //
475
+ // So such an item does not emit a line; it becomes a PREFIX carried onto
476
+ // the next emitted line, which restores the source's own same-line form.
477
+ // The columns it contributes to that line's indent prefix — its BULLET's
478
+ // width, checkbox excluded, the same rule `widths` uses — are already
479
+ // written by the prefix, so they come off the front of the line it joins.
480
+ //
481
+ // A PLAIN content-free item keeps its own line: '-\n - b' and '- - b' are
482
+ // the same tree to marked, and the canonical form is preferable because it
483
+ // gives the item a source line of its own (blockmap can then hand it a
484
+ // well-formed range). Only the checkbox forces the same-line form.
485
+ //
486
+ // The prefix is FLUSHED as its own line — degrading to literal '[ ]', which
487
+ // is what the source said anyway — whenever the next block is not deeper,
488
+ // i.e. when the item has no child to attach to. Merging it onto a SIBLING
489
+ // would invent nesting that the document never had.
490
+ let pending = null; // { text, cols, indent, blockId, indentPrefix, marker }
491
+
492
+ function flushPending() {
493
+ if (!pending) return;
494
+ lines.push(pending.text.replace(/[ \t]+$/, ''));
495
+ lineMeta.push({ blockId: pending.blockId, indentPrefix: pending.indentPrefix,
496
+ marker: pending.marker });
497
+ pending = null;
498
+ }
499
+
500
+ // Drops up to `n` leading SPACE columns — the ones `pending` has already
501
+ // physically written on this line.
502
+ function stripCols(line, n) {
503
+ let k = 0;
504
+ while (k < n && line.charAt(k) === ' ') k++;
505
+ return line.slice(k);
506
+ }
507
+
508
+ toArray(blockEls).forEach((blockEl) => {
509
+ const blockId = blockEl.getAttribute('data-block-id');
510
+
511
+ // §3.8 rule (c): a non-li block terminates a run. It has no marker and
512
+ // no .ed-li-text contract, so emitting a '- <text>' line for it would
513
+ // silently invent list structure. Flag it, never swallow it — the same
514
+ // principle the alien-child branch below follows. Names are uppercased
515
+ // to match the element-name convention the rest of `unsupported` uses.
516
+ const blockType = blockEl.getAttribute('data-block-type');
517
+ if (blockType !== 'li') {
518
+ unsupported.push(String(blockType || 'UNKNOWN').toUpperCase());
519
+ // A non-li block ends the run (§3.8 rule c), so a task prefix still
520
+ // open cannot have a child after it — put it back on its own line
521
+ // rather than letting it reach across the break.
522
+ flushPending();
523
+ return;
524
+ }
525
+
526
+ const indent = Number(blockEl.getAttribute('data-indent')) || 0;
527
+ // Only a DEEPER block is the pending item's child; anything else means it
528
+ // had none, so its marker goes back onto a line of its own.
529
+ if (pending && indent <= pending.indent) flushPending();
530
+ const listType = blockEl.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul';
531
+ const isTask = blockEl.getAttribute('data-task') === '1';
532
+ // §3.8 rule (d): this block is the first item of its own list TOKEN.
533
+ const isListStart = blockEl.getAttribute('data-list-start') === '1';
534
+
535
+ // Run bookkeeping. Going DEEPER opens a brand-new run at that depth
536
+ // (even when the type matches); a list-type change against the last
537
+ // block AT THIS DEPTH closes that run and opens another; coming back
538
+ // UP leaves this depth's run open (deeper items did not break it) but
539
+ // clears every deeper depth so the next descent restarts at 1.
540
+ //
541
+ // Rule (d) is the fourth reset, and rules (a)-(c) cannot derive it:
542
+ // ' 1. x' followed by ' 1) y' is TWO nested list tokens (a delimiter
543
+ // change starts a new list) at the SAME depth with the SAME type, so
544
+ // without this the second list is renumbered as a continuation of the
545
+ // first ('2. y') and a commit rewrites a list the user never touched.
546
+ // lib/md2doc.js stamps the attribute; only the renderer still knows
547
+ // where marked's token boundaries were.
548
+ if (isListStart || !prev || indent > prev.indent || types[indent] !== listType) {
549
+ counters[indent] = 0;
550
+ }
551
+ for (let k = counters.length - 1; k > indent; k--) {
552
+ counters[k] = 0;
553
+ types[k] = undefined;
554
+ }
555
+ types[indent] = listType;
556
+
557
+ const indentPrefix = widths.slice(0, indent).join('');
558
+ counters[indent] = (counters[indent] || 0) + 1;
559
+
560
+ const checkEl = firstChildWithClass(blockEl, 'ed-li-check');
561
+ const isChecked = checkEl ? checkEl.getAttribute('data-checked') === '1' : false;
562
+ // Two-part marker: the bullet (ordered ordinal or '- ') and an
563
+ // optional checkbox, kept independent so an ordered task list gets
564
+ // BOTH ('1. [ ] todo') — RULING F-N, same as serializeList above.
565
+ const bullet = listType === 'ol' ? (counters[indent] + '. ') : '- ';
566
+ const marker = isTask ? bullet + (isChecked ? '[x] ' : '[ ] ') : bullet;
567
+
568
+ const textEl = firstChildWithClass(blockEl, 'ed-li-text');
569
+ const innerUnsupported = [];
570
+ let itemMd = '';
571
+ if (textEl) {
572
+ // The surface's own children are filtered through isBlankText() —
573
+ // NOT isBlankBlockText() — before they reach the inline serializer,
574
+ // exactly as serializeListNode() above does for a tree <li>. A LOOSE
575
+ // item's surface is `\n<p>text</p>\n` (marked pretty-prints block-level
576
+ // output), and inline-md.js has no reason to treat a text node's "\n"
577
+ // specially: escapeText() emits it verbatim, so ONE list item turned
578
+ // into THREE physical lines — a direct violation of the gate's
579
+ // one-line-per-item contract, and worse, it desynchronised `lineMeta`
580
+ // from `md.split('\n')` so a caller committing a single item's line by
581
+ // index wrote a DIFFERENT item's line into it (observed: editing the
582
+ // item after a loose blank replaced its source line with ' - ').
583
+ // isBlankText() is the right predicate here and isBlankBlockText() is
584
+ // not: the artifact marked emits always contains a newline, while a
585
+ // bare ' ' BETWEEN INLINE NODES is meaningful spacing the item must
586
+ // keep. (isBlankBlockText() stays as-is for the block's own direct
587
+ // children, where the template's inter-element whitespace can legally
588
+ // be a single space with no newline.)
589
+ const inlineKids = [];
590
+ allChildNodes(textEl).forEach((c) => { if (!isBlankText(c)) inlineKids.push(c); });
591
+ const res = inlineMd.serializeInline({ childNodes: inlineKids });
592
+ itemMd = res.md;
593
+ res.unsupported.forEach((u) => innerUnsupported.push(u));
594
+ // The other half of the SETEXT ESCAPE applied at the emission site
595
+ // below: a U+200B is this serializer's own way of saying "empty item
596
+ // in a position where a bare marker would re-lex as a heading
597
+ // underline", so this serializer is also the one that takes it back
598
+ // off. Without it, an escaped item that later moves somewhere the
599
+ // escape is not needed (Shift+Tab back to the top level) would keep a
600
+ // zero-width character it never asked for, and the item would stop
601
+ // reading as empty to every `itemMd === ''` test in this function.
602
+ if (SETEXT_ESCAPE_RE.test(itemMd)) itemMd = '';
603
+ }
604
+
605
+ // Anything in the block that is neither chrome nor the text surface is
606
+ // content we cannot represent — flag it, never swallow it.
607
+ allChildNodes(blockEl).forEach((kid) => {
608
+ if (kid.nodeType === 3) {
609
+ if (!isBlankBlockText(kid)) unsupported.push('TEXT');
610
+ return;
611
+ }
612
+ if (kid.nodeType !== 1) return;
613
+ for (let i = 0; i < LI_CHROME.length; i++) {
614
+ if (hasClass(kid, LI_CHROME[i])) return;
615
+ }
616
+ unsupported.push(kid.nodeName);
617
+ });
618
+
619
+ // A block may own a contiguous RANGE of lines — and if it does, that is
620
+ // reported for STRUCTURAL operations only.
621
+ //
622
+ // A hard-wrapped ("lazy continuation") item is ordinary markdown: 22.4%
623
+ // of this repo's own 799 list items are one, every CHANGELOG bullet
624
+ // included. Its .ed-li-text holds a literal '\n', which inline-md.js's
625
+ // escapeText() emits verbatim. An earlier revision made such an item
626
+ // UNSUPPORTED outright and truncated it to one line, which kept `lines`
627
+ // and `lineMeta` parallel but turned a fifth of every real document
628
+ // read-only. Spec §4.1 asks for less than that: a multi-line li refuses
629
+ // structural operations as an operation TARGET, and 文字編輯不受影響.
630
+ //
631
+ // So MULTILINE goes to `unsupported` — which is what
632
+ // listRunSupportsStructuralEdit() gates on, and what forces the per-li
633
+ // partial commit path so no other item is re-emitted — and NOT to
634
+ // `unsupportedByLi`, which is the channel canWysiwygForLi() and
635
+ // resolveBurst()'s F-W text-edit refusal read. Same split the tree
636
+ // serializer has always used for loose 'P'. Callers that must ignore it
637
+ // for arming filter on STRUCTURAL_ONLY_UNSUPPORTED rather than
638
+ // hard-coding the name.
639
+ // `multiLineBlockIds` is the same fact with an OWNER attached. The flat
640
+ // `unsupported` array has no attribution, so a caller reading it can only
641
+ // ask "does this run contain a hard-wrapped item", which is exactly the
642
+ // question that made Tab useless on real documents. Spec §4.1 asks a
643
+ // narrower one — "is the block I am about to operate ON hard-wrapped" —
644
+ // and that needs the id.
645
+ // T7 — WHAT THIS FLAG IS AND IS NOT. It answers "does this item's
646
+ // SURFACE TEXT hold a newline", which catches a LAZY continuation and
647
+ // nothing else. It is NOT the answer to "does this block own more than
648
+ // one source line", and the difference is not academic: a markdown HARD
649
+ // BREAK is two trailing SPACES, which marked renders as <br>, so such an
650
+ // item's surface holds no '\n' at all and this flag calls it
651
+ // single-line. Measured: Enter on one used to be accepted and collapsed
652
+ // its two source lines into ONE bearing the literal text '<br>'
653
+ // ('- a \n b ~t' -> '- a<br>b \~t'), and as a BYSTANDER of somebody
654
+ // else's Tab it was rewritten the same way.
655
+ //
656
+ // Neither of those is fixable HERE, because this module sees a DOM and
657
+ // the answer lives in the file. Widening the test to '<br>' was tried
658
+ // and is wrong in the other direction: inline-md.js emits '<br>' for a
659
+ // real hard break, for a literal '<br>' the source spelled out by hand,
660
+ // AND for the placeholder <br> Chromium leaves behind when the user
661
+ // deletes an item's last character — so it made an EMPTIED item
662
+ // structurally frozen (Enter could no longer remove it).
663
+ // lib/editor/client.js owns both decisions instead, off `blocks`:
664
+ // listRunSupportsStructuralEdit() refuses a target whose range spans
665
+ // several lines, and bystanderCarryOver() replays every untouched
666
+ // block's own bytes. This flag stays as the narrow thing it always was.
667
+ const isMultiLine = /\n/.test(itemMd);
668
+ if (isMultiLine) {
669
+ unsupported.push('MULTILINE');
670
+ multiLineBlockIds.push(blockId);
671
+ }
672
+
673
+ // Per-li attribution, keyed by the getAttribute() string block id
674
+ // (compared against String(burst.blockId) by the caller).
675
+ if (innerUnsupported.length > 0) {
676
+ unsupportedByLi.push({ blockId: blockId, names: innerUnsupported.slice() });
677
+ }
678
+ innerUnsupported.forEach((u) => unsupported.push(u));
679
+
680
+ // BULLET, not `marker`: the '[ ] ' checkbox is content, not marker
681
+ // width — see the INDENT note above. Using marker.length here puts a
682
+ // task item's child outside marked's acceptance window and destroys it.
683
+ widths[indent] = new Array(bullet.length + 1).join(' ');
684
+ widths.length = indent + 1;
685
+
686
+ // Emission. Leading whitespace on the item's own first line is stripped
687
+ // for the same reason as in serializeListNode (a dropped leading element
688
+ // would leave a stray double space); trailing whitespace is never
689
+ // emitted, on any line.
690
+ //
691
+ // CONTINUATION COLUMN: every line after the first is re-indented to the
692
+ // item's CONTENT column — `indentPrefix + bullet width` — which is the
693
+ // same accumulated-width rule §3.4's errata pinned for a CHILD LIST's
694
+ // indent, and for the same reason: that column is where marked resumes
695
+ // the item's own paragraph. The checkbox is excluded from it ('- [ ] ' is
696
+ // a 2-column parent), exactly as `widths` above computes. One column too
697
+ // few and the continuation lexes as a sibling or a lazy line of the wrong
698
+ // item; four too many and it lexes as an INDENTED CODE BLOCK. The line's
699
+ // own leading whitespace is dropped first so the column is ours to state,
700
+ // not the DOM's to leak. test/list-md.test.js case 31(d) round-trips this
701
+ // through marked.lexer.
702
+ //
703
+ // lineMeta gets ONE ENTRY PER EMITTED LINE, each naming the block that
704
+ // owns it, so consecutive entries may share a blockId. The invariant is
705
+ // `lineMeta.length === md.split('\n').length`; violating it is precisely
706
+ // what let a caller's line index address a DIFFERENT item's line.
707
+ const ownLines = itemMd.replace(/^[ \t]+/, '').split('\n');
708
+ const contPrefix = indentPrefix + new Array(bullet.length + 1).join(' ');
709
+ // Round 6: join an open task prefix (above) to this line, dropping the
710
+ // columns it already wrote; then, if THIS item is itself a content-free
711
+ // task item, become the prefix instead of emitting.
712
+ let head = indentPrefix + marker;
713
+ // `indentPrefix` in lineMeta is the text PHYSICALLY standing before this
714
+ // line's own marker. Normally that is the accumulated width; on a line a
715
+ // task prefix has joined, it is that prefix — which is what a caller
716
+ // replaying the line (client.js's per-li commit) and §3.4's colDelta both
717
+ // need, and why this does not want a separate field.
718
+ let metaPrefix = indentPrefix;
719
+ if (pending) {
720
+ head = pending.text + stripCols(indentPrefix + marker, pending.cols);
721
+ metaPrefix = head.slice(0, head.length - marker.length);
722
+ pending = null;
723
+ }
724
+ if (isTask && ownLines.length === 1 && ownLines[0] === '') {
725
+ pending = { text: head, cols: indentPrefix.length + bullet.length, indent: indent,
726
+ blockId: blockId, indentPrefix: metaPrefix, marker: marker };
727
+ prev = { indent: indent, listType: listType };
728
+ return;
729
+ }
730
+ // ── §3.4 bystander replay ────────────────────────────────────────────
731
+ // The caller named this block's own source lines, so they are emitted
732
+ // instead of the round trip. Only the marker is re-stated (it has to be:
733
+ // an ordinal or an ancestor's width may have moved), and the resulting
734
+ // COLUMN difference is applied to every continuation line — applying it
735
+ // to the marker line alone is what turns a continuation into an indented
736
+ // code block.
737
+ //
738
+ // Declined, falling through to the ordinary path, when the arithmetic
739
+ // would be a guess rather than a measurement: no marker on the first
740
+ // source line (nothing to measure the old prefix against), or a TAB in
741
+ // that prefix (its column count depends on a tab stop this module does
742
+ // not get to choose).
743
+ //
744
+ // Which blocks are named is entirely the CALLER's decision, and it is
745
+ // not the same question as `multiLineBlockIds` above: client.js keys the
746
+ // map on each block's own SOURCE LINE RANGE, which is the only place the
747
+ // truth lives (this module sees a DOM, not a file), and names every
748
+ // block it did not itself mutate — not just the multi-line ones. A
749
+ // single-line bystander needs the replay too: escapeText() escapes a
750
+ // tilde marked never treats as markup, so '~5px' came back '\~5px' in an
751
+ // item the gesture never touched.
752
+ const carried = carryOver && blockId !== null && carryOver[blockId];
753
+ const carriedSplit = carried && carried.length ? splitSourceMarkers(carried[0]) : null;
754
+ const carriedOk = !!carriedSplit && carriedSplit.prefix !== '' &&
755
+ carriedSplit.prefix.indexOf('\t') === -1;
756
+ if (carriedOk) {
757
+ // No trailing-whitespace trim on a replayed line, unlike the
758
+ // re-serialized path below: these bytes are the file's own, and a
759
+ // markdown hard break IS two trailing spaces. Trimming would edit an
760
+ // item the operation was explicitly told not to touch.
761
+ lines.push(head + carriedSplit.content);
762
+ lineMeta.push({ blockId: blockId, indentPrefix: metaPrefix, marker: marker });
763
+ const colDelta = head.length - carriedSplit.prefix.length;
764
+ for (let k = 1; k < carried.length; k++) {
765
+ const raw = typeof carried[k] === 'string' ? carried[k] : '';
766
+ // colDelta 0 is the overwhelmingly common case (nothing about this
767
+ // item's marker moved), and it is emitted BYTE-FOR-BYTE — including
768
+ // any tab indentation, which §3.11(4) says only a re-serialized line
769
+ // may normalise.
770
+ if (colDelta === 0) {
771
+ lines.push(raw);
772
+ lineMeta.push({ blockId: blockId, indentPrefix: /^[ \t]*/.exec(raw)[0], marker: '' });
773
+ continue;
774
+ }
775
+ const lead = /^[ \t]*/.exec(raw)[0];
776
+ const body = raw.slice(lead.length);
777
+ const prefix = body === '' ? '' : spaces(Math.max(0, lead.length + colDelta));
778
+ lines.push(prefix + body);
779
+ lineMeta.push({ blockId: blockId, indentPrefix: prefix, marker: '' });
780
+ }
781
+ prev = { indent: indent, listType: listType };
782
+ return;
783
+ }
784
+ lines.push((head + escapeSetextHazard(ownLines[0], {
785
+ head: head, indentPrefix: indentPrefix, marker: marker,
786
+ listType: listType, isTask: isTask, indent: indent, prev: prev,
787
+ })).replace(/[ \t]+$/, ''));
788
+ lineMeta.push({ blockId: blockId, indentPrefix: metaPrefix, marker: marker });
789
+ for (let k = 1; k < ownLines.length; k++) {
790
+ const body = ownLines[k].replace(/^[ \t]+/, '').replace(/[ \t]+$/, '');
791
+ // A blank continuation would emit a blank line, which re-lexes the
792
+ // whole list as LOOSE and changes every item's rendering. Content that
793
+ // genuinely contains a blank line arrives as separate <p> elements
794
+ // instead (the 'P' path), so dropping an empty segment here loses
795
+ // nothing; both `lines` and `lineMeta` skip it together, so they stay
796
+ // parallel.
797
+ if (body === '') continue;
798
+ lines.push(contPrefix + body);
799
+ lineMeta.push({ blockId: blockId, indentPrefix: contPrefix, marker: '' });
800
+ }
801
+ prev = { indent: indent, listType: listType };
802
+ });
803
+ // A task prefix still open at the end of the run had no child at all.
804
+ flushPending();
805
+
806
+ return {
807
+ md: lines.join('\n'),
808
+ unsupported: unsupported,
809
+ unsupportedByLi: unsupportedByLi,
810
+ multiLineBlockIds: multiLineBlockIds,
811
+ lineMeta: lineMeta,
812
+ };
813
+ }
814
+
815
+ // Names that appear in `unsupported` to gate STRUCTURAL operations but that
816
+ // must NOT stop a block being armed for text editing. Exported so the client
817
+ // and the serializer cannot drift apart on the distinction.
818
+ const STRUCTURAL_ONLY_UNSUPPORTED = ['MULTILINE'];
819
+
820
+ return { serializeList, serializeBlocks, STRUCTURAL_ONLY_UNSUPPORTED };
258
821
  });