@brett_lamy/docstream 1.2.3 → 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 +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/gitbook/serialize.ts
CHANGED
|
@@ -1,88 +1,384 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Block,
|
|
3
|
+
CitationDef,
|
|
4
|
+
ColumnNode,
|
|
5
|
+
ColumnsNode,
|
|
6
|
+
HintNode,
|
|
7
|
+
StepNode,
|
|
8
|
+
StepperNode,
|
|
9
|
+
TabNode,
|
|
10
|
+
TabsNode,
|
|
11
|
+
UpdateNode,
|
|
12
|
+
UpdatesNode,
|
|
13
|
+
CodeBlockNode,
|
|
14
|
+
CommandNode,
|
|
15
|
+
OpenApiOperationNode,
|
|
16
|
+
DefinitionNode,
|
|
17
|
+
DemoInlineFile,
|
|
18
|
+
DemoNode,
|
|
19
|
+
DocumentNode,
|
|
20
|
+
FigureNode,
|
|
21
|
+
Inline,
|
|
22
|
+
ListItemNode,
|
|
23
|
+
ListNode,
|
|
24
|
+
TableNode,
|
|
25
|
+
} from "./ast"
|
|
2
26
|
import { attr } from "./attrs"
|
|
3
|
-
import { serializeInline, serializeReference } from "./inline"
|
|
27
|
+
import { normalizeLabel, serializeInline, serializeReference, withDocumentDefinitions } from "./inline"
|
|
28
|
+
import { plainText } from "./inline"
|
|
29
|
+
import { parseBlocks } from "./parse"
|
|
30
|
+
|
|
31
|
+
/** A `{% tag %}…{% endtag %}` container, block or item. */
|
|
32
|
+
export type TaggedNode = HintNode | TabsNode | TabNode | StepperNode | StepNode | ColumnsNode | ColumnNode | UpdatesNode | UpdateNode
|
|
33
|
+
|
|
34
|
+
/** The opening and closing lines serialization writes for a container. */
|
|
35
|
+
export function containerTags(b: TaggedNode): [string, string] {
|
|
36
|
+
switch (b.type) {
|
|
37
|
+
case "hint":
|
|
38
|
+
return [`{% hint${attr("style", b.style)} %}`, "{% endhint %}"]
|
|
39
|
+
case "tabs": {
|
|
40
|
+
const attrs = [
|
|
41
|
+
b.title ? attr("title", b.title) : "",
|
|
42
|
+
b.title && b.level && b.level !== 2 ? attr("level", b.level) : "",
|
|
43
|
+
b.sync ? attr("sync", b.sync) : "",
|
|
44
|
+
].join("")
|
|
45
|
+
return [`{% tabs${attrs} %}`, "{% endtabs %}"]
|
|
46
|
+
}
|
|
47
|
+
case "tab":
|
|
48
|
+
return [`{% tab${attr("title", b.title)} %}`, "{% endtab %}"]
|
|
49
|
+
case "stepper":
|
|
50
|
+
return ["{% stepper %}", "{% endstepper %}"]
|
|
51
|
+
case "step":
|
|
52
|
+
return ["{% step %}", "{% endstep %}"]
|
|
53
|
+
case "columns":
|
|
54
|
+
return ["{% columns %}", "{% endcolumns %}"]
|
|
55
|
+
case "column":
|
|
56
|
+
return ["{% column %}", "{% endcolumn %}"]
|
|
57
|
+
case "updates":
|
|
58
|
+
return [`{% updates${b.format ? attr("format", b.format) : ""} %}`, "{% endupdates %}"]
|
|
59
|
+
case "update":
|
|
60
|
+
return [`{% update${attr("date", b.date)} %}`, "{% endupdate %}"]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Items live inside a parent container, which a shell must supply. */
|
|
65
|
+
const ITEM_PARENT: Partial<Record<TaggedNode["type"], [string, string, string]>> = {
|
|
66
|
+
tab: ["{% tabs %}", "{% endtabs %}", "tabs"],
|
|
67
|
+
step: ["{% stepper %}", "{% endstepper %}", "steps"],
|
|
68
|
+
column: ["{% columns %}", "{% endcolumns %}", "columns"],
|
|
69
|
+
update: ["{% updates %}", "{% endupdates %}", "updates"],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What a container's tags say, without its content. */
|
|
73
|
+
function tagSettings(b: TaggedNode | Block | undefined): string {
|
|
74
|
+
if (!b) return ""
|
|
75
|
+
const { children, tabs, steps, columns, updates, opening, closing, heading, title, ...rest } = b as unknown as Record<string, unknown>
|
|
76
|
+
return canonical(b.type === "step" ? rest : { ...rest, title })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The container's opening and closing lines: as written while they still say the same, else canonical. */
|
|
80
|
+
function tagsFor(b: TaggedNode): [string, string] {
|
|
81
|
+
const [open, close] = containerTags(b)
|
|
82
|
+
if (b.opening === undefined && b.closing === undefined) return [open, close]
|
|
83
|
+
const o = b.opening ?? open
|
|
84
|
+
const c = b.closing ?? close
|
|
85
|
+
const parent = ITEM_PARENT[b.type]
|
|
86
|
+
const text = parent ? `${parent[0]}\n${o}\n${c}\n${parent[1]}` : `${o}\n${c}`
|
|
87
|
+
const [first] = parseBlocks(text.split("\n"))
|
|
88
|
+
const again = parent ? ((first as unknown as Record<string, TaggedNode[]> | undefined)?.[parent[2]]?.[0]) : first
|
|
89
|
+
return again?.type === b.type && tagSettings(again) === tagSettings(b) ? [o, c] : [open, close]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A step's title heading: as written while it still gives the title, else `### title`. */
|
|
93
|
+
function stepHeading(s: StepNode): string {
|
|
94
|
+
if (s.heading !== undefined) {
|
|
95
|
+
const again = parseBlocks(s.heading.split("\n"))
|
|
96
|
+
if (again.length === 1 && again[0].type === "heading" && plainText(again[0].children) === s.title) return s.heading
|
|
97
|
+
}
|
|
98
|
+
return s.title ? `### ${s.title}` : ""
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Footnote definitions of the document being serialized, so positioned ones follow citation edits. */
|
|
102
|
+
let documentCitations: ReadonlyMap<string, CitationDef> | null = null
|
|
4
103
|
|
|
5
104
|
export function serializeMarkdown(doc: DocumentNode): string {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
105
|
+
const citations = new Map((doc.citations ?? []).map((c) => [c.id, c]))
|
|
106
|
+
const placed = new Set<string>()
|
|
107
|
+
const defs = new Map<string, string>()
|
|
108
|
+
walkBlocks(doc.children, (b) => {
|
|
109
|
+
if (b.type !== "definition") return
|
|
110
|
+
if (b.label.startsWith("^")) placed.add(b.label.slice(1))
|
|
111
|
+
else if (!defs.has(normalizeLabel(b.label))) defs.set(normalizeLabel(b.label), b.url)
|
|
112
|
+
})
|
|
113
|
+
const saved = documentCitations
|
|
114
|
+
documentCitations = citations
|
|
115
|
+
try {
|
|
116
|
+
return withDocumentDefinitions(defs, () => {
|
|
117
|
+
let out = doc.children.map((b, k) => `${blankLines(b, k ? 1 : 0)}${serializeBlock(b)}\n`).join("")
|
|
118
|
+
// Footnote definitions not placed in the document end it, as a group.
|
|
119
|
+
const trailing = (doc.citations ?? []).filter((c) => !placed.has(c.id))
|
|
120
|
+
if (trailing.length) {
|
|
121
|
+
const lines = trailing.map((c) => footnoteDefinition(c.id, c.url, c.label)).join("\n")
|
|
122
|
+
out += `${"\n".repeat(doc.citationsGap ?? (doc.children.length ? 1 : 0))}${lines}\n`
|
|
123
|
+
}
|
|
124
|
+
const text = out.replace(/\s*$/, "") + (doc.end ?? "\n")
|
|
125
|
+
return doc.lineEnding === "\r\n" ? text.replace(/\n/g, "\r\n") : text
|
|
126
|
+
})
|
|
127
|
+
} finally {
|
|
128
|
+
documentCitations = saved
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function footnoteDefinition(id: string, url: string, label?: string): string {
|
|
133
|
+
return `[^${id}]: ${url}${label ? ` "${label}"` : ""}`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Visits every block, containers' children included. */
|
|
137
|
+
function walkBlocks(blocks: Block[], visit: (b: Block) => void) {
|
|
138
|
+
for (const b of blocks) {
|
|
139
|
+
visit(b)
|
|
140
|
+
switch (b.type) {
|
|
141
|
+
case "hint":
|
|
142
|
+
case "expandable":
|
|
143
|
+
case "blockquote":
|
|
144
|
+
walkBlocks(b.children, visit)
|
|
145
|
+
break
|
|
146
|
+
case "tabs":
|
|
147
|
+
for (const t of b.tabs) walkBlocks(t.children, visit)
|
|
148
|
+
break
|
|
149
|
+
case "stepper":
|
|
150
|
+
for (const s of b.steps) walkBlocks(s.children, visit)
|
|
151
|
+
break
|
|
152
|
+
case "columns":
|
|
153
|
+
for (const c of b.columns) walkBlocks(c.children, visit)
|
|
154
|
+
break
|
|
155
|
+
case "updates":
|
|
156
|
+
for (const u of b.updates) walkBlocks(u.children, visit)
|
|
157
|
+
break
|
|
158
|
+
case "list":
|
|
159
|
+
for (const item of b.items) walkBlocks(item.children, visit)
|
|
160
|
+
break
|
|
161
|
+
}
|
|
12
162
|
}
|
|
13
|
-
return out + "\n"
|
|
14
163
|
}
|
|
15
164
|
|
|
165
|
+
/** The blank lines before a block: `gap` of them (`fallback` by default), as written when recorded. */
|
|
166
|
+
function blankLines(b: { gap?: number; blanks?: string[] }, fallback: number): string {
|
|
167
|
+
const gap = b.gap ?? fallback
|
|
168
|
+
if (b.blanks?.length === gap && b.blanks.every((l) => !l.trim())) return b.blanks.map((l) => `${l}\n`).join("")
|
|
169
|
+
return "\n".repeat(gap)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Blocks separated by their blank lines (default one; none before the first). */
|
|
16
173
|
export function serializeBlocks(blocks: Block[]): string {
|
|
17
|
-
return blocks.map(serializeBlock).join("
|
|
174
|
+
return blocks.map((b, k) => `${k ? "\n" : ""}${blankLines(b, k ? 1 : 0)}${serializeBlock(b)}`).join("")
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface BodyOptions {
|
|
178
|
+
/** Blank lines before the first child by default. */
|
|
179
|
+
firstGap?: number
|
|
180
|
+
/** Blank lines before the closing line by default. */
|
|
181
|
+
endGap?: number
|
|
182
|
+
/** The children follow another line of the body (a step's title), so the first is a sibling. */
|
|
183
|
+
preceded?: boolean
|
|
18
184
|
}
|
|
19
185
|
|
|
20
|
-
|
|
186
|
+
/**
|
|
187
|
+
* A container: its opening line(s), each child after its blank lines, the blank lines before the end, the
|
|
188
|
+
* closing line.
|
|
189
|
+
*/
|
|
190
|
+
function container(open: string, children: Block[], gapEnd: number | undefined, close: string, o: BodyOptions = {}): string {
|
|
191
|
+
const first = o.preceded ? 1 : (o.firstGap ?? 0)
|
|
192
|
+
const body = children.map((c, k) => `${blankLines(c, k ? 1 : first)}${serializeBlock(c)}\n`).join("")
|
|
193
|
+
return `${open}\n${body}${"\n".repeat(gapEnd ?? o.endGap ?? 0)}${close}`
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Items (tabs, steps, columns, updates) after their blank lines: none before the first, one between. */
|
|
197
|
+
function items<T extends { gap?: number }>(list: T[], write: (item: T) => string): string {
|
|
198
|
+
return list.map((it, k) => `${"\n".repeat(it.gap ?? (k ? 1 : 0))}${write(it)}\n`).join("")
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Pads each line holding more than whitespace (`all`: every non-empty line). */
|
|
202
|
+
function indent(s: string, pad: string, all = false): string {
|
|
21
203
|
return s
|
|
22
204
|
.split("\n")
|
|
23
|
-
.map((l) => (l ? pad + l : l))
|
|
205
|
+
.map((l) => ((all ? l : l.trim()) ? pad + l : l))
|
|
24
206
|
.join("\n")
|
|
25
207
|
}
|
|
26
208
|
|
|
209
|
+
/** Canonical JSON (sorted keys, layout-only fields dropped): do two nodes say the same thing? */
|
|
210
|
+
function canonical(value: unknown): string {
|
|
211
|
+
return JSON.stringify(value, (key, v) => {
|
|
212
|
+
if (key === "gap" || key === "gapEnd" || key === "raw" || key === "blanks") return undefined
|
|
213
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
214
|
+
return Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : 1)))
|
|
215
|
+
}
|
|
216
|
+
return v
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** `raw` if it still parses to (a block equal to) `node`, else `fallback`. */
|
|
221
|
+
function reuse(raw: string | undefined, node: Block, fallback: () => string): string {
|
|
222
|
+
if (raw !== undefined) {
|
|
223
|
+
const again = parseBlocks(raw.split("\n"))
|
|
224
|
+
if (again.length === 1 && canonical(again[0]) === canonical(node)) return raw
|
|
225
|
+
}
|
|
226
|
+
return fallback()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The line `serializeMarkdown` writes for a definition. */
|
|
230
|
+
export function definitionLine(b: DefinitionNode): string {
|
|
231
|
+
return `[${b.label}]: ${b.url}${b.title !== undefined && (b.title || !b.label.startsWith("^")) ? ` "${b.title}"` : ""}`
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The `<figure>` written for a figure without usable source lines. */
|
|
235
|
+
export function figureMarkdown(b: FigureNode): string {
|
|
236
|
+
const alt = b.alt ? ` alt="${b.alt}"` : ' alt=""'
|
|
237
|
+
const cap = b.caption ? `<figcaption><p>${b.caption}</p></figcaption>` : "<figcaption></figcaption>"
|
|
238
|
+
return `<figure><img src="${b.src}"${alt}>${cap}</figure>`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The GFM delimiter row written for `columns` columns aligned per `align`. */
|
|
242
|
+
export function defaultDelimiterRow(columns: number, align?: TableNode["align"]): string {
|
|
243
|
+
const cell = (k: number) => {
|
|
244
|
+
const a = align?.[k]
|
|
245
|
+
return a === "left" ? ":---" : a === "center" ? ":---:" : a === "right" ? "---:" : "---"
|
|
246
|
+
}
|
|
247
|
+
return `| ${Array.from({ length: columns }, (_, k) => cell(k)).join(" | ")} |`
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Column alignment from a GFM delimiter row. */
|
|
251
|
+
export function delimiterAlign(row: string): Array<"left" | "center" | "right" | null> {
|
|
252
|
+
return row
|
|
253
|
+
.trim()
|
|
254
|
+
.replace(/^\|/, "")
|
|
255
|
+
.replace(/\|$/, "")
|
|
256
|
+
.split("|")
|
|
257
|
+
.map((cell) => {
|
|
258
|
+
const c = cell.trim()
|
|
259
|
+
const left = c.startsWith(":")
|
|
260
|
+
const right = c.endsWith(":") && c.length > 1
|
|
261
|
+
return left && right ? "center" : left ? "left" : right ? "right" : null
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The info string written after a fence. */
|
|
266
|
+
export function fenceInfo(b: CodeBlockNode): string {
|
|
267
|
+
// Line numbers default to on for titled fences, off for untitled ones.
|
|
268
|
+
const numbersByDefault = b.title !== null && b.title !== undefined && b.title !== ""
|
|
269
|
+
const attrs = [
|
|
270
|
+
b.title ? attr("title", b.title) : "",
|
|
271
|
+
b.lineNumbers !== numbersByDefault || b.lineNumbersExplicit ? attr("lineNumbers", b.lineNumbers) : "",
|
|
272
|
+
b.live ? attr("live", true) : "",
|
|
273
|
+
b.entry ? attr("entry", b.entry) : "",
|
|
274
|
+
b.collapsedCodeLines ? attr("collapsedCodeLines", b.collapsedCodeLines) : "",
|
|
275
|
+
b.expandedCodeLines ? attr("expandedCodeLines", b.expandedCodeLines) : "",
|
|
276
|
+
].join("")
|
|
277
|
+
return `${b.language ?? ""}${b.language ? attrs : attrs.trimStart()}`
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The fence settings a code block says, for comparing a recorded info string with the node. */
|
|
281
|
+
function fenceSettings(b: CodeBlockNode): string {
|
|
282
|
+
return canonical([b.language || null, b.title || null, !!b.lineNumbers, !!b.live, b.entry || null, b.collapsedCodeLines ?? null, b.expandedCodeLines ?? null])
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function serializeCode(b: CodeBlockNode): string {
|
|
286
|
+
if (b.indented && fenceSettings(b) === fenceSettings({ ...b, language: null, title: null, lineNumbers: false })) {
|
|
287
|
+
return indent(b.code, " ", true)
|
|
288
|
+
}
|
|
289
|
+
// The fence as written (indent, `~~~`, longer runs), unless a content line would close it.
|
|
290
|
+
const written = b.fence?.match(/^([ \t]*)(`{3,}|~{3,})$/)
|
|
291
|
+
const usable = written && !b.code.split("\n").some((l) => l.trim().startsWith(written[2]))
|
|
292
|
+
const open = usable ? b.fence! : "```"
|
|
293
|
+
const chars = open.trim()
|
|
294
|
+
let info = fenceInfo(b)
|
|
295
|
+
if (b.info !== undefined) {
|
|
296
|
+
const again = parseBlocks([`${chars}${b.info}`, chars])[0]
|
|
297
|
+
if (again?.type === "code" && fenceSettings(again) === fenceSettings(b) && !!again.lineNumbersExplicit === !!b.lineNumbersExplicit) info = b.info
|
|
298
|
+
}
|
|
299
|
+
// An unclosed fence (the input ended first) stays unclosed.
|
|
300
|
+
if (b.closingFence === "") return `${open}${info}\n${b.code}`
|
|
301
|
+
const close = b.closingFence !== undefined && b.closingFence.trim().startsWith(chars) ? b.closingFence : open
|
|
302
|
+
return `${open}${info}\n${b.code}\n${close}`
|
|
303
|
+
}
|
|
304
|
+
|
|
27
305
|
function serializeBlock(b: Block): string {
|
|
28
306
|
switch (b.type) {
|
|
29
307
|
case "paragraph":
|
|
30
|
-
return serializeInline(b.children)
|
|
308
|
+
return `${b.indent && /^[ \t]*$/.test(b.indent) ? b.indent : ""}${serializeInline(b.children)}`
|
|
31
309
|
|
|
32
310
|
case "heading": {
|
|
33
311
|
const inline = serializeInline(b.children)
|
|
312
|
+
if (b.setext && inline) return `${inline}\n${b.setext}`
|
|
34
313
|
return inline ? `${"#".repeat(b.level)} ${inline}` : "#".repeat(b.level)
|
|
35
314
|
}
|
|
36
315
|
|
|
37
|
-
case "code":
|
|
38
|
-
|
|
39
|
-
const numbersByDefault = b.title !== null && b.title !== undefined && b.title !== ""
|
|
40
|
-
const attrs = [
|
|
41
|
-
b.title ? attr("title", b.title) : "",
|
|
42
|
-
b.lineNumbers !== numbersByDefault || b.lineNumbersExplicit ? attr("lineNumbers", b.lineNumbers) : "",
|
|
43
|
-
b.live ? attr("live", true) : "",
|
|
44
|
-
b.entry ? attr("entry", b.entry) : "",
|
|
45
|
-
b.collapsedCodeLines ? attr("collapsedCodeLines", b.collapsedCodeLines) : "",
|
|
46
|
-
b.expandedCodeLines ? attr("expandedCodeLines", b.expandedCodeLines) : "",
|
|
47
|
-
].join("")
|
|
48
|
-
const info = `${b.language ?? ""}${b.language ? attrs : attrs.trimStart()}`
|
|
49
|
-
return `\`\`\`${info}\n${b.code}\n\`\`\``
|
|
50
|
-
}
|
|
316
|
+
case "code":
|
|
317
|
+
return reuse(b.raw, b, () => serializeCode(b))
|
|
51
318
|
|
|
52
|
-
case "hint":
|
|
53
|
-
|
|
319
|
+
case "hint": {
|
|
320
|
+
const [open, close] = tagsFor(b)
|
|
321
|
+
return container(open, b.children, b.gapEnd, close)
|
|
322
|
+
}
|
|
54
323
|
|
|
55
324
|
case "tabs": {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return
|
|
62
|
-
.map((t) => `{% tab${attr("title", t.title)} %}\n${serializeBlocks(t.children)}\n{% endtab %}`)
|
|
63
|
-
.join("\n\n")}\n{% endtabs %}`
|
|
325
|
+
const [open, close] = tagsFor(b)
|
|
326
|
+
const tabs = items(b.tabs, (t) => {
|
|
327
|
+
const [tabOpen, tabClose] = tagsFor(t)
|
|
328
|
+
return container(tabOpen, t.children, t.gapEnd, tabClose)
|
|
329
|
+
})
|
|
330
|
+
return `${open}\n${tabs}${"\n".repeat(b.gapEnd ?? 0)}${close}`
|
|
64
331
|
}
|
|
65
332
|
|
|
66
|
-
case "command":
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
333
|
+
case "command":
|
|
334
|
+
return reuse(b.raw, b, () => serializeCommand(b))
|
|
335
|
+
|
|
336
|
+
case "math":
|
|
337
|
+
return reuse(b.raw, b, () => `$$\n${b.formula}\n$$`)
|
|
338
|
+
|
|
339
|
+
case "openapi-operation":
|
|
340
|
+
return reuse(b.raw, b, () => serializeOpenApi(b))
|
|
341
|
+
|
|
342
|
+
case "demo":
|
|
343
|
+
return reuse(b.raw, b, () => serializeDemo(b))
|
|
344
|
+
|
|
345
|
+
case "expandable": {
|
|
346
|
+
const said = b.opening?.match(/<summary>(.*?)<\/summary>\s*$/i)
|
|
347
|
+
const opening =
|
|
348
|
+
b.opening !== undefined && /^\s*<details>/i.test(b.opening) && (said ? said[1] === b.summary : !b.summary && !/<summary>/i.test(b.opening))
|
|
349
|
+
? b.opening
|
|
350
|
+
: `<details>\n\n<summary>${b.summary}</summary>`
|
|
351
|
+
const closing = b.closing !== undefined && /^\s*<\/details>\s*$/i.test(b.closing) ? b.closing : "</details>"
|
|
352
|
+
return container(opening, b.children, b.gapEnd, closing, { firstGap: 1, endGap: 1 })
|
|
74
353
|
}
|
|
75
354
|
|
|
76
|
-
case "
|
|
77
|
-
|
|
355
|
+
case "stepper": {
|
|
356
|
+
const [open, close] = tagsFor(b)
|
|
357
|
+
const steps = items(b.steps, (s) => {
|
|
358
|
+
const [stepOpen, stepClose] = tagsFor(s)
|
|
359
|
+
const head = stepHeading(s)
|
|
360
|
+
return container(`${stepOpen}${head ? `\n${head}` : ""}`, s.children, s.gapEnd, stepClose, { preceded: !!head })
|
|
361
|
+
})
|
|
362
|
+
return `${open}\n${steps}${"\n".repeat(b.gapEnd ?? 0)}${close}`
|
|
363
|
+
}
|
|
78
364
|
|
|
79
|
-
case "
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
365
|
+
case "columns": {
|
|
366
|
+
const [open, close] = tagsFor(b)
|
|
367
|
+
const columns = items(b.columns, (c) => {
|
|
368
|
+
const [columnOpen, columnClose] = tagsFor(c)
|
|
369
|
+
return container(columnOpen, c.children, c.gapEnd, columnClose)
|
|
370
|
+
})
|
|
371
|
+
return `${open}\n${columns}${"\n".repeat(b.gapEnd ?? 0)}${close}`
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
case "updates": {
|
|
375
|
+
const [open, close] = tagsFor(b)
|
|
376
|
+
const updates = items(b.updates, (u) => {
|
|
377
|
+
const [updateOpen, updateClose] = tagsFor(u)
|
|
378
|
+
return container(updateOpen, u.children, u.gapEnd, updateClose)
|
|
379
|
+
})
|
|
380
|
+
return `${open}\n${updates}${"\n".repeat(b.gapEnd ?? 0)}${close}`
|
|
381
|
+
}
|
|
86
382
|
|
|
87
383
|
case "embed": {
|
|
88
384
|
const attrs = [
|
|
@@ -94,11 +390,11 @@ function serializeBlock(b: Block): string {
|
|
|
94
390
|
b.muted === undefined ? "" : attr("muted", b.muted),
|
|
95
391
|
b.controls === undefined ? "" : attr("controls", b.controls),
|
|
96
392
|
].join("")
|
|
97
|
-
return `{% embed${attrs} %}`
|
|
393
|
+
return reuse(b.raw, b, () => `{% embed${attrs} %}`)
|
|
98
394
|
}
|
|
99
395
|
|
|
100
396
|
case "content-ref":
|
|
101
|
-
return `{% content-ref${attr("url", b.url)} %}\n${serializeInline(b.children)}\n{% endcontent-ref %}`
|
|
397
|
+
return reuse(b.raw, b, () => `{% content-ref${attr("url", b.url)} %}\n${serializeInline(b.children)}\n{% endcontent-ref %}`)
|
|
102
398
|
|
|
103
399
|
case "source-ref": {
|
|
104
400
|
const attrs = [
|
|
@@ -108,93 +404,117 @@ function serializeBlock(b: Block): string {
|
|
|
108
404
|
attr("kind", b.kind),
|
|
109
405
|
b.title ? attr("title", b.title) : "",
|
|
110
406
|
].join("")
|
|
111
|
-
return `{% source-ref${attrs} %}`
|
|
407
|
+
return reuse(b.raw, b, () => `{% source-ref${attrs} %}`)
|
|
112
408
|
}
|
|
113
409
|
|
|
114
|
-
case "
|
|
115
|
-
|
|
116
|
-
attr("src", b.src),
|
|
117
|
-
b.title ? attr("title", b.title) : "",
|
|
118
|
-
b.description ? attr("description", b.description) : "",
|
|
119
|
-
b.height ? attr("height", b.height) : "",
|
|
120
|
-
b.layout ? attr("layout", b.layout) : "",
|
|
121
|
-
b.variants?.length
|
|
122
|
-
? attr("variants", b.variants.map((v) => (v.label === v.id ? v.id : `${v.id}:${v.label}`)).join(","))
|
|
123
|
-
: "",
|
|
124
|
-
b.viewport ? attr("viewport", b.viewport) : "",
|
|
125
|
-
b.entry ? attr("entry", b.entry) : "",
|
|
126
|
-
b.status ? attr("status", b.status) : "",
|
|
127
|
-
b.bleed === undefined ? "" : attr("bleed", b.bleed),
|
|
128
|
-
b.className ? attr("className", b.className) : "",
|
|
129
|
-
b.surface ? attr("surface", b.surface) : "",
|
|
130
|
-
b.variantsWidth ? attr("variantsWidth", b.variantsWidth) : "",
|
|
131
|
-
].join("")
|
|
132
|
-
return `{% demo${attrs} %}${serializeDemoFiles(b)}`
|
|
133
|
-
}
|
|
410
|
+
case "figure":
|
|
411
|
+
return reuse(b.raw, b, () => figureMarkdown(b))
|
|
134
412
|
|
|
135
|
-
case "
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
.join("\n\n")}\n{% endcolumns %}`
|
|
139
|
-
|
|
140
|
-
case "figure": {
|
|
141
|
-
const alt = b.alt ? ` alt="${b.alt}"` : ' alt=""'
|
|
142
|
-
const cap = b.caption ? `<figcaption><p>${b.caption}</p></figcaption>` : "<figcaption></figcaption>"
|
|
143
|
-
return `<figure><img src="${b.src}"${alt}>${cap}</figure>`
|
|
413
|
+
case "list": {
|
|
414
|
+
const list = b.items.map((item, idx) => `${idx ? "\n" : ""}${blankLines(item, 0)}${serializeListItem(item, b, idx)}`).join("")
|
|
415
|
+
return b.indent && /^[ \t]*$/.test(b.indent) ? indent(list, b.indent) : list
|
|
144
416
|
}
|
|
145
417
|
|
|
146
|
-
case "list":
|
|
147
|
-
return b.items.map((item, idx) => serializeListItem(item, b.ordered, b.task, idx)).join("\n")
|
|
148
|
-
|
|
149
418
|
case "blockquote":
|
|
150
|
-
return serializeBlocks(b.children)
|
|
419
|
+
return `${serializeBlocks(b.children)}${"\n".repeat(b.gapEnd ?? 0)}`
|
|
151
420
|
.split("\n")
|
|
152
|
-
.map((l) => (l ? `> ${l}` : ">"))
|
|
421
|
+
.map((l, k, all) => (b.markers?.length === all.length ? b.markers[k] + l : l ? `> ${l}` : ">"))
|
|
153
422
|
.join("\n")
|
|
154
423
|
|
|
155
424
|
case "divider":
|
|
156
|
-
return "---"
|
|
425
|
+
return b.marker && /^(?:-{3,}|\*{3,}|_{3,})$/.test(b.marker) ? b.marker : "---"
|
|
157
426
|
|
|
158
427
|
case "table": {
|
|
159
|
-
if (b.view) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
428
|
+
if (b.view || b.html) {
|
|
429
|
+
return reuse(b.raw, b, () => {
|
|
430
|
+
const cellHtml = (c: Inline[]) => inlineToHtml(c)
|
|
431
|
+
const head = `<thead><tr>${b.header.map((c) => `<th>${cellHtml(c)}</th>`).join("")}</tr></thead>`
|
|
432
|
+
const body = `<tbody>${b.rows
|
|
433
|
+
.map((r) => `<tr>${r.map((c) => `<td>${cellHtml(c)}</td>`).join("")}</tr>`)
|
|
434
|
+
.join("")}</tbody>`
|
|
435
|
+
return `<table${b.view ? ` data-view="${b.view}"` : ""}>${head}${body}</table>`
|
|
436
|
+
})
|
|
166
437
|
}
|
|
167
|
-
//
|
|
168
|
-
const cell = (c: Inline[]) => serializeInline(c
|
|
438
|
+
// Pipes inside cells are escaped so they don't break the GFM row (unless a code span wrote them bare).
|
|
439
|
+
const cell = (c: Inline[]) => serializeInline(c, { tableCell: true })
|
|
169
440
|
// An empty cell is written `| |`, as authors do.
|
|
170
441
|
const row = (cells: Inline[][]) => `|${cells.map((c) => ` ${cell(c)} `.replace(/^ $/, " ")).join("|")}|`
|
|
171
|
-
const
|
|
172
|
-
|
|
442
|
+
const columns = b.header.length
|
|
443
|
+
const align = b.align?.some((a) => a) ? b.align : undefined
|
|
444
|
+
const recorded = b.delimiterRow
|
|
445
|
+
const sep =
|
|
446
|
+
recorded !== undefined &&
|
|
447
|
+
/^\s*\|[\s:|-]+\|\s*$/.test(recorded) &&
|
|
448
|
+
canonical(delimiterAlign(recorded)) === canonical(Array.from({ length: columns }, (_, k) => align?.[k] ?? null))
|
|
449
|
+
? recorded
|
|
450
|
+
: defaultDelimiterRow(columns, align)
|
|
451
|
+
// Rows as written (padded columns, no outer pipes…) while they still say the same cells.
|
|
452
|
+
const written = (cells: Inline[][], k: number) => {
|
|
453
|
+
const raw = b.rawRows?.[k]
|
|
454
|
+
if (raw !== undefined) {
|
|
455
|
+
const again = parseBlocks([raw, defaultDelimiterRow(Math.max(cells.length, 1))])[0]
|
|
456
|
+
if (again?.type === "table" && canonical(again.header) === canonical(cells)) return raw
|
|
457
|
+
}
|
|
458
|
+
return row(cells)
|
|
459
|
+
}
|
|
460
|
+
return [written(b.header, 0), sep, ...b.rows.map((r, k) => written(r, k + 1))].join("\n")
|
|
173
461
|
}
|
|
174
462
|
|
|
175
|
-
case "
|
|
176
|
-
return
|
|
463
|
+
case "raw":
|
|
464
|
+
return b.markdown
|
|
177
465
|
|
|
178
|
-
case "
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
case "openapi-operation": {
|
|
187
|
-
const attrs = [
|
|
188
|
-
b.spec ? attr("spec", b.spec) : "",
|
|
189
|
-
b.path ? attr("path", b.path) : "",
|
|
190
|
-
b.method ? attr("method", b.method) : "",
|
|
191
|
-
].join("")
|
|
192
|
-
const inner = b.specUrl ? `\n[${b.label || b.spec || "OpenAPI"}](${b.specUrl})` : ""
|
|
193
|
-
return `{% openapi-operation${attrs} %}${inner}\n{% endopenapi-operation %}`
|
|
466
|
+
case "definition": {
|
|
467
|
+
// A footnote definition follows the document's citation, which the editor may have changed.
|
|
468
|
+
const cite = b.label.startsWith("^") ? documentCitations?.get(b.label.slice(1)) : undefined
|
|
469
|
+
const node: DefinitionNode = cite
|
|
470
|
+
? { type: "definition", label: b.label, url: cite.url, ...(cite.label ? { title: cite.label } : {}), ...(b.raw ? { raw: b.raw } : {}) }
|
|
471
|
+
: b
|
|
472
|
+
return reuse(node.raw, node, () => definitionLine(node))
|
|
194
473
|
}
|
|
195
474
|
}
|
|
196
475
|
}
|
|
197
476
|
|
|
477
|
+
function serializeCommand(b: CommandNode): string {
|
|
478
|
+
const attrs = [
|
|
479
|
+
...(["pnpm", "yarn", "bun"] as const).map((pm) => (b.overrides?.[pm] ? attr(pm, b.overrides[pm]) : "")),
|
|
480
|
+
b.sync && b.sync !== "pm" ? attr("sync", b.sync) : "",
|
|
481
|
+
].join("")
|
|
482
|
+
return b.command.includes("\n")
|
|
483
|
+
? `{% command${attrs} %}\n${b.command}\n{% endcommand %}`
|
|
484
|
+
: `{% command${attrs} %}${b.command}{% endcommand %}`
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function serializeDemo(b: DemoNode): string {
|
|
488
|
+
const attrs = [
|
|
489
|
+
attr("src", b.src),
|
|
490
|
+
b.title ? attr("title", b.title) : "",
|
|
491
|
+
b.description ? attr("description", b.description) : "",
|
|
492
|
+
b.height ? attr("height", b.height) : "",
|
|
493
|
+
b.layout ? attr("layout", b.layout) : "",
|
|
494
|
+
b.variants?.length
|
|
495
|
+
? attr("variants", b.variants.map((v) => (v.label === v.id ? v.id : `${v.id}:${v.label}`)).join(","))
|
|
496
|
+
: "",
|
|
497
|
+
b.viewport ? attr("viewport", b.viewport) : "",
|
|
498
|
+
b.entry ? attr("entry", b.entry) : "",
|
|
499
|
+
b.status ? attr("status", b.status) : "",
|
|
500
|
+
b.bleed === undefined ? "" : attr("bleed", b.bleed),
|
|
501
|
+
b.className ? attr("className", b.className) : "",
|
|
502
|
+
b.surface ? attr("surface", b.surface) : "",
|
|
503
|
+
b.variantsWidth ? attr("variantsWidth", b.variantsWidth) : "",
|
|
504
|
+
].join("")
|
|
505
|
+
return `{% demo${attrs} %}${serializeDemoFiles(b)}`
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function serializeOpenApi(b: OpenApiOperationNode): string {
|
|
509
|
+
const attrs = [
|
|
510
|
+
b.spec ? attr("spec", b.spec) : "",
|
|
511
|
+
b.path ? attr("path", b.path) : "",
|
|
512
|
+
b.method ? attr("method", b.method) : "",
|
|
513
|
+
].join("")
|
|
514
|
+
const inner = b.specUrl ? `\n[${b.label || b.spec || "OpenAPI"}](${b.specUrl})` : ""
|
|
515
|
+
return `{% openapi-operation${attrs} %}${inner}\n{% endopenapi-operation %}`
|
|
516
|
+
}
|
|
517
|
+
|
|
198
518
|
function inlineToHtml(nodes: Inline[]): string {
|
|
199
519
|
return nodes
|
|
200
520
|
.map((n) => {
|
|
@@ -215,13 +535,13 @@ function inlineToHtml(nodes: Inline[]): string {
|
|
|
215
535
|
.join("")
|
|
216
536
|
}
|
|
217
537
|
|
|
218
|
-
function serializeListItem(item: ListItemNode,
|
|
219
|
-
const
|
|
220
|
-
const check = task ? `[${item.checked ? "x" : " "}] ` : ""
|
|
538
|
+
function serializeListItem(item: ListItemNode, list: ListNode, idx: number): string {
|
|
539
|
+
const marker = item.marker ?? (list.ordered ? `${(list.start ?? 1) + idx}${list.delimiter ?? "."}` : (list.bullet ?? "-"))
|
|
540
|
+
const check = list.task ? `[${item.checked ? (item.checkMark ?? "x") : " "}] ` : ""
|
|
221
541
|
const [first, ...rest] = item.children
|
|
222
542
|
const firstText = first?.type === "paragraph" ? serializeInline(first.children) : first ? serializeBlock(first) : ""
|
|
223
|
-
const restText = rest.length ? "\n" + indent(serializeBlocks(rest), "
|
|
224
|
-
return `${
|
|
543
|
+
const restText = rest.length ? "\n" + indent(serializeBlocks(rest), " ".repeat(item.indent ?? 2)) : ""
|
|
544
|
+
return `${marker}${" ".repeat(item.pad ?? 1)}${check}${firstText}${restText}`
|
|
225
545
|
}
|
|
226
546
|
|
|
227
547
|
/** A fence longer than any backtick run in the content, so nested fences survive. */
|