@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/blockmap.js
CHANGED
|
@@ -18,27 +18,145 @@ function trimmedLineCount(raw) {
|
|
|
18
18
|
// 2. Recurse into that item's nested child lists, left-to-right.
|
|
19
19
|
// 3. Move on to the next sibling item.
|
|
20
20
|
// `nextId` is a shared box { v: <int> } so ids stay 0..n-1 in document order.
|
|
21
|
+
// 0-based LINE offsets, within an item's own raw, at which each of its CHILD
|
|
22
|
+
// LIST tokens begins. Exactly one offset per child list token, always — the
|
|
23
|
+
// arrays are built in the same pass so they cannot desynchronise.
|
|
24
|
+
//
|
|
25
|
+
// Anchored on `item.text`, which is marked's DEDENTED copy of the item's own
|
|
26
|
+
// content. It is line-for-line with `item.raw` (dedenting removes columns, never
|
|
27
|
+
// lines), and every nested token's `raw` is a genuine substring of it, so a
|
|
28
|
+
// child's line offset can be COMPUTED from a character offset instead of being
|
|
29
|
+
// guessed. Three earlier mechanisms are ruled out, each by a defect it shipped:
|
|
30
|
+
//
|
|
31
|
+
// * `ownSpan = totalSpan - childSpan` assumed an item's own lines all precede
|
|
32
|
+
// its children. False for content that resumes after a sublist, and it
|
|
33
|
+
// handed the child a startLine naming the WRONG line.
|
|
34
|
+
// * Matching the child's raw against the item's trimmed LINE TEXT fails twice
|
|
35
|
+
// over: marked dedents a nested raw and, for SAME-LINE nesting, strips the
|
|
36
|
+
// parent marker too (item '- - a' has a child whose raw '- a' appears
|
|
37
|
+
// nowhere in it), so the child was skipped, blocks[] fell one short of the
|
|
38
|
+
// render walk that consumes it in lockstep, and the document failed to open
|
|
39
|
+
// at all; and identical text elsewhere in the item — '- b' inside a fenced
|
|
40
|
+
// or indented code block — won the search, so typing into the real child
|
|
41
|
+
// landed in the fence or destroyed the code block.
|
|
42
|
+
// * Summing each token's raw NEWLINE COUNT looks like the top-level loop's
|
|
43
|
+
// trick but is not: at top level the raws are faithful source slices, while
|
|
44
|
+
// inside an item a `text` token's raw is SYNTHESISED — a lazy continuation
|
|
45
|
+
// comes back as "x\n\ncont\n" (three newlines) for two source lines, which
|
|
46
|
+
// overshoots every following child by one.
|
|
47
|
+
//
|
|
48
|
+
// So `text` tokens are skipped rather than trusted, and every other token is
|
|
49
|
+
// consumed monotonically to advance the cursor. That ordering is what keeps a
|
|
50
|
+
// code block's lookalike content behind us: the `code` token itself claims those
|
|
51
|
+
// bytes before the following `list` token is searched for. A child list always
|
|
52
|
+
// begins a line, so its match is additionally required to land at a line start —
|
|
53
|
+
// which rules out a marker sitting inside an inline code span on a text line.
|
|
54
|
+
function lineOffsetAt(content, charOffset) {
|
|
55
|
+
return (content.slice(0, charOffset).match(/\n/g) || []).length;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function indexOfAtLineStart(content, needle, from) {
|
|
59
|
+
let at = content.indexOf(needle, from);
|
|
60
|
+
while (at > 0 && content.charAt(at - 1) !== '\n') {
|
|
61
|
+
at = content.indexOf(needle, at + 1);
|
|
62
|
+
}
|
|
63
|
+
return at;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function childListStartOffsets(item) {
|
|
67
|
+
const content = typeof item.text === 'string' ? item.text : '';
|
|
68
|
+
const offsets = [];
|
|
69
|
+
let pos = 0;
|
|
70
|
+
for (const tk of item.tokens || []) {
|
|
71
|
+
if (tk.type === 'list') {
|
|
72
|
+
let at = indexOfAtLineStart(content, tk.raw, pos);
|
|
73
|
+
if (at < 0) at = content.indexOf(tk.raw, pos);
|
|
74
|
+
// Unlocatable (a shape this walk does not model): fall back to the line
|
|
75
|
+
// the cursor is already on. Never skip — a missing block desynchronises
|
|
76
|
+
// the render walk and takes the whole document down.
|
|
77
|
+
offsets.push(at < 0 ? lineOffsetAt(content, pos) : lineOffsetAt(content, at));
|
|
78
|
+
if (at >= 0) pos = at + tk.raw.length;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
// `text` raws are synthesised; everything else is a faithful slice and is
|
|
82
|
+
// consumed so later searches start beyond it.
|
|
83
|
+
if (tk.type === 'text') continue;
|
|
84
|
+
const at = content.indexOf(tk.raw, pos);
|
|
85
|
+
if (at >= 0) pos = at + tk.raw.length;
|
|
86
|
+
}
|
|
87
|
+
return offsets;
|
|
88
|
+
}
|
|
89
|
+
|
|
21
90
|
function pushListItemBlocks(listToken, cursor, indent, blocks, nextId) {
|
|
22
91
|
for (const item of listToken.items) {
|
|
23
92
|
const childListTokens = item.tokens.filter((t) => t.type === 'list');
|
|
24
93
|
const totalSpan = trimmedLineCount(item.raw);
|
|
25
|
-
const
|
|
26
|
-
|
|
94
|
+
const childLineOffsets = childListStartOffsets(item);
|
|
95
|
+
|
|
96
|
+
// The item's OWN block covers its contiguous leading lines only — up to its
|
|
97
|
+
// first child.
|
|
98
|
+
//
|
|
99
|
+
// `ownSpan = totalSpan - childSpan` (the original) assumed an item's own
|
|
100
|
+
// lines all come BEFORE its children. That is true of most markdown and
|
|
101
|
+
// false of this, which CommonMark allows:
|
|
102
|
+
//
|
|
103
|
+
// - a
|
|
104
|
+
// - b
|
|
105
|
+
//
|
|
106
|
+
// more text
|
|
107
|
+
// - c
|
|
108
|
+
//
|
|
109
|
+
// There, `a` owns line 1 AND line 4 with its child in between, so the old
|
|
110
|
+
// arithmetic put the child's cursor at line 4 — `b.startLine` named
|
|
111
|
+
// " more text" instead of " - b". In the flat block model startLine is
|
|
112
|
+
// the ADDRESS every gutter action and every focusBlockAtLine() lookup uses,
|
|
113
|
+
// so a wrong one silently targets somebody else's line.
|
|
114
|
+
//
|
|
115
|
+
// Own content that resumes AFTER a child stays deliberately OUT of the
|
|
116
|
+
// range rather than mis-covered: it is genuinely discontiguous and a
|
|
117
|
+
// {startLine, endLine} pair cannot represent it. Such an item is also
|
|
118
|
+
// unsupported to the serializer (it renders as two <p>s, or as one text
|
|
119
|
+
// node holding a newline — see list-md.js's 'P' / 'MULTILINE' reporting),
|
|
120
|
+
// so it is never armed and no structural key acts on it as a target.
|
|
121
|
+
//
|
|
122
|
+
// SAME-LINE nesting ('- - a') gives ownSpan 0, so the outer item's endLine
|
|
123
|
+
// sits one BEFORE its startLine. That is not a quirk to be smoothed over:
|
|
124
|
+
// the item's own content really is empty, because the child begins on the
|
|
125
|
+
// very first line, and an EMPTY range is the only honest way to say so.
|
|
126
|
+
// Preserved as-is — it is the shape this file has always produced for that
|
|
127
|
+
// markdown.
|
|
128
|
+
//
|
|
129
|
+
// It is the one place a block's range is not a well-formed interval, and
|
|
130
|
+
// that is dangerous rather than merely odd: lineops.js's replaceLines()
|
|
131
|
+
// computes `slice(0, start-1).concat(new, slice(end))`, whose two slices
|
|
132
|
+
// OVERLAP when end < start, so a commit against such a range INSERTS a line
|
|
133
|
+
// and leaves the original standing. The guarantee that no commit ever
|
|
134
|
+
// reaches one is enforced in lib/editor/client.js, at the single arming
|
|
135
|
+
// boundary — canWysiwygForLi() refuses a block whose range is empty, so it
|
|
136
|
+
// is never editable and no commit path can start on it. If you add a
|
|
137
|
+
// consumer that walks block ranges, either honour that emptiness or check
|
|
138
|
+
// for it; do not assume `startLine <= endLine`.
|
|
139
|
+
const ownSpan = childLineOffsets.length ? childLineOffsets[0] : totalSpan;
|
|
27
140
|
const block = {
|
|
28
141
|
id: nextId.v++,
|
|
29
142
|
type: 'li',
|
|
30
143
|
startLine: cursor,
|
|
31
144
|
endLine: cursor + ownSpan - 1,
|
|
32
|
-
|
|
145
|
+
// Two independent axes (RULING F-N): GFM allows `1. [ ] a`, so
|
|
146
|
+
// ordered-ness and task-ness cannot share one field. `listType` is the
|
|
147
|
+
// LIST's type; `task` is the ITEM's.
|
|
148
|
+
listType: listToken.ordered ? 'ol' : 'ul',
|
|
149
|
+
task: !!item.task,
|
|
33
150
|
indent,
|
|
34
151
|
};
|
|
35
152
|
if (item.task) block.checked = !!item.checked;
|
|
36
153
|
blocks.push(block);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
154
|
+
// Every child is emitted, unconditionally: blocks[] is consumed in lockstep
|
|
155
|
+
// by lib/md2doc.js's render walk, so a skipped child desynchronises the two
|
|
156
|
+
// and the render runs off the end of the array.
|
|
157
|
+
childListTokens.forEach((ct, k) => {
|
|
158
|
+
pushListItemBlocks(ct, cursor + childLineOffsets[k], indent + 1, blocks, nextId);
|
|
159
|
+
});
|
|
42
160
|
// advance cursor by full item raw newlines; fall back to totalSpan if raw
|
|
43
161
|
// has no trailing newline (EOF item).
|
|
44
162
|
cursor += (item.raw.match(/\n/g) || []).length || totalSpan;
|