@brett_lamy/docstream 1.1.1 → 1.2.1

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.
@@ -11,6 +11,11 @@ export interface TextNode {
11
11
  strike?: boolean
12
12
  code?: boolean
13
13
  link?: string
14
+ /**
15
+ * The link sits inside the emphasis (`**[x](url)**`) rather than around it
16
+ * (`[**x**](url)`, the default). Kept so the source round-trips byte-for-byte.
17
+ */
18
+ linkInner?: true
14
19
  }
15
20
 
16
21
  /** Inline HTML image, optionally wrapped in a link — GitHub README badge style. */
@@ -94,6 +99,33 @@ export interface TabsNode {
94
99
  * the reader's last choice (matched by tab title), remembered across pages.
95
100
  */
96
101
  sync?: string
102
+ /**
103
+ * Section title (`{% tabs title="Installation" %}`). The tab set renders as a
104
+ * section: this title as a heading (with an anchor id), the tab switch as pill
105
+ * tabs right-aligned in that header, and the active tab's blocks below.
106
+ */
107
+ title?: string
108
+ /** Heading level of `title` (`level="3"`). Defaults to 2. */
109
+ level?: 2 | 3 | 4
110
+ }
111
+
112
+ export type PackageManager = "npm" | "pnpm" | "yarn" | "bun"
113
+
114
+ /**
115
+ * A package-manager command box: `{% command %}npm install x{% endcommand %}`.
116
+ * The author writes one canonical npm / npx command; renderers derive the pnpm,
117
+ * yarn and bun forms (see `packageManagerCommands`), unless overridden per
118
+ * manager (`pnpm="…"`). The reader's manager is synced page-wide (and across
119
+ * pages) under `sync` — `"pm"` by default, the same key install `{% tabs %}` use.
120
+ */
121
+ export interface CommandNode {
122
+ type: "command"
123
+ /** The npm / npx command (may span lines). */
124
+ command: string
125
+ /** Explicit commands for other managers, used verbatim instead of the derived ones. */
126
+ overrides?: Partial<Record<Exclude<PackageManager, "npm">, string>>
127
+ /** Sync key. Omitted means `"pm"`. */
128
+ sync?: string
97
129
  }
98
130
 
99
131
  export interface ExpandableNode {
@@ -156,11 +188,27 @@ export interface DemoVariantOption {
156
188
  label: string
157
189
  }
158
190
 
191
+ /** One source file carried inline by a `{% demo %}` block (a titled fence). */
192
+ export interface DemoInlineFile {
193
+ /** Path relative to the demo folder, from the fence's `title` (`index.tsx`, `parts/Card.tsx`). */
194
+ path: string
195
+ content: string
196
+ /** The fence's info-string language, when it has one. */
197
+ language?: string
198
+ }
199
+
159
200
  /**
160
- * A file-backed live demo: `{% demo src="<page>/<example>" %}`. The example
161
- * folder (resolved by the host's `DemoResolver`) is the authority for the
162
- * component, its source files, and its defaults; attributes here override the
163
- * folder's `meta.json`.
201
+ * A live demo: `{% demo src="<page>/<example>" %}`. With a host `DemoResolver`
202
+ * that knows `src`, the example folder is the authority for the component, its
203
+ * source files and its defaults; attributes here override the folder's
204
+ * `meta.json`. The block form carries the files inline so the Markdown renders
205
+ * anywhere, resolver or not:
206
+ *
207
+ * {% demo src="composer/scroll-fab" title="Scroll → FAB" %}
208
+ * ```tsx title="index.tsx"
209
+ * …
210
+ * ```
211
+ * {% enddemo %}
164
212
  */
165
213
  export interface DemoNode {
166
214
  type: "demo"
@@ -175,6 +223,16 @@ export interface DemoNode {
175
223
  variants?: DemoVariantOption[]
176
224
  /** Initial preview viewport for the multi-file viewer. */
177
225
  viewport?: DemoViewport
226
+ /** Entry file among the inline files (`entry="App.tsx"`). Defaults to the first file. */
227
+ entry?: string
228
+ /** Files carried inline by the block form, in document order. */
229
+ files?: DemoInlineFile[]
230
+ /**
231
+ * Set while streaming when the block's `{% enddemo %}` has not arrived yet: the
232
+ * files may still be growing, so renderers show code but hold off running it.
233
+ * Never serialized.
234
+ */
235
+ open?: true
178
236
  }
179
237
 
180
238
  export interface ColumnNode {
@@ -256,6 +314,7 @@ export type Block =
256
314
  | CodeBlockNode
257
315
  | HintNode
258
316
  | TabsNode
317
+ | CommandNode
259
318
  | ExpandableNode
260
319
  | StepperNode
261
320
  | EmbedNode
@@ -0,0 +1,47 @@
1
+ import type { Block, DocumentNode } from "./ast"
2
+ import { text } from "./ast"
3
+
4
+ /**
5
+ * docstream-only presentation blocks as plain Markdown, for LLM prompts and tools that don't
6
+ * know docstream (`resolveDemosToMarkdown(…, { format: "plain" })`):
7
+ *
8
+ * - `{% command %}` → a ```` ```sh ```` fence with the npm command;
9
+ * - titled `{% tabs title="Installation" %}` → a `## Installation` heading, then each tab as a
10
+ * bold label followed by its blocks.
11
+ *
12
+ * Everything else is kept (GitBook tags included — they're GitBook, not docstream).
13
+ */
14
+ export function flattenForPlainMarkdown(doc: DocumentNode): DocumentNode {
15
+ return { ...doc, children: flattenBlocks(doc.children) }
16
+ }
17
+
18
+ export function flattenBlocks(blocks: Block[]): Block[] {
19
+ return blocks.flatMap((b): Block[] => {
20
+ switch (b.type) {
21
+ case "command":
22
+ return [{ type: "code", language: "sh", title: null, lineNumbers: true, code: b.command }]
23
+ case "tabs":
24
+ if (b.title) {
25
+ return [
26
+ { type: "heading", level: b.level ?? 2, children: [text(b.title)] },
27
+ ...b.tabs.flatMap((t): Block[] => [{ type: "paragraph", children: [text(t.title, { bold: true })] }, ...flattenBlocks(t.children)]),
28
+ ]
29
+ }
30
+ return [{ ...b, tabs: b.tabs.map((t) => ({ ...t, children: flattenBlocks(t.children) })) }]
31
+ case "hint":
32
+ case "expandable":
33
+ case "blockquote":
34
+ return [{ ...b, children: flattenBlocks(b.children) }]
35
+ case "stepper":
36
+ return [{ ...b, steps: b.steps.map((s) => ({ ...s, children: flattenBlocks(s.children) })) }]
37
+ case "columns":
38
+ return [{ ...b, columns: b.columns.map((c) => ({ ...c, children: flattenBlocks(c.children) })) }]
39
+ case "list":
40
+ return [{ ...b, items: b.items.map((item) => ({ ...item, children: flattenBlocks(item.children) })) }]
41
+ case "updates":
42
+ return [{ ...b, updates: b.updates.map((u) => ({ ...u, children: flattenBlocks(u.children) })) }]
43
+ default:
44
+ return [b]
45
+ }
46
+ })
47
+ }
@@ -1,6 +1,10 @@
1
1
  // Pure GitBook markdown engine (no React, no mermaid/streamdown). Safe to import
2
2
  // in non-DOM environments such as a server-side AI agent or a Cloudflare Worker.
3
3
  export type * from "./ast"
4
- export { parseDemoVariants, parseMarkdown, parseBlocks, trimPartialInlineToken } from "./parse"
5
- export { serializeBlocks, serializeMarkdown } from "./serialize"
4
+ export { closesFence, demoBody, fenceOpener, fenceTracker, parseDemoVariants, parseMarkdown, parseBlocks, trimPartialInlineToken } from "./parse"
5
+ export { fenceFor, serializeBlocks, serializeDemoFile, serializeMarkdown } from "./serialize"
6
6
  export { footnoteDefinitions, parseInline, plainText, refDefinitions, serializeInline, serializeReference } from "./inline"
7
+ export { PACKAGE_MANAGERS, packageManagerCommands } from "./package-managers"
8
+ export { flattenBlocks, flattenForPlainMarkdown } from "./flatten"
9
+ export { documentOutline, slugify } from "./outline"
10
+ export type { OutlineEntry } from "./outline"
@@ -155,7 +155,8 @@ export function parseInline(src: string, marks: Marks = {}): Inline[] {
155
155
  const m = rest.match(/^\[([^\]]*)\]\(([^)\s]+)\)/)
156
156
  if (m) {
157
157
  flush()
158
- out.push(...parseInline(m[1], { ...marks, link: m[2] }))
158
+ const inner = marks.bold || marks.italic || marks.strike ? { linkInner: true as const } : {}
159
+ out.push(...parseInline(m[1], { ...marks, link: m[2], ...inner }))
159
160
  i += m[0].length
160
161
  continue
161
162
  }
@@ -228,25 +229,54 @@ function serializeImage(n: InlineImageNode): string {
228
229
  return n.link ? `<a href="${n.link}">${img}</a>` : img
229
230
  }
230
231
 
231
- // Serializes inline nodes back to markdown.
232
+ // Serializes inline nodes back to markdown. Emphasis (bold / italic / strike) is kept open across
233
+ // adjacent runs that share it, so `**bold [link](url) inside**` — three runs under one bold — comes back
234
+ // as written instead of `**bold ****[link](url)**** inside**`.
235
+ type Emphasis = "strike" | "italic" | "bold"
236
+ const EMPHASIS: Emphasis[] = ["strike", "italic", "bold"] // outermost → innermost
237
+ const MARKER: Record<Emphasis, string> = { strike: "~~", italic: "_", bold: "**" }
238
+
239
+ function textBody(n: TextNode): string {
240
+ const s = n.code ? n.text : escapeText(n.text)
241
+ return n.code ? `\`${s}\`` : s
242
+ }
243
+
232
244
  export function serializeInline(nodes: Inline[]): string {
233
- return nodes
234
- .map((n) => {
235
- if (n.type === "image") return serializeImage(n)
236
- if (n.type === "reference") return serializeReference(n)
237
- // bare autolink: text identical to the URL, no other marks
238
- if (n.link && n.text === n.link && !n.bold && !n.italic && !n.strike && !n.code) {
239
- return n.link
240
- }
241
- let s = n.code ? n.text : escapeText(n.text)
242
- if (n.code) s = `\`${s}\``
243
- if (n.bold) s = `**${s}**`
244
- if (n.italic) s = `_${s}_`
245
- if (n.strike) s = `~~${s}~~`
246
- if (n.link) s = `[${s}](${n.link})`
247
- return s
248
- })
249
- .join("")
245
+ let out = ""
246
+ const open: Emphasis[] = []
247
+ const closeTo = (depth: number) => {
248
+ while (open.length > depth) out += MARKER[open.pop()!]
249
+ }
250
+ for (const n of nodes) {
251
+ if (n.type !== "text") {
252
+ closeTo(0)
253
+ out += n.type === "image" ? serializeImage(n) : serializeReference(n)
254
+ continue
255
+ }
256
+ // bare autolink: text identical to the URL, no other marks
257
+ if (n.link && n.text === n.link && !n.bold && !n.italic && !n.strike && !n.code) {
258
+ closeTo(0)
259
+ out += n.link
260
+ continue
261
+ }
262
+ const wanted = EMPHASIS.filter((m) => n[m])
263
+ // A link around its emphasis (`[**x**](url)`) is self-contained: close everything, wrap this run alone.
264
+ if (n.link && !(n.linkInner && wanted.length)) {
265
+ closeTo(0)
266
+ let s = textBody(n)
267
+ for (const m of [...wanted].reverse()) s = `${MARKER[m]}${s}${MARKER[m]}`
268
+ out += `[${s}](${n.link})`
269
+ continue
270
+ }
271
+ // Keep the longest prefix of open marks this run still wants, close the rest, open what's missing.
272
+ let keep = 0
273
+ while (keep < open.length && wanted.includes(open[keep])) keep++
274
+ closeTo(keep)
275
+ for (const m of wanted) if (!open.includes(m)) { open.push(m); out += MARKER[m] }
276
+ out += n.link ? `[${textBody(n)}](${n.link})` : textBody(n)
277
+ }
278
+ closeTo(0)
279
+ return out
250
280
  }
251
281
 
252
282
  export function plainText(nodes: Inline[]): string {
@@ -0,0 +1,45 @@
1
+ import type { Block, DocumentNode } from "./ast"
2
+ import { plainText } from "./inline"
3
+
4
+ /** Anchor id for a heading: `"Installation"` → `"installation"`. */
5
+ export function slugify(text: string): string {
6
+ return (
7
+ text
8
+ .toLowerCase()
9
+ .normalize("NFKD")
10
+ .replace(/[̀-ͯ]/g, "")
11
+ .replace(/[^a-z0-9]+/g, "-")
12
+ .replace(/^-+|-+$/g, "") || "section"
13
+ )
14
+ }
15
+
16
+ export interface OutlineEntry {
17
+ level: number
18
+ text: string
19
+ /** The anchor id docstream renders for titled tab sets; hosts may reuse it for headings. */
20
+ id: string
21
+ }
22
+
23
+ /**
24
+ * The page's headings for a table of contents — Markdown headings plus the titles of
25
+ * `{% tabs title="…" %}` sections — in document order (top level and inside hints,
26
+ * tabs, steppers, columns and expandables).
27
+ */
28
+ export function documentOutline(doc: DocumentNode | Block[]): OutlineEntry[] {
29
+ const out: OutlineEntry[] = []
30
+ const walk = (blocks: Block[]) => {
31
+ for (const b of blocks) {
32
+ if (b.type === "heading") {
33
+ const text = plainText(b.children)
34
+ if (text) out.push({ level: b.level, text, id: slugify(text) })
35
+ } else if (b.type === "tabs") {
36
+ if (b.title) out.push({ level: b.level ?? 2, text: b.title, id: slugify(b.title) })
37
+ for (const t of b.tabs) walk(t.children)
38
+ } else if (b.type === "hint" || b.type === "expandable") walk(b.children)
39
+ else if (b.type === "stepper") for (const s of b.steps) walk(s.children)
40
+ else if (b.type === "columns") for (const c of b.columns) walk(c.children)
41
+ }
42
+ }
43
+ walk(Array.isArray(doc) ? doc : doc.children)
44
+ return out
45
+ }
@@ -0,0 +1,98 @@
1
+ import type { CommandNode, PackageManager } from "./ast"
2
+
3
+ /** Display order of the command box's manager tabs. The first is the default choice. */
4
+ export const PACKAGE_MANAGERS: readonly PackageManager[] = ["pnpm", "npm", "yarn", "bun"]
5
+
6
+ type Others = Exclude<PackageManager, "npm">
7
+
8
+ const INSTALL = new Set(["install", "i", "add"])
9
+ const UNINSTALL = new Set(["uninstall", "un", "remove", "rm", "r"])
10
+ const LIFECYCLE = new Set(["test", "start", "stop", "restart"])
11
+
12
+ /** npm install flags → per-manager flags. Unknown flags pass through unchanged. */
13
+ const FLAGS: Record<string, Record<Others, string | null>> = {
14
+ "-D": { pnpm: "-D", yarn: "-D", bun: "-d" },
15
+ "--save-dev": { pnpm: "-D", yarn: "-D", bun: "-d" },
16
+ "-E": { pnpm: "-E", yarn: "-E", bun: "--exact" },
17
+ "--save-exact": { pnpm: "-E", yarn: "-E", bun: "--exact" },
18
+ "-O": { pnpm: "-O", yarn: "-O", bun: "--optional" },
19
+ "--save-optional": { pnpm: "-O", yarn: "-O", bun: "--optional" },
20
+ "-P": { pnpm: "-P", yarn: null, bun: null },
21
+ "--save-prod": { pnpm: "-P", yarn: null, bun: null },
22
+ "-S": { pnpm: null, yarn: null, bun: null },
23
+ "--save": { pnpm: null, yarn: null, bun: null },
24
+ }
25
+
26
+ function flags(args: string[], pm: Others): string[] {
27
+ return args.flatMap((arg) => {
28
+ const mapped = FLAGS[arg]
29
+ if (!mapped) return [arg]
30
+ const next = mapped[pm]
31
+ return next ? [next] : []
32
+ })
33
+ }
34
+
35
+ const join = (...parts: (string | string[])[]) => parts.flat().filter(Boolean).join(" ")
36
+
37
+ /** One npm / npx invocation (already split into words) for another manager, or null if it isn't one. */
38
+ function convertWords(words: string[], pm: Others): string | null {
39
+ const [head, sub, ...rest] = words
40
+ if (head === "npx") {
41
+ const args = words.slice(1).filter((w) => w !== "-y" && w !== "--yes")
42
+ return join(pm === "bun" ? "bunx" : `${pm} dlx`, args)
43
+ }
44
+ if (head !== "npm" || sub === undefined) return head === "npm" ? pm : null
45
+ if (sub === "exec" || sub === "x") {
46
+ const args = rest[0] === "--" ? rest.slice(1) : rest
47
+ return join(pm === "bun" ? "bunx" : `${pm} dlx`, args.filter((w) => w !== "-y" && w !== "--yes"))
48
+ }
49
+ if (INSTALL.has(sub)) {
50
+ const global = rest.some((w) => w === "-g" || w === "--global")
51
+ const args = rest.filter((w) => w !== "-g" && w !== "--global")
52
+ const packages = args.filter((w) => !w.startsWith("-"))
53
+ if (!packages.length && !global) return join(`${pm} install`, flags(args, pm))
54
+ if (global) return join(pm === "yarn" ? "yarn global add" : `${pm} add -g`, flags(args, pm))
55
+ return join(`${pm} add`, flags(args, pm))
56
+ }
57
+ if (sub === "ci") return `${pm} install --frozen-lockfile`
58
+ if (UNINSTALL.has(sub)) {
59
+ const global = rest.some((w) => w === "-g" || w === "--global")
60
+ const args = rest.filter((w) => w !== "-g" && w !== "--global")
61
+ return join(global ? (pm === "yarn" ? "yarn global remove" : `${pm} remove -g`) : `${pm} remove`, flags(args, pm))
62
+ }
63
+ if (sub === "run" || sub === "run-script") {
64
+ const [script, ...args] = rest
65
+ const tail = args[0] === "--" && pm !== "bun" ? args.slice(1) : args
66
+ return join(pm === "bun" ? "bun run" : pm, script ?? "", tail)
67
+ }
68
+ if (LIFECYCLE.has(sub)) return join(pm === "bun" ? "bun run" : pm, sub, rest[0] === "--" && pm !== "bun" ? rest.slice(1) : rest)
69
+ if (sub === "create" || (sub === "init" && rest.length && !rest[0].startsWith("-"))) return join(`${pm} create`, rest)
70
+ if (sub === "update" || sub === "up" || sub === "upgrade") return join(pm === "yarn" ? "yarn upgrade" : `${pm} update`, rest)
71
+ return join(pm, sub, rest)
72
+ }
73
+
74
+ /** One line (possibly `a && b`) for another manager. Lines that aren't npm/npx commands are kept. */
75
+ function convertLine(line: string, pm: Others): string {
76
+ const indent = line.match(/^\s*/)![0]
77
+ return indent + line.trim().split(/\s+&&\s+/).map((segment) => {
78
+ const prompt = segment.match(/^\$\s+/)?.[0] ?? ""
79
+ const words = segment.slice(prompt.length).split(/\s+/).filter(Boolean)
80
+ const converted = words.length ? convertWords(words, pm) : null
81
+ return converted === null ? segment : prompt + converted
82
+ }).join(" && ")
83
+ }
84
+
85
+ /**
86
+ * The pnpm / yarn / bun forms of an npm / npx command:
87
+ * `npm install x` → `pnpm add x`, `yarn add x`, `bun add x` (with `-D`, `-E`, `-g`, …);
88
+ * `npx x` → `pnpm dlx x`, `yarn dlx x`, `bunx x`; `npm run s` → `pnpm s`, `yarn s`, `bun run s`;
89
+ * `npm create x` → `<pm> create x`. Other lines (comments, `cd`, …) are kept as they are.
90
+ * `overrides` replace a manager's derived command verbatim.
91
+ */
92
+ export function packageManagerCommands(
93
+ npmCommand: string,
94
+ overrides: CommandNode["overrides"] = {},
95
+ ): Record<PackageManager, string> {
96
+ const derive = (pm: Others) => overrides[pm] ?? npmCommand.split("\n").map((line) => convertLine(line, pm)).join("\n")
97
+ return { npm: npmCommand, pnpm: derive("pnpm"), yarn: derive("yarn"), bun: derive("bun") }
98
+ }
@@ -1,6 +1,8 @@
1
1
  import type {
2
2
  Block,
3
3
  ColumnNode,
4
+ CommandNode,
5
+ DemoInlineFile,
4
6
  DemoLayout,
5
7
  DemoNode,
6
8
  DemoVariantOption,
@@ -92,13 +94,57 @@ function templateTag(line: string): TemplateTag | null {
92
94
  return { name: m[1], attrs: parseAttrs(m[2]) }
93
95
  }
94
96
 
97
+ interface Fence {
98
+ char: "`" | "~"
99
+ length: number
100
+ }
101
+
102
+ /** A fence opener (```` ```ts title="x" ```` or `~~~`), or null. Backtick info strings cannot contain backticks. */
103
+ export function fenceOpener(line: string): Fence | null {
104
+ const m = line.match(/^\s*(`{3,}|~{3,})(.*)$/)
105
+ if (!m) return null
106
+ if (m[1][0] === "`" && m[2].includes("`")) return null
107
+ return { char: m[1][0] as Fence["char"], length: m[1].length }
108
+ }
109
+
110
+ /** True when `line` closes `fence`: the same character, at least as many, nothing else. */
111
+ export function closesFence(line: string, fence: Fence): boolean {
112
+ const trimmed = line.trim()
113
+ return trimmed.length >= fence.length && trimmed === fence.char.repeat(trimmed.length)
114
+ }
115
+
116
+ /** Tracks whether a line-by-line scan is inside a fenced code block. */
117
+ export function fenceTracker() {
118
+ let open: Fence | null = null
119
+ return {
120
+ /** Feed the next line; returns true if the line is fence content or a fence delimiter. */
121
+ step(line: string): boolean {
122
+ if (open) {
123
+ if (closesFence(line, open)) open = null
124
+ return true
125
+ }
126
+ open = fenceOpener(line)
127
+ return open !== null
128
+ },
129
+ get inside() {
130
+ return open !== null
131
+ },
132
+ }
133
+ }
134
+
95
135
  // Collects the lines between an opening {% name %} and its matching
96
- // {% endname %}, honoring nesting of the same tag.
136
+ // {% endname %}, honoring nesting of the same tag. Tags inside fenced code
137
+ // (a demo file, a Markdown sample) are content, not structure.
97
138
  function collectUntil(lines: string[], start: number, name: string): { body: string[]; next: number } {
98
139
  const body: string[] = []
99
140
  let depth = 1
100
141
  let i = start
142
+ const fences = fenceTracker()
101
143
  for (; i < lines.length; i++) {
144
+ if (fences.step(lines[i])) {
145
+ body.push(lines[i])
146
+ continue
147
+ }
102
148
  const tag = templateTag(lines[i])
103
149
  if (tag?.name === name) depth++
104
150
  if (tag?.name === `end${name}`) {
@@ -131,11 +177,10 @@ export function parseMarkdown(src: string): DocumentNode {
131
177
  refDefinitions.clear()
132
178
  footnoteDefinitions.clear()
133
179
  const content: string[] = []
134
- let inFence = false
180
+ const fences = fenceTracker()
135
181
  for (const line of lines) {
136
182
  // Definition lines inside code fences are content, not definitions.
137
- if (/^\s*(?:```|~~~)/.test(line)) inFence = !inFence
138
- if (!inFence) {
183
+ if (!fences.step(line)) {
139
184
  // Footnote citation definitions first: [^id]: url "Optional Label"
140
185
  const foot = line.match(/^\[\^([^\]\s]+)\]:\s*(\S+)(?:\s+"([^"]*)")?\s*$/)
141
186
  if (foot) {
@@ -178,6 +223,97 @@ export function parseDemoVariants(raw: string): DemoVariantOption[] {
178
223
  .filter((v) => v.id)
179
224
  }
180
225
 
226
+ /** `{% command %}npm install x{% endcommand %}` on one line. */
227
+ const COMMAND_ONE_LINE_RE = /^\s*\{%\s*command(\s[^%]*?)?\s*%\}(.*?)\{%\s*endcommand\s*%\}\s*$/
228
+
229
+ function commandNode(attrs: Record<string, string>, raw: string): CommandNode {
230
+ const overrides: NonNullable<CommandNode["overrides"]> = {}
231
+ for (const pm of ["pnpm", "yarn", "bun"] as const) if (attrs[pm]) overrides[pm] = attrs[pm]
232
+ return {
233
+ type: "command",
234
+ command: raw.replace(/^(?:[ \t]*\n)+/, "").replace(/(?:\n[ \t]*)+$/, "").trim(),
235
+ ...(Object.keys(overrides).length ? { overrides } : {}),
236
+ ...(attrs.sync && attrs.sync !== "pm" ? { sync: attrs.sync } : {}),
237
+ }
238
+ }
239
+
240
+ const ENDDEMO_RE = /^\s*\{%\s*enddemo\s*%\}\s*$/
241
+
242
+ interface DemoBody {
243
+ files: DemoInlineFile[]
244
+ /** First line after the block. */
245
+ next: number
246
+ /** The input ended before `{% enddemo %}` (a block still streaming in). */
247
+ open: boolean
248
+ }
249
+
250
+ function fallbackPath(index: number, language: string | undefined): string {
251
+ const ext = language || "txt"
252
+ return index === 0 ? `index.${ext}` : `file-${index + 1}.${ext}`
253
+ }
254
+
255
+ /**
256
+ * The inline files of a `{% demo %}` block starting at `start` (the line after the tag).
257
+ *
258
+ * - `{% enddemo %}` right away: an empty block.
259
+ * - A fence right away: the block form. Fences (and blank lines) are files until
260
+ * `{% enddemo %}`; any other line ends the block too. Reaching the end of input
261
+ * first marks the block `open` (still streaming).
262
+ * - Blank lines, then fences: the block form only if `{% enddemo %}` closes it with
263
+ * nothing but fences in between — otherwise the tag is self-closing and the fences
264
+ * are ordinary code blocks, exactly as before 1.2.
265
+ * - Anything else: no body (self-closing tag).
266
+ */
267
+ export function demoBody(lines: string[], start: number, selfClosing = false): DemoBody | null {
268
+ let j = start
269
+ if (j < lines.length && ENDDEMO_RE.test(lines[j])) return { files: [], next: j + 1, open: false }
270
+ // `{% demo … /%}` takes no files (a directly following `{% enddemo %}` is tolerated above).
271
+ if (selfClosing) return null
272
+ const immediate = j < lines.length && fenceOpener(lines[j]) !== null
273
+ if (!immediate) {
274
+ while (j < lines.length && !lines[j].trim()) j++
275
+ if (j >= lines.length) return null
276
+ if (ENDDEMO_RE.test(lines[j])) return { files: [], next: j + 1, open: false }
277
+ if (!fenceOpener(lines[j])) return null
278
+ }
279
+ const files: DemoInlineFile[] = []
280
+ while (j < lines.length) {
281
+ const line = lines[j]
282
+ if (!line.trim()) {
283
+ j++
284
+ continue
285
+ }
286
+ if (ENDDEMO_RE.test(line)) return { files, next: j + 1, open: false }
287
+ const fence = fenceOpener(line)
288
+ if (!fence) return immediate ? { files, next: j, open: false } : null
289
+ const info = line.trim().slice(fence.length).trim()
290
+ // `tsx title="index.tsx"` or just `title="index.tsx"` (no language).
291
+ const first = info.match(/^[^\s`]*/)?.[0] ?? ""
292
+ const language = first.includes("=") ? "" : first
293
+ const rest = language ? info.slice(language.length) : info
294
+ const attrs = parseAttrs(rest)
295
+ const content: string[] = []
296
+ j++
297
+ let closed = false
298
+ while (j < lines.length) {
299
+ if (closesFence(lines[j], fence)) {
300
+ closed = true
301
+ j++
302
+ break
303
+ }
304
+ content.push(lines[j])
305
+ j++
306
+ }
307
+ files.push({
308
+ path: (attrs.title ?? "").trim() || fallbackPath(files.length, language || undefined),
309
+ content: content.join("\n"),
310
+ ...(language ? { language } : {}),
311
+ })
312
+ if (!closed) break
313
+ }
314
+ return immediate ? { files, next: j, open: true } : null
315
+ }
316
+
181
317
  function parseDemoTag(attrs: Record<string, string>): DemoNode {
182
318
  const variants = attrs.variants ? parseDemoVariants(attrs.variants) : []
183
319
  return {
@@ -189,6 +325,7 @@ function parseDemoTag(attrs: Record<string, string>): DemoNode {
189
325
  ...((DEMO_LAYOUTS as string[]).includes(attrs.layout) ? { layout: attrs.layout as DemoLayout } : {}),
190
326
  ...(variants.length ? { variants } : {}),
191
327
  ...((DEMO_VIEWPORTS as string[]).includes(attrs.viewport) ? { viewport: attrs.viewport as DemoViewport } : {}),
328
+ ...(attrs.entry ? { entry: attrs.entry } : {}),
192
329
  }
193
330
  }
194
331
 
@@ -201,6 +338,12 @@ export function trimPartialInlineToken(md: string): string {
201
338
  return md
202
339
  // A block tag still arriving (`{% demo src="butt`) would flash as a paragraph.
203
340
  .replace(/(^|\n)[ \t]*\{(?:%(?:(?!%\})[^\n])*)?$/, "$1")
341
+ // A one-line command still arriving (`{% command %}npm i x{% endcomm`): hold the line back
342
+ // until it closes. (The multi-line form, `{% command %}` alone on its line, renders as it grows.)
343
+ .replace(/(^|\n)[ \t]*\{%\s*command\b[^\n]*?%\}[ \t]*\S[^\n]*$/, (line: string, lead: string) =>
344
+ /\{%\s*endcommand\s*%\}\s*$/.test(line) ? line : lead)
345
+ // A fence still arriving (a lone ` or ``) would end a demo block and flash as text.
346
+ .replace(/(^|\n)[ \t]*(?:`{1,2}|~{1,2})$/, "$1")
204
347
  .replace(/\[\^[^\]]*$/, "")
205
348
  .replace(/(^|[\s([{])[@#][\w.-]*$/, "$1")
206
349
  .replace(/\\$/, "")
@@ -229,6 +372,14 @@ export function parseBlocks(lines: string[]): Block[] {
229
372
  continue
230
373
  }
231
374
 
375
+ const oneLine = line.match(COMMAND_ONE_LINE_RE)
376
+ if (oneLine) {
377
+ flushParagraph()
378
+ blocks.push(commandNode(parseAttrs(oneLine[1]), oneLine[2]))
379
+ i++
380
+ continue
381
+ }
382
+
232
383
  const tag = templateTag(line)
233
384
  if (tag) {
234
385
  flushParagraph()
@@ -245,7 +396,21 @@ export function parseBlocks(lines: string[]): Block[] {
245
396
 
246
397
  if (tag.name === "tabs") {
247
398
  const { body, next } = collectUntil(lines, i + 1, "tabs")
248
- blocks.push({ type: "tabs", tabs: parseTabs(body), ...(tag.attrs.sync ? { sync: tag.attrs.sync } : {}) })
399
+ const level = Number(tag.attrs.level)
400
+ blocks.push({
401
+ type: "tabs",
402
+ tabs: parseTabs(body),
403
+ ...(tag.attrs.sync ? { sync: tag.attrs.sync } : {}),
404
+ ...(tag.attrs.title ? { title: tag.attrs.title } : {}),
405
+ ...(tag.attrs.title && (level === 3 || level === 4) ? { level: level as 3 | 4 } : {}),
406
+ })
407
+ i = next
408
+ continue
409
+ }
410
+
411
+ if (tag.name === "command") {
412
+ const { body, next } = collectUntil(lines, i + 1, "command")
413
+ blocks.push(commandNode(tag.attrs, body.join("\n")))
249
414
  i = next
250
415
  continue
251
416
  }
@@ -328,10 +493,16 @@ export function parseBlocks(lines: string[]): Block[] {
328
493
  }
329
494
 
330
495
  if (tag.name === "demo") {
331
- blocks.push(parseDemoTag(tag.attrs))
496
+ const node = parseDemoTag(tag.attrs)
332
497
  i++
333
- // tolerate an optional {% enddemo %}
334
- if (i < lines.length && templateTag(lines[i])?.name === "enddemo") i++
498
+ // `{% demo … /%}` never has a body; otherwise look for inline files / {% enddemo %}.
499
+ const body = demoBody(lines, i, /\/\s*%\}\s*$/.test(line))
500
+ if (body) {
501
+ if (body.files.length) node.files = body.files
502
+ if (body.open) node.open = true
503
+ i = body.next
504
+ }
505
+ blocks.push(node)
335
506
  continue
336
507
  }
337
508