@helping-ai-workflow/md2doc 2.8.1 → 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.
- package/README.md +226 -0
- package/bin/md2doc.js +46 -33
- package/lib/editor/blockmap.js +75 -0
- package/lib/editor/cli.js +23 -0
- package/lib/editor/client.js +4739 -0
- package/lib/editor/history.js +94 -0
- package/lib/editor/inline-md.js +220 -0
- package/lib/editor/lineops.js +71 -0
- package/lib/editor/list-md.js +258 -0
- package/lib/editor/open.js +59 -0
- package/lib/editor/server.js +190 -0
- package/lib/editor/table-md.js +169 -0
- package/lib/md2doc.js +1110 -591
- package/package.json +2 -2
|
@@ -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 `<` (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 += '<'; 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 `<` -> `<` 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
|
+
});
|