@brett_lamy/docstream 1.2.2 → 1.2.4

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,26 +51,57 @@ 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[] {
39
76
  const trimmed = row.trim().replace(/^\|/, "").replace(/\|$/, "")
40
77
  const cells: string[] = []
41
78
  let cur = ""
42
- let inCode = false
43
79
  for (let k = 0; k < trimmed.length; k++) {
44
80
  const ch = trimmed[k]
45
- if (ch === "\\" && trimmed[k + 1] === "|") {
46
- cur += "|"
81
+ if (ch === "\\") {
82
+ // `\|` is a literal pipe (in code spans too); any other escape passes through for the inline parser.
83
+ cur += trimmed[k + 1] === "|" ? "|" : trimmed.slice(k, k + 2)
47
84
  k++
48
85
  continue
49
86
  }
50
87
  if (ch === "`") {
51
- inCode = !inCode
52
- cur += ch
88
+ // A code span (a backtick run closed by one of the same length) keeps its pipes; a lone run is literal.
89
+ let n = 1
90
+ while (trimmed[k + n] === "`") n++
91
+ const fence = "`".repeat(n)
92
+ let end = trimmed.indexOf(fence, k + n)
93
+ while (end !== -1 && (trimmed[end + n] === "`" || trimmed[end - 1] === "`")) {
94
+ let e = end
95
+ while (trimmed[e] === "`") e++
96
+ end = trimmed.indexOf(fence, e)
97
+ }
98
+ const stop = end === -1 ? k + n : end + n
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 : "|"))
101
+ k = stop - 1
53
102
  continue
54
103
  }
55
- if (ch === "|" && !inCode) {
104
+ if (ch === "|") {
56
105
  cells.push(cur)
57
106
  cur = ""
58
107
  continue
@@ -138,7 +187,7 @@ export function fenceTracker() {
138
187
  // Collects the lines between an opening {% name %} and its matching
139
188
  // {% endname %}, honoring nesting of the same tag. Tags inside fenced code
140
189
  // (a demo file, a Markdown sample) are content, not structure.
141
- 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 } {
142
191
  const body: string[] = []
143
192
  let depth = 1
144
193
  let i = start
@@ -152,21 +201,29 @@ function collectUntil(lines: string[], start: number, name: string): { body: str
152
201
  if (tag?.name === name) depth++
153
202
  if (tag?.name === `end${name}`) {
154
203
  depth--
155
- if (depth === 0) return { body, next: i + 1 }
204
+ if (depth === 0) return { body, next: i + 1, closing: lines[i] }
156
205
  }
157
206
  body.push(lines[i])
158
207
  }
159
208
  return { body, next: i }
160
209
  }
161
210
 
162
- 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 } {
163
220
  const body: string[] = []
164
221
  let i = start
165
222
  for (; i < lines.length; i++) {
166
223
  if (lines[i].includes(closeTag)) {
167
224
  const before = lines[i].slice(0, lines[i].indexOf(closeTag))
168
225
  if (before.trim()) body.push(before)
169
- return { body, next: i + 1 }
226
+ return { body, next: i + 1, ...(before.trim() ? {} : { closing: lines[i] }) }
170
227
  }
171
228
  body.push(lines[i])
172
229
  }
@@ -175,35 +232,71 @@ function collectHtmlUntil(lines: string[], start: number, closeTag: string): { b
175
232
 
176
233
  const HINT_STYLES: HintStyle[] = ["info", "success", "warning", "danger"]
177
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
+
178
256
  export function parseMarkdown(src: string): DocumentNode {
179
- 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")
180
260
  refDefinitions.clear()
181
261
  footnoteDefinitions.clear()
182
- const content: string[] = []
183
262
  const fences = fenceTracker()
184
- for (const line of lines) {
263
+ // Canonical footnote definitions (outside fences), by line.
264
+ const canonicalFoot: boolean[] = []
265
+ lines.forEach((line, k) => {
185
266
  // Definition lines inside code fences are content, not definitions.
186
- if (!fences.step(line)) {
187
- // Footnote citation definitions first: [^id]: url "Optional Label"
188
- const foot = line.match(/^\[\^([^\]\s]+)\]:\s*(\S+)(?:\s+"([^"]*)")?\s*$/)
189
- if (foot) {
190
- footnoteDefinitions.set(foot[1], { url: foot[2], ...(foot[3] ? { label: foot[3] } : {}) })
191
- continue
192
- }
193
- const def = line.match(/^\[([^\]]+)\]:\s*(\S+)\s*$/)
194
- // Keys starting with ^ are reserved for footnotes and must never
195
- // shadow reference-style link definitions.
196
- if (def && !def[1].startsWith("^")) {
197
- refDefinitions.set(def[1].toLowerCase(), def[2])
198
- continue
199
- }
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
200
274
  }
201
- content.push(line)
202
- }
203
- 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 }
204
292
  if (footnoteDefinitions.size) {
205
293
  doc.citations = [...footnoteDefinitions.entries()].map(([id, d]) => ({ id, ...d }))
206
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
207
300
  return doc
208
301
  }
209
302
 
@@ -358,14 +451,64 @@ export function trimPartialInlineToken(md: string): string {
358
451
  }
359
452
 
360
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 } {
361
474
  const blocks: Block[] = []
362
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
+ }
363
500
 
364
501
  const paragraph: string[] = []
502
+ let paragraphIndent = ""
365
503
  const flushParagraph = () => {
366
504
  if (paragraph.length) {
367
- const text = paragraph.join(" ").trim()
368
- 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
+ }
369
512
  paragraph.length = 0
370
513
  }
371
514
  }
@@ -376,14 +519,21 @@ export function parseBlocks(lines: string[]): Block[] {
376
519
 
377
520
  if (!trimmed) {
378
521
  flushParagraph()
522
+ blank++
523
+ blankLines.push(line)
379
524
  i++
380
525
  continue
381
526
  }
527
+ gapHere = blank
528
+ gapLines = blankLines
529
+ blank = 0
530
+ blankLines = []
382
531
 
383
532
  const oneLine = line.match(COMMAND_ONE_LINE_RE)
384
533
  if (oneLine) {
385
534
  flushParagraph()
386
- blocks.push(commandNode(parseAttrs(oneLine[1]), oneLine[2]))
535
+ add(commandNode(parseAttrs(oneLine[1]), oneLine[2]))
536
+ keepRaw(i, i + 1)
387
537
  i++
388
538
  continue
389
539
  }
@@ -393,46 +543,59 @@ export function parseBlocks(lines: string[]): Block[] {
393
543
  flushParagraph()
394
544
 
395
545
  if (tag.name === "hint") {
396
- const { body, next } = collectUntil(lines, i + 1, "hint")
546
+ const { body, next, closing } = collectUntil(lines, i + 1, "hint")
397
547
  const style = (HINT_STYLES as string[]).includes(tag.attrs.style)
398
548
  ? (tag.attrs.style as HintStyle)
399
549
  : "info"
400
- 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))
401
552
  i = next
402
553
  continue
403
554
  }
404
555
 
405
556
  if (tag.name === "tabs") {
406
- const { body, next } = collectUntil(lines, i + 1, "tabs")
557
+ const { body, next, closing } = collectUntil(lines, i + 1, "tabs")
407
558
  const level = Number(tag.attrs.level)
408
- 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 = {
409
564
  type: "tabs",
410
- tabs: parseTabs(body),
565
+ tabs: tabs.items,
411
566
  ...(tag.attrs.sync ? { sync: tag.attrs.sync } : {}),
412
567
  ...(tag.attrs.title ? { title: tag.attrs.title } : {}),
413
568
  ...(tag.attrs.title && (level === 3 || level === 4) ? { level: level as 3 | 4 } : {}),
414
- })
569
+ }
570
+ add(tagged(ended(tabsNode, tabs.end), line, closing))
415
571
  i = next
416
572
  continue
417
573
  }
418
574
 
419
575
  if (tag.name === "command") {
420
- const { body, next } = collectUntil(lines, i + 1, "command")
421
- 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)
422
580
  i = next
423
581
  continue
424
582
  }
425
583
 
426
584
  if (tag.name === "stepper") {
427
- const { body, next } = collectUntil(lines, i + 1, "stepper")
428
- 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))
429
588
  i = next
430
589
  continue
431
590
  }
432
591
 
433
592
  if (tag.name === "columns") {
434
- const { body, next } = collectUntil(lines, i + 1, "columns")
435
- 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))
436
599
  i = next
437
600
  continue
438
601
  }
@@ -446,14 +609,16 @@ export function parseBlocks(lines: string[]): Block[] {
446
609
  Object.assign(code, lineNumbersFrom(tag.attrs, code.title !== null))
447
610
  code.live = tag.attrs.live === "true"
448
611
  code.entry = tag.attrs.entry ?? null
449
- blocks.push(code)
612
+ delete code.gap
613
+ code.raw = lines.slice(i, next).join("\n")
614
+ add(code)
450
615
  }
451
616
  i = next
452
617
  continue
453
618
  }
454
619
 
455
620
  if (tag.name === "embed") {
456
- blocks.push({
621
+ add({
457
622
  type: "embed",
458
623
  url: tag.attrs.url ?? "",
459
624
  ...(tag.attrs.title ? { title: tag.attrs.title } : {}),
@@ -473,21 +638,25 @@ export function parseBlocks(lines: string[]): Block[] {
473
638
  })
474
639
  i++
475
640
  // tolerate optional {% endembed %}
476
- 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
+ }
477
645
  continue
478
646
  }
479
647
 
480
648
  if (tag.name === "content-ref") {
481
649
  const { body, next } = collectUntil(lines, i + 1, "content-ref")
482
650
  const inner: Inline[] = parseInline(body.join(" ").trim())
483
- 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)
484
653
  i = next
485
654
  continue
486
655
  }
487
656
 
488
657
  if (tag.name === "source-ref" || tag.name === "component" || tag.name === "story") {
489
658
  const kind = tag.name === "story" || tag.attrs.kind === "story" ? "story" : "component"
490
- blocks.push({
659
+ add({
491
660
  type: "source-ref",
492
661
  mount: tag.attrs.mount ?? "source",
493
662
  path: tag.attrs.path ?? tag.attrs.src ?? "",
@@ -495,13 +664,18 @@ export function parseBlocks(lines: string[]): Block[] {
495
664
  kind,
496
665
  ...(tag.attrs.title ? { title: tag.attrs.title } : {}),
497
666
  })
667
+ const from = i
498
668
  i++
499
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
500
673
  continue
501
674
  }
502
675
 
503
676
  if (tag.name === "demo") {
504
677
  const node = parseDemoTag(tag.attrs)
678
+ const demoStart = i
505
679
  i++
506
680
  // `{% demo … /%}` never has a body; otherwise look for inline files / {% enddemo %}.
507
681
  const body = demoBody(lines, i, /\/\s*%\}\s*$/.test(line))
@@ -510,17 +684,19 @@ export function parseBlocks(lines: string[]): Block[] {
510
684
  if (body.open) node.open = true
511
685
  i = body.next
512
686
  }
513
- blocks.push(node)
687
+ add(node)
688
+ keepRaw(demoStart, i)
514
689
  continue
515
690
  }
516
691
 
517
692
  if (tag.name === "updates") {
518
- const { body, next } = collectUntil(lines, i + 1, "updates")
519
- blocks.push({
520
- type: "updates",
521
- format: tag.attrs.format ?? null,
522
- 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)
523
697
  })
698
+ const updatesNode: UpdatesNode = { type: "updates", format: tag.attrs.format ?? null, updates: updates.items }
699
+ add(tagged(ended(updatesNode, updates.end), line, closing))
524
700
  i = next
525
701
  continue
526
702
  }
@@ -528,7 +704,7 @@ export function parseBlocks(lines: string[]): Block[] {
528
704
  if (tag.name === "openapi-operation" || tag.name === "openapi") {
529
705
  const { body, next } = collectUntil(lines, i + 1, tag.name)
530
706
  const link = body.join(" ").match(/\[([^\]]*)\]\(([^)\s]+)\)/)
531
- blocks.push({
707
+ add({
532
708
  type: "openapi-operation",
533
709
  spec: tag.attrs.spec ?? "",
534
710
  path: tag.attrs.path ?? "",
@@ -536,18 +712,20 @@ export function parseBlocks(lines: string[]): Block[] {
536
712
  specUrl: link?.[2] ?? "",
537
713
  label: link?.[1] ?? "",
538
714
  })
715
+ keepRaw(i, next)
539
716
  i = next
540
717
  continue
541
718
  }
542
719
 
543
720
  if (tag.name === "file") {
544
721
  // Render file blocks as content-refs for now — same shape, different chrome.
545
- 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 })
546
723
  i++
547
724
  continue
548
725
  }
549
726
 
550
- // 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 })
551
729
  i++
552
730
  continue
553
731
  }
@@ -569,8 +747,12 @@ export function parseBlocks(lines: string[]): Block[] {
569
747
  bodyStart = j + 1
570
748
  }
571
749
  }
572
- const { body, next } = collectHtmlUntil(lines, bodyStart, "</details>")
573
- 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)
574
756
  i = next
575
757
  continue
576
758
  }
@@ -578,13 +760,18 @@ export function parseBlocks(lines: string[]): Block[] {
578
760
  // <table …>…</table> — GitBook exports complex/cards tables as HTML
579
761
  if (/^<table[\s>]/i.test(trimmed)) {
580
762
  flushParagraph()
581
- const chunkLines = [line]
763
+ // Up to the matching </table>: nested tables count, fenced code inside cells doesn't.
582
764
  let j = i
583
- while (!chunkLines.join("\n").includes("</table>") && j + 1 < lines.length) {
584
- j++
585
- 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
586
771
  }
587
- 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
588
775
  const view = chunk.match(/<table[^>]*data-view="([^"]*)"/i)?.[1]
589
776
  const rowsHtml = [...chunk.matchAll(/<tr[^>]*>(.*?)<\/tr>/gis)].map((m) => m[1])
590
777
  const parseCells = (rowHtml: string): Inline[][] =>
@@ -593,12 +780,14 @@ export function parseBlocks(lines: string[]): Block[] {
593
780
  )
594
781
  const allRows = rowsHtml.map(parseCells)
595
782
  const [header, ...rest] = allRows.length ? allRows : [[]]
596
- blocks.push({
783
+ add({
597
784
  type: "table",
598
785
  header: header ?? [],
599
786
  rows: rest,
600
- ...(view ? { view } : {}),
787
+ ...(view ? { view } : { html: true }),
601
788
  })
789
+ const htmlTable = blocks[tableStart] as TableNode
790
+ if (chunk !== serializeBlocks([{ ...htmlTable, gap: 0 }])) htmlTable.raw = chunk
602
791
  i = j + 1
603
792
  continue
604
793
  }
@@ -608,7 +797,7 @@ export function parseBlocks(lines: string[]): Block[] {
608
797
  if (soloHtmlImg) {
609
798
  flushParagraph()
610
799
  const alt = trimmed.match(/alt="([^"]*)"/i)?.[1] ?? ""
611
- blocks.push({ type: "figure", src: soloHtmlImg[1], alt, caption: "" })
800
+ add(withRaw({ type: "figure", src: soloHtmlImg[1], alt, caption: "" }, line))
612
801
  i++
613
802
  continue
614
803
  }
@@ -626,21 +815,24 @@ export function parseBlocks(lines: string[]): Block[] {
626
815
  const img = chunk.match(/<img[^>]*src="([^"]*)"[^>]*>/i)
627
816
  const alt = chunk.match(/<img[^>]*alt="([^"]*)"[^>]*>/i)
628
817
  const cap = chunk.match(/<figcaption>(.*?)<\/figcaption>/is)
629
- blocks.push({
818
+ const figure: FigureNode = {
630
819
  type: "figure",
631
820
  src: img?.[1] ?? "",
632
821
  alt: alt?.[1] ?? "",
633
822
  caption: (cap?.[1] ?? "").replace(/<\/?p>/g, "").trim(),
634
- })
823
+ }
824
+ add(withRaw(figure, chunk))
635
825
  i = j + 1
636
826
  continue
637
827
  }
638
828
 
639
829
  // plain markdown image on its own line
640
- const soloImg = trimmed.match(/^!\[([^\]]*)\]\(([^)\s]+)\)$/)
830
+ const soloImg = trimmed.match(SOLO_IMG_RE)
641
831
  if (soloImg) {
642
832
  flushParagraph()
643
- 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))
644
836
  i++
645
837
  continue
646
838
  }
@@ -649,13 +841,15 @@ export function parseBlocks(lines: string[]): Block[] {
649
841
  if (trimmed === "$$") {
650
842
  flushParagraph()
651
843
  const formula: string[] = []
844
+ const mathStart = i
652
845
  i++
653
846
  while (i < lines.length && lines[i].trim() !== "$$") {
654
847
  formula.push(lines[i])
655
848
  i++
656
849
  }
657
850
  i++
658
- blocks.push({ type: "math", formula: formula.join("\n") })
851
+ add({ type: "math", formula: formula.join("\n") })
852
+ keepRaw(mathStart, i)
659
853
  continue
660
854
  }
661
855
 
@@ -672,8 +866,15 @@ export function parseBlocks(lines: string[]): Block[] {
672
866
  code.push(lines[i])
673
867
  i++
674
868
  }
869
+ const closingLine = i < lines.length ? lines[i] : ""
675
870
  i++
676
- 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,
677
878
  type: "code",
678
879
  language: (!bareAttrs && fence[2]) || null,
679
880
  title: attrs.title ?? null,
@@ -687,13 +888,17 @@ export function parseBlocks(lines: string[]): Block[] {
687
888
  ...(positiveNumberAttr(attrs, "expandedCodeLines") === undefined
688
889
  ? {}
689
890
  : { expandedCodeLines: positiveNumberAttr(attrs, "expandedCodeLines") }),
690
- })
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)
691
895
  continue
692
896
  }
693
897
 
694
898
  // indented code block (4+ spaces, GFM)
695
899
  if (paragraph.length === 0 && /^ {4,}\S/.test(line) && !line.trim().match(/^([-*+]|\d+[.)])\s/)) {
696
900
  const code: string[] = []
901
+ const from = i
697
902
  while (
698
903
  i < lines.length &&
699
904
  (/^ {4,}\S/.test(lines[i]) || (!lines[i].trim() && /^ {4,}\S/.test(lines[i + 1] ?? "")))
@@ -701,31 +906,36 @@ export function parseBlocks(lines: string[]): Block[] {
701
906
  code.push(lines[i].slice(4))
702
907
  i++
703
908
  }
704
- 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)
705
914
  continue
706
915
  }
707
916
 
708
917
  // setext headings: a paragraph line followed by ==== (h1) or ---- (h2)
709
918
  if (paragraph.length && /^=+$/.test(trimmed)) {
710
- const text = paragraph.join(" ").trim()
919
+ const text = paragraph.join("\n").trim()
711
920
  paragraph.length = 0
712
- blocks.push({ type: "heading", level: 1, children: parseInline(text) })
921
+ add({ type: "heading", level: 1, children: parseInline(text), setext: trimmed }, paragraphGap, paragraphLines)
713
922
  i++
714
923
  continue
715
924
  }
716
925
  if (paragraph.length && /^-+$/.test(trimmed)) {
717
- const text = paragraph.join(" ").trim()
926
+ const text = paragraph.join("\n").trim()
718
927
  paragraph.length = 0
719
- blocks.push({ type: "heading", level: 2, children: parseInline(text) })
928
+ add({ type: "heading", level: 2, children: parseInline(text), setext: trimmed }, paragraphGap, paragraphLines)
720
929
  i++
721
930
  continue
722
931
  }
723
932
 
724
933
  // heading (GitBook exports can contain empty headings like a bare "##")
725
- 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+(.*))?$/)
726
936
  if (heading) {
727
937
  flushParagraph()
728
- blocks.push({
938
+ add({
729
939
  type: "heading",
730
940
  level: heading[1].length as 1 | 2 | 3 | 4 | 5 | 6,
731
941
  children: parseInline(heading[2] ?? ""),
@@ -737,7 +947,7 @@ export function parseBlocks(lines: string[]): Block[] {
737
947
  // divider
738
948
  if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
739
949
  flushParagraph()
740
- blocks.push({ type: "divider" })
950
+ add(trimmed === "---" ? { type: "divider" } : { type: "divider", marker: trimmed })
741
951
  i++
742
952
  continue
743
953
  }
@@ -746,27 +956,39 @@ export function parseBlocks(lines: string[]): Block[] {
746
956
  if (trimmed.startsWith(">")) {
747
957
  flushParagraph()
748
958
  const quote: string[] = []
959
+ const markers: string[] = []
749
960
  while (i < lines.length && lines[i].trim().startsWith(">")) {
750
- 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))
751
964
  i++
752
965
  }
753
- 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))
754
969
  continue
755
970
  }
756
971
 
757
972
  // table
758
973
  if (trimmed.startsWith("|") && lines[i + 1]?.trim().match(/^\|[\s:|-]+\|$/)) {
759
974
  flushParagraph()
760
- const parseRow = (row: string): Inline[][] =>
761
- splitTableRow(row).map((cell) => parseInline(cell.trim()))
975
+ const parseRow = (row: string): Inline[][] => splitTableRow(row).map((cell) => parseCell(cell.trim()))
762
976
  const header = parseRow(lines[i])
977
+ const delimiterRow = lines[i + 1]
763
978
  i += 2
764
979
  const rows: Inline[][][] = []
765
980
  while (i < lines.length && lines[i].trim().startsWith("|")) {
766
981
  rows.push(parseRow(lines[i]))
767
982
  i++
768
983
  }
769
- 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)
770
992
  continue
771
993
  }
772
994
 
@@ -775,56 +997,88 @@ export function parseBlocks(lines: string[]): Block[] {
775
997
  if (listMatch && listMatch[1].length < 4) {
776
998
  flushParagraph()
777
999
  const { list, next } = parseList(lines, i)
778
- blocks.push(list)
1000
+ add(list)
779
1001
  i = next
780
1002
  continue
781
1003
  }
782
1004
 
783
- 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())
784
1027
  i++
785
1028
  }
786
1029
 
787
1030
  flushParagraph()
788
- return blocks
1031
+ return { blocks, end: blank }
789
1032
  }
790
1033
 
791
- function parseTabs(lines: string[]): TabNode[] {
792
- 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
793
1046
  let i = 0
794
1047
  while (i < lines.length) {
1048
+ if (!lines[i].trim()) {
1049
+ blank++
1050
+ i++
1051
+ continue
1052
+ }
795
1053
  const tag = templateTag(lines[i])
796
- if (tag?.name === "tab") {
797
- const { body, next } = collectUntil(lines, i + 1, "tab")
798
- 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))
799
1057
  i = next
800
1058
  } else {
801
1059
  i++
802
1060
  }
1061
+ blank = 0
803
1062
  }
804
- return tabs
1063
+ return { items, end: blank }
805
1064
  }
806
1065
 
807
- function parseSteps(lines: string[]): StepNode[] {
808
- const steps: StepNode[] = []
809
- let i = 0
810
- while (i < lines.length) {
811
- const tag = templateTag(lines[i])
812
- if (tag?.name === "step") {
813
- const { body, next } = collectUntil(lines, i + 1, "step")
814
- // GitBook convention: the step's first heading is its title
815
- const inner = parseBlocks(body)
816
- let title = ""
817
- if (inner[0]?.type === "heading") {
818
- title = plainText(inner[0].children)
819
- inner.shift()
820
- }
821
- steps.push({ type: "step", title, children: inner })
822
- i = next
823
- } else {
824
- 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
825
1079
  }
826
- }
827
- return steps
1080
+ return ended(step, end)
1081
+ })
828
1082
  }
829
1083
 
830
1084
  interface ParsedList {
@@ -832,31 +1086,49 @@ interface ParsedList {
832
1086
  next: number
833
1087
  }
834
1088
 
1089
+ const LIST_ITEM_RE = /^(\s*)([-*+]|\d+[.)])(\s+)(.*)$/
1090
+
835
1091
  // Indent-aware list parser: deeper-indented marker lines become nested lists
836
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.
837
1095
  function parseList(lines: string[], start: number): ParsedList {
838
- const first = lines[start].match(/^(\s*)([-*+]|\d+[.)])\s+/)!
1096
+ const first = lines[start].match(LIST_ITEM_RE)!
839
1097
  const baseIndent = first[1].length
840
1098
  const ordered = /^\d/.test(first[2])
841
1099
  const items: ListItemNode[] = []
842
- 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
+ }
843
1109
  let i = start
1110
+ let gap = 0
1111
+ let blankLine = ""
844
1112
 
845
1113
  while (i < lines.length) {
846
- const m = lines[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/)
1114
+ const m = lines[i].match(LIST_ITEM_RE)
847
1115
  if (!m || m[1].length !== baseIndent || /^\d/.test(m[2]) !== ordered) break
848
1116
 
849
- let content = m[3]
1117
+ let content = m[4]
850
1118
  let checked: boolean | undefined
1119
+ let checkMark: string | undefined
851
1120
  const taskMatch = content.match(/^\[([ xX])\]\s+(.*)$/)
852
1121
  if (taskMatch) {
853
- task = true
1122
+ list.task = true
854
1123
  checked = taskMatch[1] !== " "
1124
+ checkMark = taskMatch[1]
855
1125
  content = taskMatch[2]
856
1126
  }
857
1127
 
858
1128
  // Everything indented deeper than the marker belongs to this item.
859
1129
  const contIndent = baseIndent + m[2].length + 1
1130
+ const minIndent = Math.min(contIndent, baseIndent + 2)
1131
+ let bodyIndent: number | undefined
860
1132
  const body: string[] = []
861
1133
  i++
862
1134
  while (i < lines.length) {
@@ -864,16 +1136,19 @@ function parseList(lines: string[], start: number): ParsedList {
864
1136
  if (!raw.trim()) {
865
1137
  // blank line stays in the item only if more indented content follows
866
1138
  const lookahead = lines[i + 1]
867
- if (lookahead !== undefined && /^\s+/.test(lookahead) && lookahead.search(/\S/) >= Math.min(contIndent, baseIndent + 2)) {
868
- 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)
869
1142
  i++
870
1143
  continue
871
1144
  }
872
1145
  break
873
1146
  }
874
1147
  const indent = raw.search(/\S/)
875
- if (indent >= Math.min(contIndent, baseIndent + 2)) {
876
- 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)))
877
1152
  i++
878
1153
  continue
879
1154
  }
@@ -882,53 +1157,27 @@ function parseList(lines: string[], start: number): ParsedList {
882
1157
 
883
1158
  const para: Block = { type: "paragraph", children: parseInline(content) }
884
1159
  const children: Block[] = body.length ? [para, ...parseBlocks(body)] : [para]
885
- items.push(
886
- checked === undefined ? { type: "listItem", children } : { type: "listItem", children, checked }
887
- )
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 = ""
888
1170
 
889
1171
  // skip a single blank line between sibling items
890
1172
  if (i < lines.length && !lines[i].trim()) {
891
1173
  const after = lines[i + 1]?.match(/^(\s*)([-*+]|\d+[.)])\s+/)
892
- if (after && after[1].length === baseIndent) i++
893
- else break
894
- }
895
- }
896
-
897
- return { list: { type: "list", ordered, task, items }, next: i }
898
- }
899
-
900
- function parseUpdates(lines: string[]): UpdateNode[] {
901
- const updates: UpdateNode[] = []
902
- let i = 0
903
- while (i < lines.length) {
904
- const tag = templateTag(lines[i])
905
- if (tag?.name === "update") {
906
- const { body, next } = collectUntil(lines, i + 1, "update")
907
- updates.push({
908
- type: "update",
909
- date: tag.attrs.date ?? "",
910
- children: parseBlocks(body),
911
- })
912
- i = next
913
- } else {
914
- 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
915
1179
  }
916
1180
  }
917
- return updates
918
- }
919
1181
 
920
- function parseColumns(lines: string[]): ColumnNode[] {
921
- const cols: ColumnNode[] = []
922
- let i = 0
923
- while (i < lines.length) {
924
- const tag = templateTag(lines[i])
925
- if (tag?.name === "column") {
926
- const { body, next } = collectUntil(lines, i + 1, "column")
927
- cols.push({ type: "column", children: parseBlocks(body) })
928
- i = next
929
- } else {
930
- i++
931
- }
932
- }
933
- return cols
1182
+ return { list, next: i }
934
1183
  }