@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.
- package/README.md +56 -10
- package/package.json +9 -3
- package/src/demo/markdown.ts +1 -1
- package/src/demo/types.ts +2 -2
- package/src/docs/DocsRenderer.tsx +52 -16
- package/src/docs/math.ts +3 -0
- package/src/gitbook/ast.ts +240 -43
- package/src/gitbook/inline.ts +542 -149
- package/src/gitbook/parse.ts +403 -165
- package/src/gitbook/serialize.ts +446 -126
- package/src/index.ts +10 -28
- package/src/playground/PlaygroundStreamdown.tsx +28 -61
- package/src/playground/filesystem.ts +10 -190
- package/src/playground/index.ts +4 -0
- package/src/playground/project.ts +194 -0
- package/src/replay/NativeReplay.tsx +236 -0
- package/src/replay/ReplayPreview.tsx +7 -225
- package/src/styles.css +0 -1
package/src/gitbook/parse.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
/** `` 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
|
-
|
|
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
|
-
|
|
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 (
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
763
|
+
// Up to the matching </table>: nested tables count, fenced code inside cells doesn't.
|
|
593
764
|
let j = i
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
830
|
+
const soloImg = trimmed.match(SOLO_IMG_RE)
|
|
652
831
|
if (soloImg) {
|
|
653
832
|
flushParagraph()
|
|
654
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
919
|
+
const text = paragraph.join("\n").trim()
|
|
722
920
|
paragraph.length = 0
|
|
723
|
-
|
|
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("
|
|
926
|
+
const text = paragraph.join("\n").trim()
|
|
729
927
|
paragraph.length = 0
|
|
730
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1000
|
+
add(list)
|
|
790
1001
|
i = next
|
|
791
1002
|
continue
|
|
792
1003
|
}
|
|
793
1004
|
|
|
794
|
-
|
|
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
|
-
|
|
803
|
-
|
|
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 ===
|
|
808
|
-
const { body, next } = collectUntil(lines, i + 1,
|
|
809
|
-
|
|
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
|
|
1063
|
+
return { items, end: blank }
|
|
816
1064
|
}
|
|
817
1065
|
|
|
818
|
-
function parseSteps(lines: string[]): StepNode[] {
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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[
|
|
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/) >=
|
|
879
|
-
|
|
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 >=
|
|
887
|
-
|
|
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
|
-
|
|
897
|
-
|
|
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)
|
|
904
|
-
|
|
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
|
-
|
|
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
|
}
|