@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.
@@ -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 childSpan = childListTokens.reduce((s, t) => s + trimmedLineCount(t.raw), 0);
26
- const ownSpan = totalSpan - childSpan;
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
- listType: item.task ? 'task' : (listToken.ordered ? 'ol' : 'ul'),
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
- let childCursor = cursor + ownSpan;
38
- for (const ct of childListTokens) {
39
- pushListItemBlocks(ct, childCursor, indent + 1, blocks, nextId);
40
- childCursor += (ct.raw.match(/\n/g) || []).length;
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;