@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.
- package/lib/editor/blockmap.js +126 -8
- package/lib/editor/client.js +2431 -652
- package/lib/editor/convert-md.js +200 -0
- package/lib/editor/indent-clamp.js +152 -0
- package/lib/editor/inline-md.js +25 -1
- package/lib/editor/list-md.js +567 -4
- package/lib/editor/server.js +56 -3
- package/lib/md2doc.js +430 -39
- package/package.json +2 -2
package/lib/editor/list-md.js
CHANGED
|
@@ -236,9 +236,20 @@
|
|
|
236
236
|
n++;
|
|
237
237
|
lines.push((indentPrefix + marker + itemMd).replace(/[ \t]+$/, ''));
|
|
238
238
|
|
|
239
|
-
// childIndentPrefix derives from
|
|
240
|
-
//
|
|
241
|
-
|
|
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
|
-
|
|
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
|
});
|