@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/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,92 +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
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
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 })
|
|
440
|
+
// An empty cell is written `| |`, as authors do.
|
|
441
|
+
const row = (cells: Inline[][]) => `|${cells.map((c) => ` ${cell(c)} `.replace(/^ $/, " ")).join("|")}|`
|
|
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")
|
|
172
461
|
}
|
|
173
462
|
|
|
174
|
-
case "
|
|
175
|
-
return
|
|
463
|
+
case "raw":
|
|
464
|
+
return b.markdown
|
|
176
465
|
|
|
177
|
-
case "
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
case "openapi-operation": {
|
|
186
|
-
const attrs = [
|
|
187
|
-
b.spec ? attr("spec", b.spec) : "",
|
|
188
|
-
b.path ? attr("path", b.path) : "",
|
|
189
|
-
b.method ? attr("method", b.method) : "",
|
|
190
|
-
].join("")
|
|
191
|
-
const inner = b.specUrl ? `\n[${b.label || b.spec || "OpenAPI"}](${b.specUrl})` : ""
|
|
192
|
-
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))
|
|
193
473
|
}
|
|
194
474
|
}
|
|
195
475
|
}
|
|
196
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
|
+
|
|
197
518
|
function inlineToHtml(nodes: Inline[]): string {
|
|
198
519
|
return nodes
|
|
199
520
|
.map((n) => {
|
|
@@ -214,13 +535,13 @@ function inlineToHtml(nodes: Inline[]): string {
|
|
|
214
535
|
.join("")
|
|
215
536
|
}
|
|
216
537
|
|
|
217
|
-
function serializeListItem(item: ListItemNode,
|
|
218
|
-
const
|
|
219
|
-
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") : " "}] ` : ""
|
|
220
541
|
const [first, ...rest] = item.children
|
|
221
542
|
const firstText = first?.type === "paragraph" ? serializeInline(first.children) : first ? serializeBlock(first) : ""
|
|
222
|
-
const restText = rest.length ? "\n" + indent(serializeBlocks(rest), "
|
|
223
|
-
return `${
|
|
543
|
+
const restText = rest.length ? "\n" + indent(serializeBlocks(rest), " ".repeat(item.indent ?? 2)) : ""
|
|
544
|
+
return `${marker}${" ".repeat(item.pad ?? 1)}${check}${firstText}${restText}`
|
|
224
545
|
}
|
|
225
546
|
|
|
226
547
|
/** A fence longer than any backtick run in the content, so nested fences survive. */
|