@brett_lamy/docstream 1.2.3 → 1.3.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.
@@ -9,15 +9,33 @@ import type {
9
9
  DemoVariantOption,
10
10
  DemoViewport,
11
11
  DocumentNode,
12
+ DefinitionNode,
13
+ FigureNode,
12
14
  HintStyle,
15
+ ListNode,
16
+ ParagraphNode,
13
17
  Inline,
14
18
  ListItemNode,
19
+ BlockSpacing,
20
+ BlockquoteNode,
21
+ ContainerSpacing,
22
+ EmbedNode,
23
+ ExpandableNode,
24
+ HintNode,
25
+ SourceRefNode,
26
+ StepperNode,
27
+ ColumnsNode,
28
+ TabsNode,
29
+ UpdatesNode,
15
30
  StepNode,
31
+ TableNode,
16
32
  TabNode,
33
+ TextNode,
17
34
  UpdateNode,
18
35
  } from "./ast"
19
36
  import { TAG_ATTRS, parseAttrs } from "./attrs"
20
- import { footnoteDefinitions, parseInline, plainText, refDefinitions } from "./inline"
37
+ import { containerTags, defaultDelimiterRow, definitionLine, delimiterAlign, fenceInfo, figureMarkdown, serializeBlocks, type TaggedNode } from "./serialize"
38
+ import { footnoteDefinitions, normalizeLabel, parseInline, plainText, refDefinitions } from "./inline"
21
39
 
22
40
  // Minimal HTML-inline → markdown-inline bridge for HTML table cells.
23
41
  function htmlToInlineMd(html: string): string {
@@ -33,6 +51,25 @@ function htmlToInlineMd(html: string): string {
33
51
  .trim()
34
52
  }
35
53
 
54
+ /** Stands in for a pipe written bare inside a table cell's code span, until the cell is parsed. */
55
+ const BARE_PIPE = "\uE000"
56
+
57
+ /** A table cell's inlines: pipes a code span wrote bare are remembered on the run (`barePipes`). */
58
+ function parseCell(cell: string): Inline[] {
59
+ const nodes = parseInline(cell)
60
+ if (!cell.includes(BARE_PIPE)) return nodes
61
+ for (const n of nodes) {
62
+ if (n.type !== "text" || !n.text.includes(BARE_PIPE)) continue
63
+ if (n.code) {
64
+ const bare: number[] = []
65
+ for (let k = 0; k < n.text.length; k++) if (n.text[k] === BARE_PIPE) bare.push(k)
66
+ ;(n as TextNode).barePipes = bare
67
+ }
68
+ n.text = n.text.split(BARE_PIPE).join("|")
69
+ }
70
+ return nodes
71
+ }
72
+
36
73
  // Split a GFM pipe-table row into cell strings. Splits only on unescaped pipes
37
74
  // that are outside inline-code spans, and unescapes "\|" back to a literal "|".
38
75
  function splitTableRow(row: string): string[] {
@@ -59,7 +96,8 @@ function splitTableRow(row: string): string[] {
59
96
  end = trimmed.indexOf(fence, e)
60
97
  }
61
98
  const stop = end === -1 ? k + n : end + n
62
- cur += trimmed.slice(k, stop).replace(/\\\|/g, "|")
99
+ // `\|` is a pipe; a bare one is marked so the cell can remember it was written bare.
100
+ cur += trimmed.slice(k, stop).replace(/\\\||\|/g, (m) => (m === "|" ? BARE_PIPE : "|"))
63
101
  k = stop - 1
64
102
  continue
65
103
  }
@@ -149,7 +187,7 @@ export function fenceTracker() {
149
187
  // Collects the lines between an opening {% name %} and its matching
150
188
  // {% endname %}, honoring nesting of the same tag. Tags inside fenced code
151
189
  // (a demo file, a Markdown sample) are content, not structure.
152
- function collectUntil(lines: string[], start: number, name: string): { body: string[]; next: number } {
190
+ function collectUntil(lines: string[], start: number, name: string): { body: string[]; next: number; closing?: string } {
153
191
  const body: string[] = []
154
192
  let depth = 1
155
193
  let i = start
@@ -163,21 +201,29 @@ function collectUntil(lines: string[], start: number, name: string): { body: str
163
201
  if (tag?.name === name) depth++
164
202
  if (tag?.name === `end${name}`) {
165
203
  depth--
166
- if (depth === 0) return { body, next: i + 1 }
204
+ if (depth === 0) return { body, next: i + 1, closing: lines[i] }
167
205
  }
168
206
  body.push(lines[i])
169
207
  }
170
208
  return { body, next: i }
171
209
  }
172
210
 
173
- function collectHtmlUntil(lines: string[], start: number, closeTag: string): { body: string[]; next: number } {
211
+ /** Records a container's opening / closing tag lines where they aren't what serialization writes. */
212
+ function tagged<T extends TaggedNode>(node: T, opening: string, closing: string | undefined): T {
213
+ const [open, close] = containerTags(node)
214
+ if (opening !== open) node.opening = opening
215
+ if ((closing ?? "") !== close) node.closing = closing ?? ""
216
+ return node
217
+ }
218
+
219
+ function collectHtmlUntil(lines: string[], start: number, closeTag: string): { body: string[]; next: number; closing?: string } {
174
220
  const body: string[] = []
175
221
  let i = start
176
222
  for (; i < lines.length; i++) {
177
223
  if (lines[i].includes(closeTag)) {
178
224
  const before = lines[i].slice(0, lines[i].indexOf(closeTag))
179
225
  if (before.trim()) body.push(before)
180
- return { body, next: i + 1 }
226
+ return { body, next: i + 1, ...(before.trim() ? {} : { closing: lines[i] }) }
181
227
  }
182
228
  body.push(lines[i])
183
229
  }
@@ -186,35 +232,71 @@ function collectHtmlUntil(lines: string[], start: number, closeTag: string): { b
186
232
 
187
233
  const HINT_STYLES: HintStyle[] = ["info", "success", "warning", "danger"]
188
234
 
235
+ /** `![alt](src "title")` alone on its line. */
236
+ const SOLO_IMG_RE = /^!\[([^\]]*)\]\(([^)\s]+)(?:[ \t]+"([^"]*)")?\)$/
237
+
238
+ /** Keeps the figure's source lines, unless they are exactly what serialization writes anyway. */
239
+ function withRaw(figure: FigureNode, raw: string): FigureNode {
240
+ if (raw !== figureMarkdown(figure)) figure.raw = raw
241
+ return figure
242
+ }
243
+
244
+
245
+
246
+ /** `[^id]: url "Label"` — a footnote citation definition. */
247
+ const FOOTNOTE_DEF_RE = /^\[\^([^\]\s]+)\]:\s*(\S+)(?:\s+"([^"]*)")?\s*$/
248
+ /** `[label]: url "title"` — a reference-style link definition (up to three spaces of indent). */
249
+ const REF_DEF_RE = /^ {0,3}\[((?:\\.|[^\]\\])+)\]:[ \t]*(\S+)(?:[ \t]+"((?:\\.|[^"\\])*)")?[ \t]*$/
250
+
251
+ /** The canonical line for a footnote definition. */
252
+ export function footnoteLine(id: string, url: string, label?: string): string {
253
+ return `[^${id}]: ${url}${label ? ` "${label}"` : ""}`
254
+ }
255
+
189
256
  export function parseMarkdown(src: string): DocumentNode {
190
- const lines = src.split(/\r?\n/)
257
+ // Windows line endings throughout come back as such; mixed ones stay in the lines as written.
258
+ const crlf = src.includes("\r\n") && !/(^|[^\r])\n/.test(src)
259
+ const lines = src.split(crlf || !src.includes("\r\n") ? /\r?\n/ : "\n")
191
260
  refDefinitions.clear()
192
261
  footnoteDefinitions.clear()
193
- const content: string[] = []
194
262
  const fences = fenceTracker()
195
- for (const line of lines) {
263
+ // Canonical footnote definitions (outside fences), by line.
264
+ const canonicalFoot: boolean[] = []
265
+ lines.forEach((line, k) => {
196
266
  // Definition lines inside code fences are content, not definitions.
197
- if (!fences.step(line)) {
198
- // Footnote citation definitions first: [^id]: url "Optional Label"
199
- const foot = line.match(/^\[\^([^\]\s]+)\]:\s*(\S+)(?:\s+"([^"]*)")?\s*$/)
200
- if (foot) {
201
- footnoteDefinitions.set(foot[1], { url: foot[2], ...(foot[3] ? { label: foot[3] } : {}) })
202
- continue
203
- }
204
- const def = line.match(/^\[([^\]]+)\]:\s*(\S+)\s*$/)
205
- // Keys starting with ^ are reserved for footnotes and must never
206
- // shadow reference-style link definitions.
207
- if (def && !def[1].startsWith("^")) {
208
- refDefinitions.set(def[1].toLowerCase(), def[2])
209
- continue
210
- }
267
+ if (fences.step(line)) return
268
+ // Footnote citation definitions first: [^id]: url "Optional Label"
269
+ const foot = line.match(FOOTNOTE_DEF_RE)
270
+ if (foot) {
271
+ footnoteDefinitions.set(foot[1], { url: foot[2], ...(foot[3] ? { label: foot[3] } : {}) })
272
+ canonicalFoot[k] = line === footnoteLine(foot[1], foot[2], foot[3])
273
+ return
211
274
  }
212
- content.push(line)
213
- }
214
- const doc: DocumentNode = { type: "doc", children: parseBlocks(content) }
275
+ const def = line.match(REF_DEF_RE)
276
+ // Keys starting with ^ are reserved for footnotes and must never
277
+ // shadow reference-style link definitions. The first definition of a label wins.
278
+ if (def && !def[1].startsWith("^")) {
279
+ const key = normalizeLabel(def[1])
280
+ if (!refDefinitions.has(key)) refDefinitions.set(key, def[2])
281
+ }
282
+ })
283
+ // The footnote definitions ending the document are its doc-level `citations`, written back as a group;
284
+ // any others stay where they are, as definition blocks.
285
+ let last = lines.length - 1
286
+ while (last >= 0 && !lines[last].trim()) last--
287
+ let group = last + 1
288
+ while (group > 0 && canonicalFoot[group - 1]) group--
289
+ const trailing = group <= last
290
+ const { blocks, end } = parseBody(trailing ? lines.slice(0, group) : lines)
291
+ const doc: DocumentNode = { type: "doc", children: blocks }
215
292
  if (footnoteDefinitions.size) {
216
293
  doc.citations = [...footnoteDefinitions.entries()].map(([id, d]) => ({ id, ...d }))
217
294
  }
295
+ if (trailing && end !== (blocks.length ? 1 : 0)) doc.citationsGap = end
296
+ const ending = crlf ? src.match(/\s*$/)![0].replace(/\r\n/g, "\n") : src.match(/\s*$/)![0]
297
+ if (crlf) doc.lineEnding = "\r\n"
298
+ // A missing final newline is added; anything else after the last line is kept.
299
+ if (ending !== "\n" && ending !== "") doc.end = ending
218
300
  return doc
219
301
  }
220
302
 
@@ -369,14 +451,64 @@ export function trimPartialInlineToken(md: string): string {
369
451
  }
370
452
 
371
453
  export function parseBlocks(lines: string[]): Block[] {
454
+ return parseBody(lines).blocks
455
+ }
456
+
457
+ /** Records `gap` on a block (or item) when it differs from the default. */
458
+ function spaced<T extends BlockSpacing>(node: T, gap: number, fallback: number): T {
459
+ if (gap !== fallback) node.gap = gap
460
+ return node
461
+ }
462
+
463
+ /** Records `gapEnd` on a container when it differs from the default. */
464
+ function ended<T>(node: T & ContainerSpacing, end: number, fallback = 0): T {
465
+ if (end !== fallback) node.gapEnd = end
466
+ return node
467
+ }
468
+
469
+ /**
470
+ * Blocks of `lines`, with the blank lines before each (`gap`, where not the default: `firstGap` before the
471
+ * first, 1 between siblings) and the number of blank lines after the last (`end`).
472
+ */
473
+ function parseBody(lines: string[], firstGap = 0): { blocks: Block[]; end: number } {
372
474
  const blocks: Block[] = []
373
475
  let i = 0
476
+ // Blank lines since the last block, and before the block starting on the current line.
477
+ let blank = 0
478
+ let gapHere = 0
479
+ let paragraphGap = 0
480
+ // The blank lines themselves, for whitespace-only ones.
481
+ let blankLines: string[] = []
482
+ let gapLines: string[] = []
483
+ let paragraphLines: string[] = []
484
+ const add = (b: Block, gap = gapHere, written = gapLines) => {
485
+ if (written.some((l) => l)) b.blanks = [...written]
486
+ blocks.push(spaced(b, gap, blocks.length ? 1 : firstGap))
487
+ }
488
+ const container = <T extends Block & { gapEnd?: number }>(node: T, body: string[], build: (children: Block[]) => void, first = 0, last = 0) => {
489
+ const parsed = parseBody(body, first)
490
+ build(parsed.blocks)
491
+ add(ended(node, parsed.end, last))
492
+ }
493
+
494
+ /** Keeps the last block's source lines as `raw` when they aren't what serialization writes. */
495
+ const keepRaw = (from: number, to: number) => {
496
+ const b = blocks[blocks.length - 1] as Block & { raw?: string }
497
+ const written = lines.slice(from, Math.min(to, lines.length)).join("\n")
498
+ if (written !== serializeBlocks([{ ...b, gap: 0, blanks: undefined } as Block])) b.raw = written
499
+ }
374
500
 
375
501
  const paragraph: string[] = []
502
+ let paragraphIndent = ""
376
503
  const flushParagraph = () => {
377
504
  if (paragraph.length) {
378
- const text = paragraph.join(" ").trim()
379
- if (text) blocks.push({ type: "paragraph", children: parseInline(text) })
505
+ // Soft line breaks stay newlines and trailing spaces stay, so wrapped paragraphs come back as written.
506
+ const text = paragraph.join("\n")
507
+ if (text.trim()) {
508
+ const node: ParagraphNode = { type: "paragraph", children: parseInline(text) }
509
+ if (paragraphIndent) node.indent = paragraphIndent
510
+ add(node, paragraphGap, paragraphLines)
511
+ }
380
512
  paragraph.length = 0
381
513
  }
382
514
  }
@@ -387,14 +519,21 @@ export function parseBlocks(lines: string[]): Block[] {
387
519
 
388
520
  if (!trimmed) {
389
521
  flushParagraph()
522
+ blank++
523
+ blankLines.push(line)
390
524
  i++
391
525
  continue
392
526
  }
527
+ gapHere = blank
528
+ gapLines = blankLines
529
+ blank = 0
530
+ blankLines = []
393
531
 
394
532
  const oneLine = line.match(COMMAND_ONE_LINE_RE)
395
533
  if (oneLine) {
396
534
  flushParagraph()
397
- blocks.push(commandNode(parseAttrs(oneLine[1]), oneLine[2]))
535
+ add(commandNode(parseAttrs(oneLine[1]), oneLine[2]))
536
+ keepRaw(i, i + 1)
398
537
  i++
399
538
  continue
400
539
  }
@@ -404,46 +543,59 @@ export function parseBlocks(lines: string[]): Block[] {
404
543
  flushParagraph()
405
544
 
406
545
  if (tag.name === "hint") {
407
- const { body, next } = collectUntil(lines, i + 1, "hint")
546
+ const { body, next, closing } = collectUntil(lines, i + 1, "hint")
408
547
  const style = (HINT_STYLES as string[]).includes(tag.attrs.style)
409
548
  ? (tag.attrs.style as HintStyle)
410
549
  : "info"
411
- blocks.push({ type: "hint", style, children: parseBlocks(body) })
550
+ const hint: HintNode = { type: "hint", style, children: [] }
551
+ container(tagged(hint, line, closing), body, (children) => (hint.children = children))
412
552
  i = next
413
553
  continue
414
554
  }
415
555
 
416
556
  if (tag.name === "tabs") {
417
- const { body, next } = collectUntil(lines, i + 1, "tabs")
557
+ const { body, next, closing } = collectUntil(lines, i + 1, "tabs")
418
558
  const level = Number(tag.attrs.level)
419
- blocks.push({
559
+ const tabs = parseItems(body, "tab", (t, lines): TabNode => {
560
+ const parsed = parseBody(lines)
561
+ return ended({ type: "tab", title: t.attrs.title ?? "Tab", children: parsed.blocks }, parsed.end)
562
+ })
563
+ const tabsNode: TabsNode = {
420
564
  type: "tabs",
421
- tabs: parseTabs(body),
565
+ tabs: tabs.items,
422
566
  ...(tag.attrs.sync ? { sync: tag.attrs.sync } : {}),
423
567
  ...(tag.attrs.title ? { title: tag.attrs.title } : {}),
424
568
  ...(tag.attrs.title && (level === 3 || level === 4) ? { level: level as 3 | 4 } : {}),
425
- })
569
+ }
570
+ add(tagged(ended(tabsNode, tabs.end), line, closing))
426
571
  i = next
427
572
  continue
428
573
  }
429
574
 
430
575
  if (tag.name === "command") {
431
- const { body, next } = collectUntil(lines, i + 1, "command")
432
- blocks.push(commandNode(tag.attrs, body.join("\n")))
576
+ const { body, next, closing } = collectUntil(lines, i + 1, "command")
577
+ add(commandNode(tag.attrs, body.join("\n")))
578
+ // (A block still streaming in — no end tag yet — isn't kept as written.)
579
+ if (closing !== undefined) keepRaw(i, next)
433
580
  i = next
434
581
  continue
435
582
  }
436
583
 
437
584
  if (tag.name === "stepper") {
438
- const { body, next } = collectUntil(lines, i + 1, "stepper")
439
- blocks.push({ type: "stepper", steps: parseSteps(body) })
585
+ const { body, next, closing } = collectUntil(lines, i + 1, "stepper")
586
+ const steps = parseSteps(body)
587
+ add(tagged(ended({ type: "stepper", steps: steps.items } as StepperNode, steps.end), line, closing))
440
588
  i = next
441
589
  continue
442
590
  }
443
591
 
444
592
  if (tag.name === "columns") {
445
- const { body, next } = collectUntil(lines, i + 1, "columns")
446
- blocks.push({ type: "columns", columns: parseColumns(body) })
593
+ const { body, next, closing } = collectUntil(lines, i + 1, "columns")
594
+ const columns = parseItems(body, "column", (_, lines): ColumnNode => {
595
+ const parsed = parseBody(lines)
596
+ return ended({ type: "column", children: parsed.blocks }, parsed.end)
597
+ })
598
+ add(tagged(ended({ type: "columns", columns: columns.items } as ColumnsNode, columns.end), line, closing))
447
599
  i = next
448
600
  continue
449
601
  }
@@ -457,14 +609,16 @@ export function parseBlocks(lines: string[]): Block[] {
457
609
  Object.assign(code, lineNumbersFrom(tag.attrs, code.title !== null))
458
610
  code.live = tag.attrs.live === "true"
459
611
  code.entry = tag.attrs.entry ?? null
460
- blocks.push(code)
612
+ delete code.gap
613
+ code.raw = lines.slice(i, next).join("\n")
614
+ add(code)
461
615
  }
462
616
  i = next
463
617
  continue
464
618
  }
465
619
 
466
620
  if (tag.name === "embed") {
467
- blocks.push({
621
+ add({
468
622
  type: "embed",
469
623
  url: tag.attrs.url ?? "",
470
624
  ...(tag.attrs.title ? { title: tag.attrs.title } : {}),
@@ -484,21 +638,25 @@ export function parseBlocks(lines: string[]): Block[] {
484
638
  })
485
639
  i++
486
640
  // tolerate optional {% endembed %}
487
- if (i < lines.length && templateTag(lines[i])?.name === "endembed") i++
641
+ if (i < lines.length && templateTag(lines[i])?.name === "endembed") {
642
+ ;(blocks[blocks.length - 1] as EmbedNode).raw = lines.slice(i - 1, i + 1).join("\n")
643
+ i++
644
+ }
488
645
  continue
489
646
  }
490
647
 
491
648
  if (tag.name === "content-ref") {
492
649
  const { body, next } = collectUntil(lines, i + 1, "content-ref")
493
650
  const inner: Inline[] = parseInline(body.join(" ").trim())
494
- blocks.push({ type: "content-ref", url: tag.attrs.url ?? "", children: inner })
651
+ add({ type: "content-ref", url: tag.attrs.url ?? "", children: inner })
652
+ keepRaw(i, next)
495
653
  i = next
496
654
  continue
497
655
  }
498
656
 
499
657
  if (tag.name === "source-ref" || tag.name === "component" || tag.name === "story") {
500
658
  const kind = tag.name === "story" || tag.attrs.kind === "story" ? "story" : "component"
501
- blocks.push({
659
+ add({
502
660
  type: "source-ref",
503
661
  mount: tag.attrs.mount ?? "source",
504
662
  path: tag.attrs.path ?? tag.attrs.src ?? "",
@@ -506,13 +664,18 @@ export function parseBlocks(lines: string[]): Block[] {
506
664
  kind,
507
665
  ...(tag.attrs.title ? { title: tag.attrs.title } : {}),
508
666
  })
667
+ const from = i
509
668
  i++
510
669
  if (i < lines.length && templateTag(lines[i])?.name === `end${tag.name}`) i++
670
+ const source = blocks[blocks.length - 1] as SourceRefNode
671
+ const written = lines.slice(from, i).join("\n")
672
+ if (written !== serializeBlocks([{ ...source, gap: 0 }])) source.raw = written
511
673
  continue
512
674
  }
513
675
 
514
676
  if (tag.name === "demo") {
515
677
  const node = parseDemoTag(tag.attrs)
678
+ const demoStart = i
516
679
  i++
517
680
  // `{% demo … /%}` never has a body; otherwise look for inline files / {% enddemo %}.
518
681
  const body = demoBody(lines, i, /\/\s*%\}\s*$/.test(line))
@@ -521,17 +684,19 @@ export function parseBlocks(lines: string[]): Block[] {
521
684
  if (body.open) node.open = true
522
685
  i = body.next
523
686
  }
524
- blocks.push(node)
687
+ add(node)
688
+ keepRaw(demoStart, i)
525
689
  continue
526
690
  }
527
691
 
528
692
  if (tag.name === "updates") {
529
- const { body, next } = collectUntil(lines, i + 1, "updates")
530
- blocks.push({
531
- type: "updates",
532
- format: tag.attrs.format ?? null,
533
- updates: parseUpdates(body),
693
+ const { body, next, closing } = collectUntil(lines, i + 1, "updates")
694
+ const updates = parseItems(body, "update", (t, lines): UpdateNode => {
695
+ const parsed = parseBody(lines)
696
+ return ended({ type: "update", date: t.attrs.date ?? "", children: parsed.blocks }, parsed.end)
534
697
  })
698
+ const updatesNode: UpdatesNode = { type: "updates", format: tag.attrs.format ?? null, updates: updates.items }
699
+ add(tagged(ended(updatesNode, updates.end), line, closing))
535
700
  i = next
536
701
  continue
537
702
  }
@@ -539,7 +704,7 @@ export function parseBlocks(lines: string[]): Block[] {
539
704
  if (tag.name === "openapi-operation" || tag.name === "openapi") {
540
705
  const { body, next } = collectUntil(lines, i + 1, tag.name)
541
706
  const link = body.join(" ").match(/\[([^\]]*)\]\(([^)\s]+)\)/)
542
- blocks.push({
707
+ add({
543
708
  type: "openapi-operation",
544
709
  spec: tag.attrs.spec ?? "",
545
710
  path: tag.attrs.path ?? "",
@@ -547,18 +712,20 @@ export function parseBlocks(lines: string[]): Block[] {
547
712
  specUrl: link?.[2] ?? "",
548
713
  label: link?.[1] ?? "",
549
714
  })
715
+ keepRaw(i, next)
550
716
  i = next
551
717
  continue
552
718
  }
553
719
 
554
720
  if (tag.name === "file") {
555
721
  // Render file blocks as content-refs for now — same shape, different chrome.
556
- blocks.push({ type: "content-ref", url: tag.attrs.src ?? "", children: parseInline(tag.attrs.caption ?? tag.attrs.src ?? "") })
722
+ add({ type: "content-ref", url: tag.attrs.src ?? "", children: parseInline(tag.attrs.caption ?? tag.attrs.src ?? ""), raw: line })
557
723
  i++
558
724
  continue
559
725
  }
560
726
 
561
- // Unknown template tag: skip the line rather than corrupting output.
727
+ // Unknown template tag: kept verbatim (renderers show nothing for it).
728
+ add({ type: "raw", markdown: line })
562
729
  i++
563
730
  continue
564
731
  }
@@ -580,8 +747,12 @@ export function parseBlocks(lines: string[]): Block[] {
580
747
  bodyStart = j + 1
581
748
  }
582
749
  }
583
- const { body, next } = collectHtmlUntil(lines, bodyStart, "</details>")
584
- blocks.push({ type: "expandable", summary, children: parseBlocks(body) })
750
+ const { body, next, closing } = collectHtmlUntil(lines, bodyStart, "</details>")
751
+ const expandable: ExpandableNode = { type: "expandable", summary, children: [] }
752
+ if (closing !== undefined && closing !== "</details>") expandable.closing = closing
753
+ const opening = lines.slice(i, bodyStart).join("\n")
754
+ if (opening !== `<details>\n\n<summary>${summary}</summary>`) expandable.opening = opening
755
+ container(expandable, body, (children) => (expandable.children = children), 1, 1)
585
756
  i = next
586
757
  continue
587
758
  }
@@ -589,13 +760,18 @@ export function parseBlocks(lines: string[]): Block[] {
589
760
  // <table …>…</table> — GitBook exports complex/cards tables as HTML
590
761
  if (/^<table[\s>]/i.test(trimmed)) {
591
762
  flushParagraph()
592
- const chunkLines = [line]
763
+ // Up to the matching </table>: nested tables count, fenced code inside cells doesn't.
593
764
  let j = i
594
- while (!chunkLines.join("\n").includes("</table>") && j + 1 < lines.length) {
595
- j++
596
- chunkLines.push(lines[j])
765
+ let depth = 0
766
+ const tableFences = fenceTracker()
767
+ for (; j < lines.length; j++) {
768
+ if (j > i && tableFences.step(lines[j])) continue
769
+ depth += (lines[j].match(/<table[\s>]/gi) ?? []).length - (lines[j].match(/<\/table>/gi) ?? []).length
770
+ if (depth <= 0) break
597
771
  }
598
- const chunk = chunkLines.join("\n")
772
+ j = Math.min(j, lines.length - 1)
773
+ const chunk = lines.slice(i, j + 1).join("\n")
774
+ const tableStart = blocks.length
599
775
  const view = chunk.match(/<table[^>]*data-view="([^"]*)"/i)?.[1]
600
776
  const rowsHtml = [...chunk.matchAll(/<tr[^>]*>(.*?)<\/tr>/gis)].map((m) => m[1])
601
777
  const parseCells = (rowHtml: string): Inline[][] =>
@@ -604,12 +780,14 @@ export function parseBlocks(lines: string[]): Block[] {
604
780
  )
605
781
  const allRows = rowsHtml.map(parseCells)
606
782
  const [header, ...rest] = allRows.length ? allRows : [[]]
607
- blocks.push({
783
+ add({
608
784
  type: "table",
609
785
  header: header ?? [],
610
786
  rows: rest,
611
- ...(view ? { view } : {}),
787
+ ...(view ? { view } : { html: true }),
612
788
  })
789
+ const htmlTable = blocks[tableStart] as TableNode
790
+ if (chunk !== serializeBlocks([{ ...htmlTable, gap: 0 }])) htmlTable.raw = chunk
613
791
  i = j + 1
614
792
  continue
615
793
  }
@@ -619,7 +797,7 @@ export function parseBlocks(lines: string[]): Block[] {
619
797
  if (soloHtmlImg) {
620
798
  flushParagraph()
621
799
  const alt = trimmed.match(/alt="([^"]*)"/i)?.[1] ?? ""
622
- blocks.push({ type: "figure", src: soloHtmlImg[1], alt, caption: "" })
800
+ add(withRaw({ type: "figure", src: soloHtmlImg[1], alt, caption: "" }, line))
623
801
  i++
624
802
  continue
625
803
  }
@@ -637,21 +815,24 @@ export function parseBlocks(lines: string[]): Block[] {
637
815
  const img = chunk.match(/<img[^>]*src="([^"]*)"[^>]*>/i)
638
816
  const alt = chunk.match(/<img[^>]*alt="([^"]*)"[^>]*>/i)
639
817
  const cap = chunk.match(/<figcaption>(.*?)<\/figcaption>/is)
640
- blocks.push({
818
+ const figure: FigureNode = {
641
819
  type: "figure",
642
820
  src: img?.[1] ?? "",
643
821
  alt: alt?.[1] ?? "",
644
822
  caption: (cap?.[1] ?? "").replace(/<\/?p>/g, "").trim(),
645
- })
823
+ }
824
+ add(withRaw(figure, chunk))
646
825
  i = j + 1
647
826
  continue
648
827
  }
649
828
 
650
829
  // plain markdown image on its own line
651
- const soloImg = trimmed.match(/^!\[([^\]]*)\]\(([^)\s]+)\)$/)
830
+ const soloImg = trimmed.match(SOLO_IMG_RE)
652
831
  if (soloImg) {
653
832
  flushParagraph()
654
- blocks.push({ type: "figure", src: soloImg[2], alt: soloImg[1], caption: "" })
833
+ const figure: FigureNode = { type: "figure", src: soloImg[2], alt: soloImg[1], caption: "" }
834
+ if (soloImg[3] !== undefined) figure.title = soloImg[3]
835
+ add(withRaw(figure, line))
655
836
  i++
656
837
  continue
657
838
  }
@@ -660,13 +841,15 @@ export function parseBlocks(lines: string[]): Block[] {
660
841
  if (trimmed === "$$") {
661
842
  flushParagraph()
662
843
  const formula: string[] = []
844
+ const mathStart = i
663
845
  i++
664
846
  while (i < lines.length && lines[i].trim() !== "$$") {
665
847
  formula.push(lines[i])
666
848
  i++
667
849
  }
668
850
  i++
669
- blocks.push({ type: "math", formula: formula.join("\n") })
851
+ add({ type: "math", formula: formula.join("\n") })
852
+ keepRaw(mathStart, i)
670
853
  continue
671
854
  }
672
855
 
@@ -683,8 +866,15 @@ export function parseBlocks(lines: string[]): Block[] {
683
866
  code.push(lines[i])
684
867
  i++
685
868
  }
869
+ const closingLine = i < lines.length ? lines[i] : ""
686
870
  i++
687
- blocks.push({
871
+ const layout: Partial<CodeBlockNode> = {}
872
+ const lead = line.match(/^\s*/)![0]
873
+ if (lead + fence[1] !== "```") layout.fence = lead + fence[1]
874
+ if (closingLine !== fence[1]) layout.closingFence = closingLine
875
+ const infoRaw = line.trimStart().slice(fence[1].length)
876
+ const codeNode: CodeBlockNode = {
877
+ ...layout,
688
878
  type: "code",
689
879
  language: (!bareAttrs && fence[2]) || null,
690
880
  title: attrs.title ?? null,
@@ -698,13 +888,17 @@ export function parseBlocks(lines: string[]): Block[] {
698
888
  ...(positiveNumberAttr(attrs, "expandedCodeLines") === undefined
699
889
  ? {}
700
890
  : { expandedCodeLines: positiveNumberAttr(attrs, "expandedCodeLines") }),
701
- })
891
+ }
892
+ // The info string as written, where it isn't what serialization writes (spacing, attribute order).
893
+ if (infoRaw !== fenceInfo(codeNode)) codeNode.info = infoRaw
894
+ add(codeNode)
702
895
  continue
703
896
  }
704
897
 
705
898
  // indented code block (4+ spaces, GFM)
706
899
  if (paragraph.length === 0 && /^ {4,}\S/.test(line) && !line.trim().match(/^([-*+]|\d+[.)])\s/)) {
707
900
  const code: string[] = []
901
+ const from = i
708
902
  while (
709
903
  i < lines.length &&
710
904
  (/^ {4,}\S/.test(lines[i]) || (!lines[i].trim() && /^ {4,}\S/.test(lines[i + 1] ?? "")))
@@ -712,31 +906,36 @@ export function parseBlocks(lines: string[]): Block[] {
712
906
  code.push(lines[i].slice(4))
713
907
  i++
714
908
  }
715
- blocks.push({ type: "code", language: null, title: null, lineNumbers: false, code: code.join("\n") })
909
+ const indentedCode: CodeBlockNode = { type: "code", language: null, title: null, lineNumbers: false, code: code.join("\n"), indented: true }
910
+ // Whitespace-only lines inside keep their spaces.
911
+ const written = lines.slice(from, i).join("\n")
912
+ if (written !== code.map((l) => (l ? ` ${l}` : l)).join("\n")) indentedCode.raw = written
913
+ add(indentedCode)
716
914
  continue
717
915
  }
718
916
 
719
917
  // setext headings: a paragraph line followed by ==== (h1) or ---- (h2)
720
918
  if (paragraph.length && /^=+$/.test(trimmed)) {
721
- const text = paragraph.join(" ").trim()
919
+ const text = paragraph.join("\n").trim()
722
920
  paragraph.length = 0
723
- blocks.push({ type: "heading", level: 1, children: parseInline(text) })
921
+ add({ type: "heading", level: 1, children: parseInline(text), setext: trimmed }, paragraphGap, paragraphLines)
724
922
  i++
725
923
  continue
726
924
  }
727
925
  if (paragraph.length && /^-+$/.test(trimmed)) {
728
- const text = paragraph.join(" ").trim()
926
+ const text = paragraph.join("\n").trim()
729
927
  paragraph.length = 0
730
- blocks.push({ type: "heading", level: 2, children: parseInline(text) })
928
+ add({ type: "heading", level: 2, children: parseInline(text), setext: trimmed }, paragraphGap, paragraphLines)
731
929
  i++
732
930
  continue
733
931
  }
734
932
 
735
933
  // heading (GitBook exports can contain empty headings like a bare "##")
736
- const heading = trimmed.match(/^(#{1,6})(?:\s+(.*))?$/)
934
+ // Trailing spaces stay in the text, so they come back.
935
+ const heading = line.trimStart().match(/^(#{1,6})(?:\s+(.*))?$/)
737
936
  if (heading) {
738
937
  flushParagraph()
739
- blocks.push({
938
+ add({
740
939
  type: "heading",
741
940
  level: heading[1].length as 1 | 2 | 3 | 4 | 5 | 6,
742
941
  children: parseInline(heading[2] ?? ""),
@@ -748,7 +947,7 @@ export function parseBlocks(lines: string[]): Block[] {
748
947
  // divider
749
948
  if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
750
949
  flushParagraph()
751
- blocks.push({ type: "divider" })
950
+ add(trimmed === "---" ? { type: "divider" } : { type: "divider", marker: trimmed })
752
951
  i++
753
952
  continue
754
953
  }
@@ -757,27 +956,39 @@ export function parseBlocks(lines: string[]): Block[] {
757
956
  if (trimmed.startsWith(">")) {
758
957
  flushParagraph()
759
958
  const quote: string[] = []
959
+ const markers: string[] = []
760
960
  while (i < lines.length && lines[i].trim().startsWith(">")) {
761
- quote.push(lines[i].trim().replace(/^>\s?/, ""))
961
+ const marker = lines[i].match(/^\s*>[ \t]?/)![0]
962
+ markers.push(marker)
963
+ quote.push(lines[i].slice(marker.length))
762
964
  i++
763
965
  }
764
- blocks.push({ type: "blockquote", children: parseBlocks(quote) })
966
+ const quoteNode: BlockquoteNode = { type: "blockquote", children: [] }
967
+ if (markers.some((m, k) => m !== (quote[k] ? "> " : ">"))) quoteNode.markers = markers
968
+ container(quoteNode, quote, (children) => (quoteNode.children = children))
765
969
  continue
766
970
  }
767
971
 
768
972
  // table
769
973
  if (trimmed.startsWith("|") && lines[i + 1]?.trim().match(/^\|[\s:|-]+\|$/)) {
770
974
  flushParagraph()
771
- const parseRow = (row: string): Inline[][] =>
772
- splitTableRow(row).map((cell) => parseInline(cell.trim()))
975
+ const parseRow = (row: string): Inline[][] => splitTableRow(row).map((cell) => parseCell(cell.trim()))
773
976
  const header = parseRow(lines[i])
977
+ const delimiterRow = lines[i + 1]
774
978
  i += 2
775
979
  const rows: Inline[][][] = []
776
980
  while (i < lines.length && lines[i].trim().startsWith("|")) {
777
981
  rows.push(parseRow(lines[i]))
778
982
  i++
779
983
  }
780
- blocks.push({ type: "table", header, rows })
984
+ const table: TableNode = { type: "table", header, rows }
985
+ const rowLines = lines.slice(i - rows.length - 2, i).filter((_, k) => k !== 1)
986
+ const align = delimiterAlign(delimiterRow)
987
+ if (align.some((a) => a !== null)) table.align = align
988
+ if (delimiterRow !== defaultDelimiterRow(header.length, table.align)) table.delimiterRow = delimiterRow
989
+ const canonicalRows = serializeBlocks([{ ...table, gap: 0 }]).split("\n").filter((_, k) => k !== 1)
990
+ if (rowLines.some((l, k) => l !== canonicalRows[k])) table.rawRows = rowLines
991
+ add(table)
781
992
  continue
782
993
  }
783
994
 
@@ -786,56 +997,88 @@ export function parseBlocks(lines: string[]): Block[] {
786
997
  if (listMatch && listMatch[1].length < 4) {
787
998
  flushParagraph()
788
999
  const { list, next } = parseList(lines, i)
789
- blocks.push(list)
1000
+ add(list)
790
1001
  i = next
791
1002
  continue
792
1003
  }
793
1004
 
794
- paragraph.push(trimmed)
1005
+ // [label]: url "title" — a reference definition, kept where it is; a footnote definition that doesn't
1006
+ // end the document too
1007
+ const foot = line.match(FOOTNOTE_DEF_RE)
1008
+ const def = foot ? null : line.match(REF_DEF_RE)
1009
+ if (foot || (def && !def[1].startsWith("^"))) {
1010
+ flushParagraph()
1011
+ const node: DefinitionNode = foot
1012
+ ? { type: "definition", label: `^${foot[1]}`, url: foot[2], ...(foot[3] ? { title: foot[3] } : {}) }
1013
+ : { type: "definition", label: def![1], url: def![2], ...(def![3] !== undefined ? { title: def![3] } : {}) }
1014
+ if (line !== definitionLine(node)) node.raw = line
1015
+ add(node)
1016
+ i++
1017
+ continue
1018
+ }
1019
+
1020
+ // Continuation lines keep their indentation and hard-break spaces, so wrapped text comes back as written.
1021
+ if (!paragraph.length) {
1022
+ paragraphGap = gapHere
1023
+ paragraphLines = gapLines
1024
+ paragraphIndent = line.match(/^\s*/)![0]
1025
+ }
1026
+ paragraph.push(paragraph.length ? line : line.trimStart())
795
1027
  i++
796
1028
  }
797
1029
 
798
1030
  flushParagraph()
799
- return blocks
1031
+ return { blocks, end: blank }
800
1032
  }
801
1033
 
802
- function parseTabs(lines: string[]): TabNode[] {
803
- const tabs: TabNode[] = []
1034
+ /**
1035
+ * The `{% name %}…{% endname %}` items of a container body (tabs, steps, columns, updates), with the blank
1036
+ * lines before each (`gap`: default 0 before the first, 1 between items) and after the last (`end`).
1037
+ * Other lines between items are ignored.
1038
+ */
1039
+ function parseItems<T extends TaggedNode>(
1040
+ lines: string[],
1041
+ name: string,
1042
+ make: (tag: TemplateTag, body: string[]) => T,
1043
+ ): { items: T[]; end: number } {
1044
+ const items: T[] = []
1045
+ let blank = 0
804
1046
  let i = 0
805
1047
  while (i < lines.length) {
1048
+ if (!lines[i].trim()) {
1049
+ blank++
1050
+ i++
1051
+ continue
1052
+ }
806
1053
  const tag = templateTag(lines[i])
807
- if (tag?.name === "tab") {
808
- const { body, next } = collectUntil(lines, i + 1, "tab")
809
- tabs.push({ type: "tab", title: tag.attrs.title ?? "Tab", children: parseBlocks(body) })
1054
+ if (tag?.name === name) {
1055
+ const { body, next, closing } = collectUntil(lines, i + 1, name)
1056
+ items.push(spaced(tagged(make(tag, body), lines[i], closing), blank, items.length ? 1 : 0))
810
1057
  i = next
811
1058
  } else {
812
1059
  i++
813
1060
  }
1061
+ blank = 0
814
1062
  }
815
- return tabs
1063
+ return { items, end: blank }
816
1064
  }
817
1065
 
818
- function parseSteps(lines: string[]): StepNode[] {
819
- const steps: StepNode[] = []
820
- let i = 0
821
- while (i < lines.length) {
822
- const tag = templateTag(lines[i])
823
- if (tag?.name === "step") {
824
- const { body, next } = collectUntil(lines, i + 1, "step")
825
- // GitBook convention: the step's first heading is its title
826
- const inner = parseBlocks(body)
827
- let title = ""
828
- if (inner[0]?.type === "heading") {
829
- title = plainText(inner[0].children)
830
- inner.shift()
831
- }
832
- steps.push({ type: "step", title, children: inner })
833
- i = next
834
- } else {
835
- i++
1066
+ function parseSteps(lines: string[]): { items: StepNode[]; end: number } {
1067
+ return parseItems(lines, "step", (_, body): StepNode => {
1068
+ // GitBook convention: the step's first heading is its title
1069
+ const { blocks: inner, end } = parseBody(body)
1070
+ let title = ""
1071
+ const heading = inner[0]?.type === "heading" ? inner[0] : undefined
1072
+ const step: StepNode = { type: "step", title, children: inner }
1073
+ if (heading) {
1074
+ step.title = plainText(heading.children)
1075
+ inner.shift()
1076
+ // The title heading as written (level, formatting, blank lines before it), where not `### title`.
1077
+ const written = body.slice(0, (heading.gap ?? 0) + (heading.setext ? 2 : 1)).join("\n")
1078
+ if (written !== `### ${step.title}`) step.heading = written
836
1079
  }
837
- }
838
- return steps
1080
+ return ended(step, end)
1081
+ })
839
1082
  }
840
1083
 
841
1084
  interface ParsedList {
@@ -843,31 +1086,49 @@ interface ParsedList {
843
1086
  next: number
844
1087
  }
845
1088
 
1089
+ const LIST_ITEM_RE = /^(\s*)([-*+]|\d+[.)])(\s+)(.*)$/
1090
+
846
1091
  // Indent-aware list parser: deeper-indented marker lines become nested lists
847
1092
  // inside the item (handled recursively via parseBlocks on dedented lines).
1093
+ // The markers, numbering, padding, continuation indent and blank lines between items are recorded
1094
+ // where they differ from what serialization writes.
848
1095
  function parseList(lines: string[], start: number): ParsedList {
849
- const first = lines[start].match(/^(\s*)([-*+]|\d+[.)])\s+/)!
1096
+ const first = lines[start].match(LIST_ITEM_RE)!
850
1097
  const baseIndent = first[1].length
851
1098
  const ordered = /^\d/.test(first[2])
852
1099
  const items: ListItemNode[] = []
853
- let task = false
1100
+ const list: ListNode = { type: "list", ordered, task: false, items }
1101
+ if (first[1]) list.indent = first[1]
1102
+ if (ordered) {
1103
+ const n = Number.parseInt(first[2], 10)
1104
+ if (n !== 1) list.start = n
1105
+ if (first[2].endsWith(")")) list.delimiter = ")"
1106
+ } else if (first[2] !== "-") {
1107
+ list.bullet = first[2] as "*" | "+"
1108
+ }
854
1109
  let i = start
1110
+ let gap = 0
1111
+ let blankLine = ""
855
1112
 
856
1113
  while (i < lines.length) {
857
- const m = lines[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/)
1114
+ const m = lines[i].match(LIST_ITEM_RE)
858
1115
  if (!m || m[1].length !== baseIndent || /^\d/.test(m[2]) !== ordered) break
859
1116
 
860
- let content = m[3]
1117
+ let content = m[4]
861
1118
  let checked: boolean | undefined
1119
+ let checkMark: string | undefined
862
1120
  const taskMatch = content.match(/^\[([ xX])\]\s+(.*)$/)
863
1121
  if (taskMatch) {
864
- task = true
1122
+ list.task = true
865
1123
  checked = taskMatch[1] !== " "
1124
+ checkMark = taskMatch[1]
866
1125
  content = taskMatch[2]
867
1126
  }
868
1127
 
869
1128
  // Everything indented deeper than the marker belongs to this item.
870
1129
  const contIndent = baseIndent + m[2].length + 1
1130
+ const minIndent = Math.min(contIndent, baseIndent + 2)
1131
+ let bodyIndent: number | undefined
871
1132
  const body: string[] = []
872
1133
  i++
873
1134
  while (i < lines.length) {
@@ -875,16 +1136,19 @@ function parseList(lines: string[], start: number): ParsedList {
875
1136
  if (!raw.trim()) {
876
1137
  // blank line stays in the item only if more indented content follows
877
1138
  const lookahead = lines[i + 1]
878
- if (lookahead !== undefined && /^\s+/.test(lookahead) && lookahead.search(/\S/) >= Math.min(contIndent, baseIndent + 2)) {
879
- body.push("")
1139
+ if (lookahead !== undefined && /^\s+/.test(lookahead) && lookahead.search(/\S/) >= minIndent) {
1140
+ // Whitespace-only lines stay as written (serialization doesn't indent them).
1141
+ body.push(raw)
880
1142
  i++
881
1143
  continue
882
1144
  }
883
1145
  break
884
1146
  }
885
1147
  const indent = raw.search(/\S/)
886
- if (indent >= Math.min(contIndent, baseIndent + 2)) {
887
- body.push(raw.slice(Math.min(indent, contIndent)))
1148
+ if (indent >= minIndent) {
1149
+ // The first continuation line sets the item's indent (four or more past the content is code).
1150
+ bodyIndent ??= indent >= contIndent + 4 ? contIndent : indent
1151
+ body.push(raw.slice(Math.min(indent, bodyIndent)))
888
1152
  i++
889
1153
  continue
890
1154
  }
@@ -893,53 +1157,27 @@ function parseList(lines: string[], start: number): ParsedList {
893
1157
 
894
1158
  const para: Block = { type: "paragraph", children: parseInline(content) }
895
1159
  const children: Block[] = body.length ? [para, ...parseBlocks(body)] : [para]
896
- items.push(
897
- checked === undefined ? { type: "listItem", children } : { type: "listItem", children, checked }
898
- )
1160
+ const item: ListItemNode = checked === undefined ? { type: "listItem", children } : { type: "listItem", children, checked }
1161
+ const expected = ordered ? `${(list.start ?? 1) + items.length}${list.delimiter ?? "."}` : (list.bullet ?? "-")
1162
+ if (m[2] !== expected) item.marker = m[2]
1163
+ if (m[3].length !== 1) item.pad = m[3].length
1164
+ if (bodyIndent !== undefined && bodyIndent - baseIndent !== 2) item.indent = bodyIndent - baseIndent
1165
+ if (checkMark === "X") item.checkMark = "X"
1166
+ if (blankLine) item.blanks = [blankLine]
1167
+ items.push(spaced(item, gap, 0))
1168
+ gap = 0
1169
+ blankLine = ""
899
1170
 
900
1171
  // skip a single blank line between sibling items
901
1172
  if (i < lines.length && !lines[i].trim()) {
902
1173
  const after = lines[i + 1]?.match(/^(\s*)([-*+]|\d+[.)])\s+/)
903
- if (after && after[1].length === baseIndent) i++
904
- else break
905
- }
906
- }
907
-
908
- return { list: { type: "list", ordered, task, items }, next: i }
909
- }
910
-
911
- function parseUpdates(lines: string[]): UpdateNode[] {
912
- const updates: UpdateNode[] = []
913
- let i = 0
914
- while (i < lines.length) {
915
- const tag = templateTag(lines[i])
916
- if (tag?.name === "update") {
917
- const { body, next } = collectUntil(lines, i + 1, "update")
918
- updates.push({
919
- type: "update",
920
- date: tag.attrs.date ?? "",
921
- children: parseBlocks(body),
922
- })
923
- i = next
924
- } else {
925
- i++
1174
+ if (after && after[1].length === baseIndent && /^\d/.test(after[2]) === ordered) {
1175
+ gap = 1
1176
+ blankLine = lines[i]
1177
+ i++
1178
+ } else break
926
1179
  }
927
1180
  }
928
- return updates
929
- }
930
1181
 
931
- function parseColumns(lines: string[]): ColumnNode[] {
932
- const cols: ColumnNode[] = []
933
- let i = 0
934
- while (i < lines.length) {
935
- const tag = templateTag(lines[i])
936
- if (tag?.name === "column") {
937
- const { body, next } = collectUntil(lines, i + 1, "column")
938
- cols.push({ type: "column", children: parseBlocks(body) })
939
- i = next
940
- } else {
941
- i++
942
- }
943
- }
944
- return cols
1182
+ return { list, next: i }
945
1183
  }