@helping-ai-workflow/md2doc 2.8.0 → 2.10.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.
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+ (function (root, factory) {
3
+ if (typeof module === 'object' && module.exports) module.exports = factory();
4
+ else root.md2docHistory = factory();
5
+ })(typeof self !== 'undefined' ? self : this, function () {
6
+
7
+ function createBurstHistory(captureFn, options = {}) {
8
+ const debounceMs = options?.debounceMs ?? 400;
9
+ const getNow = options?.now ?? (() => Date.now());
10
+
11
+ let stack = [];
12
+ let redoTail = [];
13
+ let lastNoteTime = null;
14
+ let isPendingSnap = false;
15
+
16
+ return {
17
+ start() {
18
+ // captureFn exceptions intentionally bubble to caller
19
+ stack = [captureFn()];
20
+ redoTail = [];
21
+ lastNoteTime = null;
22
+ isPendingSnap = false;
23
+ },
24
+
25
+ snap(reason) {
26
+ // reason is currently unused metadata (for future per-reason hooks)
27
+ // captureFn exceptions intentionally bubble to caller
28
+ const current = captureFn();
29
+ const top = stack[stack.length - 1];
30
+
31
+ if (current !== top) {
32
+ stack.push(current);
33
+ redoTail = [];
34
+ }
35
+
36
+ isPendingSnap = false;
37
+ },
38
+
39
+ noteTyping() {
40
+ const nowTime = getNow();
41
+
42
+ if (isPendingSnap && lastNoteTime !== null && nowTime - lastNoteTime >= debounceMs) {
43
+ // Debounce interval has elapsed, snap the pending capture
44
+ this.snap('typing');
45
+ }
46
+
47
+ // Mark/update pending snapshot
48
+ isPendingSnap = true;
49
+ lastNoteTime = nowTime;
50
+ },
51
+
52
+ flushTyping() {
53
+ if (isPendingSnap) {
54
+ this.snap('typing');
55
+ }
56
+ },
57
+
58
+ undo() {
59
+ // First flush any pending typing debounce
60
+ this.flushTyping();
61
+
62
+ // Then step back
63
+ if (stack.length <= 1) return null;
64
+
65
+ redoTail.push(stack.pop());
66
+ return stack[stack.length - 1];
67
+ },
68
+
69
+ redo() {
70
+ if (redoTail.length === 0) return null;
71
+
72
+ stack.push(redoTail.pop());
73
+ return stack[stack.length - 1];
74
+ },
75
+
76
+ atBottom() {
77
+ return stack.length === 1;
78
+ },
79
+
80
+ size() {
81
+ return stack.length;
82
+ },
83
+
84
+ dispose() {
85
+ stack = [];
86
+ redoTail = [];
87
+ isPendingSnap = false;
88
+ lastNoteTime = null;
89
+ }
90
+ };
91
+ }
92
+
93
+ return { createBurstHistory };
94
+ });
@@ -0,0 +1,220 @@
1
+ 'use strict';
2
+ (function (root, factory) {
3
+ if (typeof module === 'object' && module.exports) module.exports = factory();
4
+ else root.md2docInlineMd = factory();
5
+ })(typeof self !== 'undefined' ? self : this, function () {
6
+
7
+ // Escaping rules (verbatim from task-2 brief, plus review fix 2026-08-25,
8
+ // plus the strikethrough/underline fix below):
9
+ // - backslash / backtick / asterisk / brackets escaped always
10
+ // - underscore escaped ONLY at a word boundary (snake_case stays clean)
11
+ // - literal `<` becomes `&lt;` (HTML-safe, keeps <br> etc. unambiguous)
12
+ // - tilde escaped UNCONDITIONALLY (every `~`, not just doubled runs) —
13
+ // since DEL/S now round-trips through GFM `~~...~~` (see walkChildren
14
+ // below), a literal `~~` typed by the user (no strikethrough intent at
15
+ // all) would otherwise silently turn into real strikethrough the next
16
+ // time the source is re-rendered. Escaping every single `~` is the
17
+ // simplest deterministic rule that can't under-escape a run of any
18
+ // length; marked un-escapes `\~` back to a literal `~` on parse either
19
+ // way, so this is lossless for genuinely single tildes too.
20
+ // `]` mirrors `[` (CRITICAL 2 fix): escaping only `[` leaves an unbalanced
21
+ // literal `]` that breaks bracket pairing in the enclosing link/citation
22
+ // syntax — e.g. an <a> whose label is itself "[text]" would otherwise
23
+ // parse as a stray "[[text]](<a...>...)" instead of one working link.
24
+ function isWordChar(ch) {
25
+ return ch !== undefined && /\w/.test(ch);
26
+ }
27
+
28
+ function escapeText(s) {
29
+ let out = '';
30
+ for (let i = 0; i < s.length; i++) {
31
+ const c = s[i];
32
+ if (c === '\\') { out += '\\\\'; continue; }
33
+ if (c === '`') { out += '\\`'; continue; }
34
+ if (c === '*') { out += '\\*'; continue; }
35
+ if (c === '[') { out += '\\['; continue; }
36
+ if (c === ']') { out += '\\]'; continue; }
37
+ if (c === '<') { out += '&lt;'; continue; }
38
+ if (c === '~') { out += '\\~'; continue; }
39
+ if (c === '_') {
40
+ const prev = s[i - 1];
41
+ const next = s[i + 1];
42
+ out += (isWordChar(prev) && isWordChar(next)) ? '_' : '\\_';
43
+ continue;
44
+ }
45
+ out += c;
46
+ }
47
+ return out;
48
+ }
49
+
50
+ // Real contenteditable output wraps plain runs in attribute-less <span>s
51
+ // (Chrome/Firefox formatting artifacts). Those are transparent. A <span>
52
+ // carrying any of these attributes is a real style/class/behavior carrier
53
+ // we don't support yet, so it is reported via `unsupported` instead of
54
+ // unwrapped. Widened (review fix 2026-08-25, IMPORTANT 4) past style/class
55
+ // to the realistic contenteditable/extension attribute set: browsers and
56
+ // editing extensions (spellcheck UI, Grammarly, TinyMCE-style paste) stamp
57
+ // these onto spans that carry real, non-plain-text intent.
58
+ const SPAN_ATTR_PROBE = [
59
+ 'style', 'class', 'id', 'data-mce-style', 'data-mce-bogus',
60
+ 'dir', 'contenteditable', 'spellcheck', 'lang', 'title',
61
+ 'data-gramm', 'data-gramm_editor', 'data-enable-grammarly',
62
+ ];
63
+
64
+ function spanHasAttributes(node) {
65
+ for (let i = 0; i < SPAN_ATTR_PROBE.length; i++) {
66
+ const v = node.getAttribute(SPAN_ATTR_PROBE[i]);
67
+ // Deliberate: an attribute present but set to '' (e.g. class="") is
68
+ // treated the same as absent — it carries no actual style/behavior
69
+ // intent, so it shouldn't disqualify the span from being transparent.
70
+ if (v !== null && v !== undefined && v !== '') return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ // CommonMark code-span fence: the fence must be longer than the longest
76
+ // run of consecutive backticks inside the content, or the fence closes
77
+ // early on that run (CRITICAL 1 fix, verified against marked.parseInline:
78
+ // a fixed 2-backtick fence corrupts content containing "``"). Padding is
79
+ // *required* whenever the content touches the fence boundary with a
80
+ // backtick (verified: unpadded "```x``" fails to parse as code at all);
81
+ // for interior-only backtick runs padding is optional but harmless
82
+ // (verified round-trip-identical either way), so we always pad once a
83
+ // fence is needed — simpler, and preserves the original single-backtick
84
+ // test's exact padded form.
85
+ function serializeCode(node) {
86
+ const raw = node.textContent;
87
+ const runs = raw.match(/`+/g);
88
+ const longestRun = runs ? Math.max.apply(null, runs.map((r) => r.length)) : 0;
89
+ if (longestRun === 0) return '`' + raw + '`';
90
+ const fence = new Array(longestRun + 2).join('`');
91
+ return fence + ' ' + raw + ' ' + fence;
92
+ }
93
+
94
+ // IMPORTANT 3 (degrade-never-lose): the citation form only round-trips
95
+ // through md2doc's own citation regex ([^\]\n]+) when the anchor's body
96
+ // is a single plain-text run with no embedded `]`. Nested formatting
97
+ // (childNodes isn't exactly one text node) or a body containing `]`
98
+ // between the outer brackets would either silently flatten real content
99
+ // or emit citation syntax md2doc can't re-parse — so those degrade to
100
+ // unsupported instead of emitting best-effort-but-broken markdown.
101
+ function isCitationEligible(node, text) {
102
+ if (node.childNodes.length !== 1 || node.childNodes[0].nodeType !== 3) return false;
103
+ const inner = text.slice(1, -1);
104
+ return inner.indexOf(']') === -1;
105
+ }
106
+
107
+ function serializeAnchor(node, unsupported) {
108
+ const href = node.getAttribute('href');
109
+ const text = node.textContent;
110
+ // citation: <a href="#slug">[body]</a> -> [[body]]
111
+ if (href && href.charAt(0) === '#' && /^\[.*\]$/.test(text)) {
112
+ if (isCitationEligible(node, text)) {
113
+ return '[[' + text.slice(1, -1) + ']]';
114
+ }
115
+ unsupported.push('A');
116
+ return '';
117
+ }
118
+ const label = walkChildren(node.childNodes, unsupported);
119
+ return '[' + label + '](' + (href || '') + ')';
120
+ }
121
+
122
+ // Walk a sibling list. A top-level <div> is a contenteditable line-break
123
+ // artifact (browsers split lines with <div> rather than <br>), so each
124
+ // <div> boundary after the first emits a <br> and its children are
125
+ // spliced inline, never nested further.
126
+ function walkChildren(nodes, unsupported) {
127
+ let out = '';
128
+ let firstSegment = true;
129
+ for (let i = 0; i < nodes.length; i++) {
130
+ const node = nodes[i];
131
+ if (node.nodeType === 3) {
132
+ out += escapeText(node.textContent);
133
+ firstSegment = false;
134
+ continue;
135
+ }
136
+ if (node.nodeType !== 1) continue;
137
+ const name = node.nodeName;
138
+ if (name === 'DIV') {
139
+ if (!firstSegment) out += '<br>';
140
+ out += walkChildren(node.childNodes, unsupported);
141
+ firstSegment = false;
142
+ continue;
143
+ }
144
+ if (name === 'SPAN') {
145
+ if (spanHasAttributes(node)) {
146
+ unsupported.push(name);
147
+ } else {
148
+ out += walkChildren(node.childNodes, unsupported);
149
+ }
150
+ firstSegment = false;
151
+ continue;
152
+ }
153
+ if (name === 'STRONG' || name === 'B') {
154
+ out += '**' + walkChildren(node.childNodes, unsupported) + '**';
155
+ firstSegment = false;
156
+ continue;
157
+ }
158
+ if (name === 'EM' || name === 'I') {
159
+ out += '*' + walkChildren(node.childNodes, unsupported) + '*';
160
+ firstSegment = false;
161
+ continue;
162
+ }
163
+ if (name === 'DEL' || name === 'S') {
164
+ // GFM strikethrough — verified marked (gfm: true, the renderer's own
165
+ // setOptions()) round-trips `~~x~~` to `<del>x</del>`; `<s>` is
166
+ // accepted on input (some contenteditable/paste paths produce it)
167
+ // but the toolbar/serializer always speak DEL, matching what marked
168
+ // itself emits.
169
+ out += '~~' + walkChildren(node.childNodes, unsupported) + '~~';
170
+ firstSegment = false;
171
+ continue;
172
+ }
173
+ if (name === 'U') {
174
+ // Underline has no Markdown/GFM syntax at all, so this emits literal
175
+ // inline HTML by design — marked passes raw inline `<u>...</u>`
176
+ // straight through untouched (verified), which is exactly what we
177
+ // want: the rendered output shows an underline, and re-opening the
178
+ // WYSIWYG editor sees the same <u> element back (server-rendered
179
+ // HTML round-trips through the DOM parser the same way STRONG/EM/
180
+ // DEL do). No escaping concern the other marks have: unlike `~`/`*`/
181
+ // backtick, literal `<u>` typed as plain text is already escaped by
182
+ // the `<` -> `&lt;` rule above, so it can never collide with a real
183
+ // toolbar-made underline.
184
+ out += '<u>' + walkChildren(node.childNodes, unsupported) + '</u>';
185
+ firstSegment = false;
186
+ continue;
187
+ }
188
+ if (name === 'CODE') {
189
+ out += serializeCode(node);
190
+ firstSegment = false;
191
+ continue;
192
+ }
193
+ if (name === 'A') {
194
+ out += serializeAnchor(node, unsupported);
195
+ firstSegment = false;
196
+ continue;
197
+ }
198
+ if (name === 'BR') {
199
+ out += '<br>';
200
+ firstSegment = false;
201
+ continue;
202
+ }
203
+ unsupported.push(name);
204
+ firstSegment = false;
205
+ }
206
+ return out;
207
+ }
208
+
209
+ function serializeInline(rootEl) {
210
+ const unsupported = [];
211
+ const md = walkChildren(rootEl.childNodes, unsupported);
212
+ return { md, unsupported };
213
+ }
214
+
215
+ function canWysiwyg(rootEl) {
216
+ return serializeInline(rootEl).unsupported.length === 0;
217
+ }
218
+
219
+ return { serializeInline, canWysiwyg, escapeText };
220
+ });
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+ (function (root, factory) {
3
+ if (typeof module === 'object' && module.exports) module.exports = factory();
4
+ else root.md2docLineOps = factory();
5
+ })(typeof self !== 'undefined' ? self : this, function () {
6
+
7
+ function replaceLines(lines, startLine, endLine, newLines) {
8
+ const out = lines.slice(0, startLine - 1)
9
+ .concat(newLines, lines.slice(endLine));
10
+ return { lines: out, delta: newLines.length - (endLine - startLine + 1) };
11
+ }
12
+
13
+ function insertLines(lines, afterLine, newLines) {
14
+ const out = lines.slice(0, afterLine).concat(newLines, lines.slice(afterLine));
15
+ return { lines: out, delta: newLines.length };
16
+ }
17
+
18
+ function shiftBlocks(blocks, editedId, delta) {
19
+ return blocks.map((b) =>
20
+ b.id > editedId
21
+ ? Object.assign({}, b, { startLine: b.startLine + delta, endLine: b.endLine + delta })
22
+ : b
23
+ );
24
+ }
25
+
26
+ function UndoStack() {
27
+ this._done = [];
28
+ this._undone = [];
29
+ this._savedDepth = 0;
30
+ }
31
+ UndoStack.prototype.push = function (op) {
32
+ this._done.push(op);
33
+ this._undone.length = 0;
34
+ };
35
+ UndoStack.prototype.undo = function (lines) {
36
+ const op = this._done.pop();
37
+ if (!op) return null;
38
+ this._undone.push(op);
39
+ const span = { startLine: op.startLine, endLine: op.startLine + op.after.length - 1 };
40
+ return { lines: replaceLines(lines, span.startLine, span.endLine, op.before).lines, op };
41
+ };
42
+ UndoStack.prototype.redo = function (lines) {
43
+ const op = this._undone.pop();
44
+ if (!op) return null;
45
+ this._done.push(op);
46
+ return { lines: replaceLines(lines, op.startLine, op.endLine, op.after).lines, op };
47
+ };
48
+ // §10-gap fix (review): pops the top of the stack and reverses it
49
+ // directly on `lines`, exactly like undo() — but, UNLIKE undo(), never
50
+ // pushes the popped op onto `_undone`. There is nothing to "redo" back
51
+ // to: as far as the stack's history is concerned this op never
52
+ // happened. Used to collapse an insert-then-immediately-abandon
53
+ // (never edited) block insertion to a true no-op — the file AND the
54
+ // undo stack both end up byte-identical to their pre-insert state,
55
+ // not merely "one undo away from it". Returns null if the stack is
56
+ // empty — same contract as undo()/redo().
57
+ UndoStack.prototype.discardTop = function (lines) {
58
+ const op = this._done.pop();
59
+ if (!op) return null;
60
+ const span = { startLine: op.startLine, endLine: op.startLine + op.after.length - 1 };
61
+ return { lines: replaceLines(lines, span.startLine, span.endLine, op.before).lines, op };
62
+ };
63
+ Object.defineProperty(UndoStack.prototype, 'dirtyDepth', {
64
+ get() { return this._done.length - this._savedDepth; },
65
+ });
66
+ UndoStack.prototype.markSaved = function () {
67
+ this._savedDepth = this._done.length;
68
+ };
69
+
70
+ return { replaceLines, insertLines, shiftBlocks, UndoStack };
71
+ });
@@ -0,0 +1,258 @@
1
+ 'use strict';
2
+ (function (root, factory) {
3
+ if (typeof module === 'object' && module.exports) {
4
+ module.exports = factory(require('./inline-md.js'));
5
+ } else {
6
+ root.md2docListMd = factory(root.md2docInlineMd);
7
+ }
8
+ })(typeof self !== 'undefined' ? self : this, function (inlineMd) {
9
+
10
+ // list-md.js — list DOM -> minimal-form markdown (Phase-3 Task 3).
11
+ //
12
+ // ── Renderer finding (verified against lib/md2doc.js BEFORE writing
13
+ // serializeList below — required by the task brief) ─────────────────
14
+ // lib/md2doc.js does NOT override renderer.list — only renderer.listitem,
15
+ // and that override (~line 638) is text-collection-only (appendSectionText)
16
+ // then delegates to marked's own baseListitem. So the actual list HTML
17
+ // shape below is marked's stock output (verified directly against
18
+ // marked.parse() with the same { gfm: true, breaks: false } options
19
+ // md2doc.js sets at ~line 703):
20
+ //
21
+ // TIGHT (no blank line between source items):
22
+ // <ul>\n<li>item two<ul>\n<li>nested a</li>\n...</ul>\n</li>\n...</ul>
23
+ // A nested <ul>/<ol> is a trailing CHILD of the parent <li>, placed
24
+ // AFTER that <li>'s own text/inline nodes — never a sibling of <li>
25
+ // inside the outer list, and never wrapped in another <li>.
26
+ //
27
+ // LOOSE (blank line between source items):
28
+ // <li><p>item one</p>\n</li>
29
+ // <li><p>item two</p>\n<ul>\n<li>nested a</li>\n</ul>\n</li>
30
+ // marked wraps EACH item's own text in a <p>; a nested list (if any)
31
+ // still comes after that <p>, still inside the same <li>.
32
+ //
33
+ // TASK LIST: <li><input disabled type="checkbox"> todo</li> — the
34
+ // checkbox is a plain, unhandled element as far as inline-md.js's
35
+ // walkChildren() is concerned, so passing it through already reports
36
+ // 'INPUT' via that function's default unsupported-name branch — no
37
+ // special-case needed here.
38
+ //
39
+ // Both shapes also carry INSIGNIFICANT whitespace-only text nodes
40
+ // (bare "\n") between a closing </p> or </ul> tag and the enclosing
41
+ // closing </li> tag — an artifact of marked's own pretty-printed HTML
42
+ // once it round-trips through a real DOM parser (innerHTML/DOMParser).
43
+ // These are dropped before classifying an item's content (see
44
+ // isBlankText() below) — keeping them would (a) miscount a loose
45
+ // item's content node as 2 nodes instead of 1 (P + trailing "\n"),
46
+ // breaking the loose-item detection, and (b) leak a literal "\n" into
47
+ // the emitted line via inline-md's escapeText(), which does not treat
48
+ // "\n" specially, splitting one list-item line into two physical
49
+ // lines — a direct violation of the gate's one-line-per-item
50
+ // contract. Dropping them loses no real content: they carry no
51
+ // visible text. A stray NON-blank text node in either position (some
52
+ // other DOM-construction path, not marked's own output) is a
53
+ // different case — see "stray text" note below — and is flagged
54
+ // unsupported rather than silently dropped or silently kept.
55
+ //
56
+ // ── Loose-list decision (task brief requires this be documented) ──────
57
+ // Loose (<p>-wrapped) items are reported UNSUPPORTED ('P') rather than
58
+ // serialized faithfully as blank-line-separated markdown. Reasoning:
59
+ // 1. Faithful round-trip needs a blank line between EVERY item at
60
+ // the loose list's level, while nested tight sub-lists at deeper
61
+ // indents must NOT get blank lines — the emission rule ("no blank
62
+ // lines inside the emitted block UNLESS loose-list support
63
+ // requires them") already flags this as the risky path.
64
+ // 2. The gate-compat contract (see test/gate-compat.test.js's sibling
65
+ // table-md.js invariants, and this task's own list gate-compat
66
+ // cases) is line-based: every list line matches
67
+ // /^ *(-|\d+\.) / (indent is ancestor-marker-width accumulated, see
68
+ // "INDENT" note below — not a fixed multiple of 2), and no test
69
+ // elsewhere in this codebase tolerates a blank line inside a
70
+ // structural emission.
71
+ // 3. Degrade-never-lose is the established pattern in inline-md.js
72
+ // (SPAN with attributes, non-single-text-node citation anchors)
73
+ // and table-md.js (CODE span containing '|'): when a DOM shape
74
+ // cannot be represented losslessly under the emission contract,
75
+ // flag it unsupported and let the caller fall back to raw-edit
76
+ // instead of emitting best-effort-but-corrupting markdown.
77
+ // The item's own text is still serialized (best-effort, for debugging/
78
+ // visibility) even when flagged 'P' — same as table-md.js still returns
79
+ // `md` alongside a non-empty `unsupported` array. Callers must check
80
+ // `unsupported.length === 0` before trusting `md`, exactly as with
81
+ // inline-md.js / table-md.js.
82
+ //
83
+ // ── Emission form ───────────────────────────────────────────────────
84
+ // UL item: '- <inline>'; OL item: '<n>. <inline>' with n renumbered
85
+ // 1..n regardless of any source `start` attribute (a WYSIWYG editor
86
+ // must renumber after item insert/delete/reorder, so preserving a
87
+ // stale `start` would be actively wrong).
88
+ //
89
+ // INDENT (controller ruling, supersedes a fixed "2-space per depth"):
90
+ // a nested list's indent is the ACCUMULATED WIDTH of every ancestor
91
+ // item's own emitted marker, not a flat ' '.repeat(depth). A '- '
92
+ // marker is 2 columns; '1. ' is 3; '10. ' is 4 — CommonMark (and
93
+ // marked's own lexer) only keeps a sub-list attached to its parent
94
+ // list_item when the sub-list's indent is AT LEAST the parent marker's
95
+ // width. Verified directly against marked.lexer(): 2-space indent under
96
+ // an OL item (marker '1. ', 3 columns) de-nests on re-parse — the
97
+ // nested list comes back as a SEPARATE top-level list token, and two
98
+ // sibling OL items whose nested lists both mis-indent this way collapse
99
+ // into one merged list on re-lex. 3-space (or more) indent under that
100
+ // same '1. ' item keeps the nested list correctly attached as a child
101
+ // of item.tokens. See serializeListNode()'s `indentPrefix` parameter
102
+ // below and test/list-md.test.js's round-trip cases (ol>li>ul, ol>li>ol,
103
+ // 3-deep mixed, and the two-digit '10. '-width case).
104
+ //
105
+ // Nested lists are emitted as additional lines AFTER their parent
106
+ // item's own line. No trailing whitespace on any line (each line is
107
+ // explicitly trimmed of trailing space/tab); no leading whitespace
108
+ // either beyond the accumulated indent — a dropped unsupported leading
109
+ // element (e.g. a task-list checkbox <input>, which inline-md.js's
110
+ // walkChildren() already flags via its default unhandled-element
111
+ // branch without emitting anything for it) would otherwise leave a
112
+ // stray double space between the marker and the item's remaining text;
113
+ // itemMd's own leading whitespace is trimmed for exactly this reason.
114
+ //
115
+ // Constrained (same as inline-md.js / table-md.js, and for the same
116
+ // reason — the node test drives this with the hand-rolled element stub
117
+ // from test/inline-md.test.js / test/table-md.test.js) to childNodes /
118
+ // nodeType / nodeName / textContent / getAttribute — NO
119
+ // querySelector/querySelectorAll.
120
+
121
+ function allChildNodes(node) {
122
+ const out = [];
123
+ for (let i = 0; i < node.childNodes.length; i++) out.push(node.childNodes[i]);
124
+ return out;
125
+ }
126
+
127
+ // Matches ONLY the marked-pretty-print whitespace artifact (a text node
128
+ // that is pure whitespace AND contains a newline) — never a bare space
129
+ // (or run of spaces) with no newline, which is meaningful inline
130
+ // spacing a real contenteditable DOM can legitimately place directly
131
+ // between two inline nodes inside a tight <li> (no <p> wrapper). See
132
+ // header note above: the artifact marked actually emits is always
133
+ // exactly "\n", never a plain " ".
134
+ function isBlankText(node) {
135
+ return node.nodeType === 3 && /\n/.test(node.textContent) && /^\s*$/.test(node.textContent);
136
+ }
137
+
138
+ function isListNode(node) {
139
+ return node.nodeType === 1 && (node.nodeName === 'UL' || node.nodeName === 'OL');
140
+ }
141
+
142
+ // Serializes one UL/OL node (and everything nested under it) at the
143
+ // given accumulated indent prefix (a literal string of spaces — the
144
+ // sum of every ancestor item's own marker width, see header "INDENT"
145
+ // note). Returns an array of already-trimmed, already-indented
146
+ // physical lines. Mutates `unsupported` and `unsupportedByLi` in place
147
+ // (same aggregation pattern as table-md.js's serializeRow).
148
+ function serializeListNode(listEl, indentPrefix, unsupported, unsupportedByLi) {
149
+ const ordered = listEl.nodeName === 'OL';
150
+ const lines = [];
151
+ let n = 1;
152
+
153
+ allChildNodes(listEl).forEach((kid) => {
154
+ if (kid.nodeType === 3) {
155
+ // A stray text node directly under UL/OL (i.e. NOT inside an <li>)
156
+ // is either marked's own insignificant "\n" pretty-print artifact
157
+ // (dropped, see header) or genuine stray content from some other
158
+ // DOM-construction path — the latter can't be represented as a
159
+ // list line at all, so it is flagged rather than silently eaten.
160
+ if (!isBlankText(kid)) unsupported.push('TEXT');
161
+ return;
162
+ }
163
+ if (kid.nodeType !== 1) return; // any other exotic node type: ignore
164
+ if (kid.nodeName !== 'LI') {
165
+ unsupported.push(kid.nodeName);
166
+ return;
167
+ }
168
+
169
+ let checkAttr = null; // non-null once a .ed-li-check span is seen
170
+ const nestedLists = [];
171
+ const contentNodes = [];
172
+
173
+ // classifyLiChild: the per-child classify body, extracted so it can be
174
+ // shared between the direct <li> children loop and the .ed-li-text
175
+ // unwrap below — avoids duplicating isListNode/isBlankText branches.
176
+ const classifyLiChild = (c) => {
177
+ if (isListNode(c)) {
178
+ nestedLists.push(c);
179
+ } else if (isBlankText(c)) {
180
+ // dropped: insignificant whitespace-only artifact, see header
181
+ } else {
182
+ contentNodes.push(c);
183
+ }
184
+ };
185
+
186
+ allChildNodes(kid).forEach((c) => {
187
+ if (c.nodeType === 1 && c.nodeName === 'SPAN' && c.getAttribute('class') === 'ed-li-check') {
188
+ // Task 4 DOM shape: checkbox state lives on data-checked; consume
189
+ // the span here so it never reaches inline-md as unsupported content.
190
+ checkAttr = c.getAttribute('data-checked') === '1';
191
+ } else if (c.nodeType === 1 && c.nodeName === 'DIV' && c.getAttribute('class') === 'ed-li-text') {
192
+ // Task 4 DOM shape: inline content is wrapped in a .ed-li-text div.
193
+ // Unwrap by splicing its children into the SAME contentNodes list —
194
+ // NOT a separate variable — so the loose-item detection below
195
+ // (contentNodes.length === 1 && P) still fires correctly for a
196
+ // .ed-li-text that contains a single <p> (RULING F-M).
197
+ allChildNodes(c).forEach(classifyLiChild);
198
+ } else {
199
+ // Pre-Task-4 bare shape (tight/loose marked output, or plain li('text')
200
+ // fixtures) — unchanged behaviour, additive branch only.
201
+ classifyLiChild(c);
202
+ }
203
+ });
204
+
205
+ let inlineChildNodes = contentNodes;
206
+ if (contentNodes.length === 1 && contentNodes[0].nodeType === 1 && contentNodes[0].nodeName === 'P') {
207
+ unsupported.push('P'); // loose list item — see header decision
208
+ inlineChildNodes = allChildNodes(contentNodes[0]);
209
+ }
210
+
211
+ const { md: rawItemMd, unsupported: innerUnsupported } =
212
+ inlineMd.serializeInline({ childNodes: inlineChildNodes });
213
+
214
+ // Per-li attribution: record inline-serializer unsupported names keyed by
215
+ // this li's data-block-id (may be null for provisional lis that have not
216
+ // been assigned a blockId yet).
217
+ if (innerUnsupported.length > 0) {
218
+ unsupportedByLi.push({
219
+ blockId: kid.getAttribute('data-block-id'),
220
+ names: innerUnsupported.slice(),
221
+ });
222
+ }
223
+ innerUnsupported.forEach((u) => unsupported.push(u));
224
+
225
+ // strip leading whitespace left behind by a dropped leading element
226
+ // (e.g. a checkbox <input>) — see header note.
227
+ const itemMd = rawItemMd.replace(/^[ \t]+/, '');
228
+
229
+ // Two-part marker: bullet (ordered ordinal or plain '- ') and an
230
+ // optional checkbox prefix. The two parts are independent so that
231
+ // an ordered task list ('1. [ ] todo') gets BOTH — the brief's
232
+ // single-expression marker silently deletes checkbox on ordered
233
+ // task lists (RULING F-N).
234
+ const bullet = ordered ? (n + '. ') : '- ';
235
+ const marker = checkAttr === null ? bullet : bullet + (checkAttr ? '[x] ' : '[ ] ');
236
+ n++;
237
+ lines.push((indentPrefix + marker + itemMd).replace(/[ \t]+$/, ''));
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(' ');
242
+ nestedLists.forEach((nl) => {
243
+ serializeListNode(nl, childIndentPrefix, unsupported, unsupportedByLi).forEach((l) => lines.push(l));
244
+ });
245
+ });
246
+
247
+ return lines;
248
+ }
249
+
250
+ function serializeList(listEl) {
251
+ const unsupported = [];
252
+ const unsupportedByLi = [];
253
+ const lines = serializeListNode(listEl, '', unsupported, unsupportedByLi);
254
+ return { md: lines.join('\n'), unsupported, unsupportedByLi };
255
+ }
256
+
257
+ return { serializeList };
258
+ });