@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.
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/demo/markdown.ts +1 -1
- package/src/docs/DocsRenderer.tsx +21 -9
- package/src/gitbook/ast.ts +251 -38
- package/src/gitbook/index.ts +1 -1
- package/src/gitbook/inline.ts +901 -169
- package/src/gitbook/parse.ts +419 -170
- package/src/gitbook/serialize.ts +448 -127
- package/src/index.ts +2 -0
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,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 === "\\"
|
|
46
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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 === "|"
|
|
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
|
-
|
|
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
|
+
/** `` 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
|
-
|
|
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
|
-
|
|
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 (
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
763
|
+
// Up to the matching </table>: nested tables count, fenced code inside cells doesn't.
|
|
582
764
|
let j = i
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
830
|
+
const soloImg = trimmed.match(SOLO_IMG_RE)
|
|
641
831
|
if (soloImg) {
|
|
642
832
|
flushParagraph()
|
|
643
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
919
|
+
const text = paragraph.join("\n").trim()
|
|
711
920
|
paragraph.length = 0
|
|
712
|
-
|
|
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("
|
|
926
|
+
const text = paragraph.join("\n").trim()
|
|
718
927
|
paragraph.length = 0
|
|
719
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1000
|
+
add(list)
|
|
779
1001
|
i = next
|
|
780
1002
|
continue
|
|
781
1003
|
}
|
|
782
1004
|
|
|
783
|
-
|
|
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
|
-
|
|
792
|
-
|
|
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 ===
|
|
797
|
-
const { body, next } = collectUntil(lines, i + 1,
|
|
798
|
-
|
|
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
|
|
1063
|
+
return { items, end: blank }
|
|
805
1064
|
}
|
|
806
1065
|
|
|
807
|
-
function parseSteps(lines: string[]): StepNode[] {
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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[
|
|
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/) >=
|
|
868
|
-
|
|
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 >=
|
|
876
|
-
|
|
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
|
-
|
|
886
|
-
|
|
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)
|
|
893
|
-
|
|
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
|
-
|
|
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
|
}
|