@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.
@@ -1,88 +1,384 @@
1
- import type { Block, DemoInlineFile, DemoNode, DocumentNode, Inline, ListItemNode } from "./ast"
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
- let out = serializeBlocks(doc.children).trimEnd()
7
- if (doc.citations?.length) {
8
- const defs = doc.citations
9
- .map((c) => `[^${c.id}]: ${c.url}${c.label ? ` "${c.label}"` : ""}`)
10
- .join("\n")
11
- out = out ? `${out}\n\n${defs}` : defs
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("\n\n")
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
- function indent(s: string, pad: string): string {
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
- // Line numbers default to on for titled fences, off for untitled ones.
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
- return `{% hint${attr("style", b.style)} %}\n${serializeBlocks(b.children)}\n{% endhint %}`
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 attrs = [
57
- b.title ? attr("title", b.title) : "",
58
- b.title && b.level && b.level !== 2 ? attr("level", b.level) : "",
59
- b.sync ? attr("sync", b.sync) : "",
60
- ].join("")
61
- return `{% tabs${attrs} %}\n${b.tabs
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
- const attrs = [
68
- ...(["pnpm", "yarn", "bun"] as const).map((pm) => (b.overrides?.[pm] ? attr(pm, b.overrides[pm]) : "")),
69
- b.sync && b.sync !== "pm" ? attr("sync", b.sync) : "",
70
- ].join("")
71
- return b.command.includes("\n")
72
- ? `{% command${attrs} %}\n${b.command}\n{% endcommand %}`
73
- : `{% command${attrs} %}${b.command}{% endcommand %}`
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 "expandable":
77
- return `<details>\n\n<summary>${b.summary}</summary>\n\n${serializeBlocks(b.children)}\n\n</details>`
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 "stepper":
80
- return `{% stepper %}\n${b.steps
81
- .map((s) => {
82
- const title = s.title ? `### ${s.title}\n\n` : ""
83
- return `{% step %}\n${title}${serializeBlocks(s.children)}\n{% endstep %}`
84
- })
85
- .join("\n\n")}\n{% endstepper %}`
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 "demo": {
115
- const attrs = [
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 "columns":
136
- return `{% columns %}\n${b.columns
137
- .map((c) => `{% column %}\n${serializeBlocks(c.children)}\n{% endcolumn %}`)
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
- const cellHtml = (c: Inline[]) => inlineToHtml(c)
161
- const head = `<thead><tr>${b.header.map((c) => `<th>${cellHtml(c)}</th>`).join("")}</tr></thead>`
162
- const body = `<tbody>${b.rows
163
- .map((r) => `<tr>${r.map((c) => `<td>${cellHtml(c)}</td>`).join("")}</tr>`)
164
- .join("")}</tbody>`
165
- return `<table data-view="${b.view}">${head}${body}</table>`
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
- // Escape pipes inside cell text so they don't break the GFM row.
168
- const cell = (c: Inline[]) => serializeInline(c).replace(/\|/g, "\\|")
169
- const row = (cells: Inline[][]) => `| ${cells.map(cell).join(" | ")} |`
170
- const sep = `| ${b.header.map(() => "---").join(" | ")} |`
171
- return [row(b.header), sep, ...b.rows.map(row)].join("\n")
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 "math":
175
- return `$$\n${b.formula}\n$$`
463
+ case "raw":
464
+ return b.markdown
176
465
 
177
- case "updates":
178
- return `{% updates${b.format ? attr("format", b.format) : ""} %}\n${b.updates
179
- .map(
180
- (u) =>
181
- `{% update${attr("date", u.date)} %}\n${serializeBlocks(u.children)}\n{% endupdate %}`
182
- )
183
- .join("\n\n")}\n{% endupdates %}`
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, ordered: boolean, task: boolean, idx: number): string {
218
- const bullet = ordered ? `${idx + 1}.` : "-"
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 `${bullet} ${check}${firstText}${restText}`
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. */