@brett_lamy/docstream 1.1.0 → 1.2.0

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,14 +1,33 @@
1
- import type { DemoNode } from "../gitbook/ast"
2
- import { parseMarkdown } from "../gitbook/parse"
1
+ import type { DemoInlineFile, DemoNode } from "../gitbook/ast"
2
+ import { flattenForPlainMarkdown } from "../gitbook/flatten"
3
+ import { demoBody, fenceTracker, parseBlocks, parseMarkdown } from "../gitbook/parse"
4
+ import { fenceFor, serializeBlocks, serializeMarkdown } from "../gitbook/serialize"
3
5
  import type { DemoFile, DemoMeta, DemoResolver } from "./types"
4
6
  import { languageForPath } from "./glob"
5
7
 
8
+ export { fenceFor }
9
+
10
+ /**
11
+ * - `"inline"` (default): every `{% demo src=… %}` keeps its tag and gains its files as
12
+ * inline fences (`{% demo … %}` + titled fences + `{% enddemo %}`), so the Markdown is
13
+ * still a docstream page: it renders back identically where the resolver exists and
14
+ * self-contained everywhere else.
15
+ * - `"plain"`: for LLM prompts and plain Markdown tools — each demo becomes a bold
16
+ * "Example — Title" line, its description, a variants note and one titled fence per
17
+ * file (the pre-1.2 output); `{% command %}` boxes become npm ```` ```sh ```` fences and
18
+ * titled tab sets become a heading plus one bold-labelled part per tab
19
+ * (`flattenForPlainMarkdown`). Not renderable as docstream any more.
20
+ */
21
+ export type DemoMarkdownFormat = "inline" | "plain"
22
+
6
23
  export interface ResolveDemosOptions {
7
- /** Title shown on each file fence. Defaults to `<src>/<path>`. */
24
+ /** Output shape. Defaults to `"inline"` (renderable docstream Markdown). */
25
+ format?: DemoMarkdownFormat
26
+ /** `"plain"` only: title shown on each file fence. Defaults to `<src>/<path>`. */
8
27
  filePath?: (src: string, file: DemoFile) => string
9
28
  /**
10
- * Markdown rendered for one demo. The default is a bold "Example — Title" line,
11
- * the description, a variants note, then one titled fence per file (entry first).
29
+ * Markdown rendered for one demo, replacing the whole `{% demo %}` block whatever the
30
+ * `format`. Called only for demos with files (resolved, or inline).
12
31
  */
13
32
  render?: (demo: ResolvedDemo) => string
14
33
  }
@@ -20,19 +39,13 @@ export interface ResolvedDemo {
20
39
  title: string
21
40
  }
22
41
 
23
- const TAG_RE = /^\s*\{%\s*demo(\s[^%]*?)?\s*\/?%\}\s*$/
24
- const END_RE = /^\s*\{%\s*enddemo\s*%\}\s*$/
25
-
26
- /** A fence longer than any backtick run in the content, so nested fences survive. */
27
- export function fenceFor(content: string): string {
28
- const longest = Math.max(2, ...[...content.matchAll(/`+/g)].map((m) => m[0].length))
29
- return "`".repeat(longest + 1)
30
- }
42
+ const TAG_RE = /^(\s*)\{%\s*demo(\s[^%]*?)?\s*\/?%\}\s*$/
31
43
 
32
44
  function titleFor(src: string, node: DemoNode, meta: DemoMeta | undefined) {
33
- return node.title ?? meta?.title ?? src.split("/").pop() ?? src
45
+ return node.title ?? meta?.title ?? (src.split("/").pop() || src || "Demo")
34
46
  }
35
47
 
48
+ /** The pre-1.2 "plain" rendering of one demo: prose plus titled fences. */
36
49
  export function defaultDemoMarkdown(demo: ResolvedDemo, filePath?: ResolveDemosOptions["filePath"]): string {
37
50
  const { node, meta, files, title } = demo
38
51
  const description = node.description ?? meta?.description
@@ -46,57 +59,141 @@ export function defaultDemoMarkdown(demo: ResolvedDemo, filePath?: ResolveDemosO
46
59
  for (const file of files) {
47
60
  const fence = fenceFor(file.content)
48
61
  const language = file.language ?? languageForPath(file.path) ?? ""
49
- const title = filePath ? filePath(node.src, file) : `${node.src}/${file.path}`
62
+ const title = filePath ? filePath(node.src, file) : node.src ? `${node.src}/${file.path}` : file.path
50
63
  const body = file.content.replace(/\n+$/, "")
51
64
  parts.push(`${fence}${language} title="${title}"\n${body}\n${fence}`)
52
65
  }
53
66
  return parts.join("\n\n")
54
67
  }
55
68
 
69
+ /** Resolver / viewer files as inline demo files: trailing newlines dropped, language filled in. */
70
+ export function toInlineFiles(files: DemoFile[]): DemoInlineFile[] {
71
+ return files.map((file) => {
72
+ const language = file.language ?? languageForPath(file.path)
73
+ return { path: file.path, content: file.content.replace(/\n+$/, ""), ...(language ? { language } : {}) }
74
+ })
75
+ }
76
+
56
77
  /**
57
- * Replace every `{% demo %}` tag with the demo's real source files as titled
58
- * fences, so the Markdown is self-contained (Copy page, `.md` exports, llms.txt).
59
- * Everything else is returned byte-for-byte; tags inside code fences are left alone.
60
- * Without a resolver, or for demos the resolver cannot find, tags are replaced by
61
- * a short note rather than left as syntax other tools would not understand.
78
+ * A demo as a self-contained block-form `{% demo %}`: the node's attributes, with the
79
+ * folder's `meta.json` defaults folded in (so the block renders the same without the
80
+ * resolver), and `files` as inline fences.
81
+ */
82
+ export function inlineDemoNode(node: DemoNode, files: DemoFile[], meta?: DemoMeta): DemoNode {
83
+ const inline = toInlineFiles(files)
84
+ const height = node.height ?? (meta?.height === undefined || meta.height === "" ? undefined : String(meta.height))
85
+ const title = node.title ?? meta?.title
86
+ const description = node.description ?? meta?.description
87
+ const layout = node.layout ?? meta?.layout
88
+ const variants = node.variants?.length ? node.variants : meta?.variants?.length ? meta.variants : undefined
89
+ const entry = node.entry ?? (meta?.entry && inline[0]?.path !== meta.entry ? meta.entry : undefined)
90
+ return {
91
+ type: "demo",
92
+ src: node.src,
93
+ ...(title ? { title } : {}),
94
+ ...(description ? { description } : {}),
95
+ ...(height ? { height } : {}),
96
+ ...(layout ? { layout } : {}),
97
+ ...(variants ? { variants } : {}),
98
+ ...(node.viewport ? { viewport: node.viewport } : {}),
99
+ ...(entry ? { entry } : {}),
100
+ ...(inline.length ? { files: inline } : {}),
101
+ }
102
+ }
103
+
104
+ /** `inlineDemoNode` serialized: `{% demo … %}` + titled fences + `{% enddemo %}`. */
105
+ export function inlineDemoMarkdown(node: DemoNode, files: DemoFile[], meta?: DemoMeta): string {
106
+ return serializeBlocks([inlineDemoNode(node, files, meta)])
107
+ }
108
+
109
+ /**
110
+ * Make every `{% demo %}` in a page carry its real source files (Copy page, `.md`
111
+ * exports, llms.txt). By default the result is still docstream Markdown — see
112
+ * `DemoMarkdownFormat`. Everything outside demo blocks is returned byte-for-byte, and
113
+ * tags inside code fences are left alone.
114
+ *
115
+ * `"inline"`: blocks that already carry inline files, and demos the resolver cannot
116
+ * resolve (or no resolver), are left exactly as written — they are valid docstream.
117
+ * `"plain"`: unresolvable demos become a short note rather than tag syntax other tools
118
+ * would not understand.
62
119
  */
63
120
  export async function resolveDemosToMarkdown(
64
121
  markdown: string,
65
122
  resolver: DemoResolver | undefined,
66
123
  options: ResolveDemosOptions = {},
67
124
  ): Promise<string> {
125
+ // Plain output also flattens docstream-only presentation blocks (command boxes, titled tabs).
126
+ if (options.format === "plain" && /\{%\s*(?:command\b|tabs\s[^%]*\btitle=)/.test(markdown)) {
127
+ markdown = serializeMarkdown(flattenForPlainMarkdown(parseMarkdown(markdown)))
128
+ }
68
129
  const lines = markdown.split("\n")
69
- const jobs: { index: number; drop: number; promise: Promise<string> }[] = []
70
- let fence: string | null = null
130
+ const jobs: { index: number; end: number; indent: string; promise: Promise<string | null> }[] = []
131
+ const fences = fenceTracker()
71
132
  for (let i = 0; i < lines.length; i++) {
72
133
  const line = lines[i]
73
- const opener = line.match(/^\s*(`{3,}|~{3,})/)
74
- if (opener) {
75
- if (!fence) fence = opener[1]
76
- else if (line.trim().startsWith(fence[0].repeat(fence.length)) && line.trim().replace(/[`~]/g, "") === "") fence = null
77
- continue
78
- }
79
- if (fence || !TAG_RE.test(line)) continue
80
- const node = parseMarkdown(line.trim()).children[0]
134
+ if (fences.step(line)) continue
135
+ const tag = line.match(TAG_RE)
136
+ if (!tag) continue
137
+ const indent = tag[1]
138
+ const body = demoBody(lines, i + 1, /\/\s*%\}\s*$/.test(line))
139
+ const end = body ? body.next : i + 1
140
+ const block = lines.slice(i, end).map((l) => (l.startsWith(indent) ? l.slice(indent.length) : l))
141
+ const node = parseBlocks(block)[0]
81
142
  if (node?.type !== "demo") continue
82
- const drop = END_RE.test(lines[i + 1] ?? "") ? 1 : 0
83
- jobs.push({ index: i, drop, promise: renderDemo(node, resolver, options) })
143
+ jobs.push({ index: i, end, indent, promise: renderDemo(node, block[0], resolver, options) })
144
+ i = end - 1
84
145
  }
85
146
  if (!jobs.length) return markdown
86
147
  const rendered = await Promise.all(jobs.map((job) => job.promise))
87
148
  for (let j = jobs.length - 1; j >= 0; j--) {
88
- lines.splice(jobs[j].index, 1 + jobs[j].drop, rendered[j])
149
+ const out = rendered[j]
150
+ if (out === null) continue
151
+ const { index, end, indent } = jobs[j]
152
+ const text = indent ? out.split("\n").map((l) => (l ? indent + l : l)).join("\n") : out
153
+ lines.splice(index, end - index, text)
89
154
  }
90
155
  return lines.join("\n")
91
156
  }
92
157
 
93
- async function renderDemo(node: DemoNode, resolver: DemoResolver | undefined, options: ResolveDemosOptions): Promise<string> {
94
- if (!resolver || !node.src) return `> Live demo \`${node.src || "?"}\` (interactive on the docs site).`
95
- try {
96
- const [meta, files] = await Promise.all([resolver.meta(node.src), resolver.files(node.src)])
97
- const demo: ResolvedDemo = { node, meta, files, title: titleFor(node.src, node, meta) }
98
- return options.render ? options.render(demo) : defaultDemoMarkdown(demo, options.filePath)
99
- } catch (error) {
100
- return `> Live demo \`${node.src}\` could not be resolved: ${error instanceof Error ? error.message : String(error)}`
158
+ /**
159
+ * `inlineDemoMarkdown`, but keeping the author's tag line byte-for-byte when folding in
160
+ * `meta.json` added no attributes (only the files are new).
161
+ */
162
+ function inlineDemoKeepingTag(tagLine: string, node: DemoNode, files: DemoFile[], meta: DemoMeta | undefined): string {
163
+ const next = inlineDemoNode(node, files, meta)
164
+ const out = serializeBlocks([next])
165
+ const bare = (n: DemoNode): DemoNode => {
166
+ const { files: _files, open: _open, ...rest } = n
167
+ return rest
168
+ }
169
+ if (serializeBlocks([bare(next)]) !== serializeBlocks([bare(node)])) return out
170
+ const tag = tagLine.trim().replace(/\s*\/\s*%\}$/, " %}")
171
+ return tag + out.slice(out.indexOf("\n"))
172
+ }
173
+
174
+ /** The replacement for one demo block, or null to keep it byte-for-byte. */
175
+ async function renderDemo(node: DemoNode, tagLine: string, resolver: DemoResolver | undefined, options: ResolveDemosOptions): Promise<string | null> {
176
+ const plain = options.format === "plain"
177
+ const inlineFiles: DemoFile[] | undefined = node.files?.length ? node.files : undefined
178
+ let meta: DemoMeta | undefined
179
+ let files: DemoFile[] | undefined
180
+ let failure: string | undefined
181
+ if (resolver && node.src && !(inlineFiles && !plain && !options.render)) {
182
+ try {
183
+ ;[meta, files] = await Promise.all([resolver.meta(node.src), resolver.files(node.src)])
184
+ } catch (error) {
185
+ failure = error instanceof Error ? error.message : String(error)
186
+ }
187
+ }
188
+ // Resolver files are the authority; inline files stand in when it cannot help.
189
+ const chosen = files?.length ? files : inlineFiles
190
+ if (!chosen) {
191
+ if (!plain) return null
192
+ if (failure !== undefined) return `> Live demo \`${node.src}\` could not be resolved: ${failure}`
193
+ if (!resolver || !node.src) return `> Live demo \`${node.src || "?"}\` (interactive on the docs site).`
101
194
  }
195
+ const demo: ResolvedDemo = { node, meta, files: chosen ?? [], title: titleFor(node.src, node, meta) }
196
+ if (options.render) return options.render(demo)
197
+ if (plain) return defaultDemoMarkdown(demo, options.filePath)
198
+ return chosen === inlineFiles ? null : inlineDemoKeepingTag(tagLine, node, chosen!, meta)
102
199
  }
package/src/demo/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ComponentType } from "react"
1
+ import type { ComponentType, ReactNode } from "react"
2
2
  import type { DemoLayout, DemoVariantOption } from "../gitbook/ast"
3
3
 
4
4
  export type DemoVariant = DemoVariantOption
@@ -7,7 +7,11 @@ export type DemoVariant = DemoVariantOption
7
7
  export interface DemoMeta {
8
8
  title?: string
9
9
  description?: string
10
- /** Preview height: pixels, or any CSS length (`"60vh"`, `"auto"`). */
10
+ /**
11
+ * Preview height: pixels, or any CSS length (`"60vh"`, `"auto"`). Single-file previews
12
+ * treat it as a **minimum** (the card grows with its content); the multi-file viewer's
13
+ * resizable stage and inline-demo iframes use it as the height.
14
+ */
11
15
  height?: number | string
12
16
  variants?: DemoVariant[]
13
17
  /** Entry file, relative to the demo folder. Defaults to `index.tsx`. */
@@ -15,6 +19,17 @@ export interface DemoMeta {
15
19
  layout?: DemoLayout
16
20
  /** Drop the padded preview surface for demos that bring their own full-bleed frame. */
17
21
  bleed?: boolean
22
+ /** Extra class on the preview canvas (the surface the component renders on). */
23
+ className?: string
24
+ /**
25
+ * Inline style for the preview canvas: CSS custom properties or plain properties,
26
+ * e.g. `{ "background": "var(--brand-50)", "--demo-gap": "12px" }`.
27
+ */
28
+ surface?: Record<string, string | number>
29
+ /** A short status shown in the demo header, e.g. `"Live"` or `"Needs network"`. */
30
+ status?: string
31
+ /** Width of the variant switch (px number or CSS length); its options share it equally. */
32
+ variantsWidth?: number | string
18
33
  }
19
34
 
20
35
  export interface DemoFile {
@@ -51,3 +66,32 @@ export interface DemoResolver {
51
66
  */
52
67
  href?(src: string, options?: { variant?: string }): string | undefined
53
68
  }
69
+
70
+ /** What an inline-demo runtime gets: the block's own files, ready to run. */
71
+ export interface InlineDemoRuntimeProps {
72
+ /** The block's `src` (may be empty); useful as a cache key or label. */
73
+ src: string
74
+ title: string
75
+ /** The inline files, entry first, paths relative to the demo folder. */
76
+ files: DemoFile[]
77
+ /** Path of the entry file (its default export is the component). */
78
+ entry: string
79
+ /** The selected variant, passed to the component as its `variant` prop. */
80
+ variant?: string
81
+ /** Extra npm dependencies (`{ "@brett_lamy/ui": "^1.2.0" }`) the files may import. */
82
+ dependencies?: Record<string, string>
83
+ }
84
+
85
+ /**
86
+ * Renders the Preview of a `{% demo %}` block that carries its files inline and has
87
+ * no resolver behind it. `@brett_lamy/docstream/playground` provides an almost-node
88
+ * implementation (`createAlmostNodeDemoRuntime`), which `PlaygroundStreamdown` (the
89
+ * package root's `GitbookStreamdown`) wires in by default.
90
+ */
91
+ export type InlineDemoRuntime = (props: InlineDemoRuntimeProps) => ReactNode
92
+
93
+ export interface DemoRuntimeOptions {
94
+ runtime?: InlineDemoRuntime
95
+ /** Extra npm dependencies handed to the runtime for every inline demo. */
96
+ dependencies?: Record<string, string>
97
+ }
@@ -1,4 +1,4 @@
1
- import { useId, useMemo, useState, type ReactNode } from "react"
1
+ import { useContext, useId, useMemo, useState, type ReactNode } from "react"
2
2
  import { motion, useReducedMotion } from "framer-motion"
3
3
  import "katex/dist/katex.min.css"
4
4
  import {
@@ -11,15 +11,17 @@ import {
11
11
  XCircle,
12
12
  } from "lucide-react"
13
13
 
14
- import type { Block, CodeBlockNode, DocumentNode, Inline, SourceRefNode } from "../gitbook/ast"
14
+ import type { Block, CodeBlockNode, CommandNode, DocumentNode, Inline, PackageManager, SourceRefNode } from "../gitbook/ast"
15
+ import { PACKAGE_MANAGERS, packageManagerCommands } from "../gitbook/package-managers"
16
+ import { slugify } from "../gitbook/outline"
15
17
  import { resolveAsset } from "../assets"
16
18
  import { parseMarkdown } from "../gitbook/parse"
17
19
  import { serializeMarkdown } from "../gitbook/serialize"
18
- import { DocstreamDemoContext } from "../demo/context"
20
+ import { DocstreamDemoContext, DocstreamDemoRuntimeContext } from "../demo/context"
19
21
  import { DemoBlock } from "../demo/DemoViewer"
20
- import type { DemoResolver } from "../demo/types"
22
+ import type { DemoResolver, InlineDemoRuntime } from "../demo/types"
21
23
  import { CopyButton } from "./copy"
22
- import { rovingKeyDown } from "./controls"
24
+ import { PillTabs, rovingKeyDown } from "./controls"
23
25
  import { DocPageActions, type DocPageActionsOptions } from "./PageActions"
24
26
  import { useSyncedTab } from "./tabs-sync"
25
27
  import { ReplayPreview, isReplayQaUrl } from "../replay"
@@ -120,28 +122,24 @@ function codeOnlyTabs(block: Extract<Block, { type: "tabs" }>): CodeBlockNode[]
120
122
  return codes
121
123
  }
122
124
 
123
- function Tabs({ block, liveRenderer, sourceRenderer }: { block: Extract<Block, { type: "tabs" }> } & Renderers) {
124
- const [local, setLocal] = useState(0)
125
- const [synced, setSynced] = useSyncedTab(block.sync)
126
- const syncedIndex = synced === null ? -1 : block.tabs.findIndex((t) => t.title === synced)
127
- const active = Math.min(syncedIndex >= 0 ? syncedIndex : local, Math.max(0, block.tabs.length - 1))
128
- const baseId = useId()
125
+ /** The underline tab strip shared by tab sets and command boxes. */
126
+ function TabStrip({ titles, active, onSelect, label, baseId }: {
127
+ titles: string[]
128
+ active: number
129
+ onSelect: (index: number) => void
130
+ label: string
131
+ baseId: string
132
+ }) {
129
133
  const reduced = useReducedMotion()
130
- const codes = useMemo(() => codeOnlyTabs(block), [block])
131
- const select = (index: number) => {
132
- setLocal(index)
133
- const title = block.tabs[index]?.title
134
- if (block.sync && title !== undefined) setSynced(title)
135
- }
136
- const ids = block.tabs.map((_, i) => String(i))
137
- const header = (
134
+ const ids = titles.map((_, i) => String(i))
135
+ return (
138
136
  <div
139
137
  className="docs-tabs-header"
140
138
  role="tablist"
141
- aria-label={block.sync ? `${block.sync} options` : "Content tabs"}
142
- onKeyDown={(event) => rovingKeyDown(event, ids, String(active), (id) => select(Number(id)))}
139
+ aria-label={label}
140
+ onKeyDown={(event) => rovingKeyDown(event, ids, String(active), (id) => onSelect(Number(id)))}
143
141
  >
144
- {block.tabs.map((t, i) => (
142
+ {titles.map((title, i) => (
145
143
  <button
146
144
  key={i}
147
145
  id={`${baseId}-tab-${i}`}
@@ -152,7 +150,7 @@ function Tabs({ block, liveRenderer, sourceRenderer }: { block: Extract<Block, {
152
150
  aria-controls={`${baseId}-panel`}
153
151
  tabIndex={i === active ? 0 : -1}
154
152
  className={i === active ? "docs-tab-active" : ""}
155
- onClick={() => select(i)}
153
+ onClick={() => onSelect(i)}
156
154
  >
157
155
  {i === active ? (
158
156
  <motion.span
@@ -161,11 +159,89 @@ function Tabs({ block, liveRenderer, sourceRenderer }: { block: Extract<Block, {
161
159
  transition={reduced ? { duration: 0 } : { type: "spring", bounce: 0.2, duration: 0.6 }}
162
160
  />
163
161
  ) : null}
164
- <span className="docs-tab-label">{t.title}</span>
162
+ <span className="docs-tab-label">{title}</span>
165
163
  </button>
166
164
  ))}
167
165
  </div>
168
166
  )
167
+ }
168
+
169
+ /** `{% command %}`: one npm command, shown for the reader's package manager (synced page-wide). */
170
+ function CommandBox({ block }: { block: CommandNode }) {
171
+ const [synced, setSynced] = useSyncedTab(block.sync ?? "pm")
172
+ const [local, setLocal] = useState<PackageManager>(PACKAGE_MANAGERS[0])
173
+ const active: PackageManager = synced && (PACKAGE_MANAGERS as readonly string[]).includes(synced) ? (synced as PackageManager) : local
174
+ const commands = useMemo(() => packageManagerCommands(block.command, block.overrides), [block.command, block.overrides])
175
+ const baseId = useId()
176
+ const index = PACKAGE_MANAGERS.indexOf(active)
177
+ const select = (i: number) => {
178
+ const pm = PACKAGE_MANAGERS[i]
179
+ setLocal(pm)
180
+ setSynced(pm)
181
+ }
182
+ return (
183
+ <div className="docs-tabs docs-tabs-code docs-command" data-sync={block.sync ?? "pm"} data-pm={active}>
184
+ <div className="docs-tabs-code-head">
185
+ <span className="docs-tabs-code-term" aria-hidden="true">
186
+ <Terminal width={12} height={12} strokeWidth={2.4} />
187
+ </span>
188
+ <TabStrip titles={[...PACKAGE_MANAGERS]} active={index} onSelect={select} label="Package manager" baseId={baseId} />
189
+ <span className="docs-tabs-code-title" />
190
+ <CopyButton text={commands[active]} label={`Copy ${active} command`} />
191
+ </div>
192
+ <div className="docs-tabs-code-body" role="tabpanel" id={`${baseId}-panel`} aria-labelledby={`${baseId}-tab-${index}`}>
193
+ <pre>
194
+ <HighlightedCode code={commands[active]} language="sh" lineNumbers={false} />
195
+ </pre>
196
+ </div>
197
+ </div>
198
+ )
199
+ }
200
+
201
+ function Tabs({ block, liveRenderer, sourceRenderer }: { block: Extract<Block, { type: "tabs" }> } & Renderers) {
202
+ const [local, setLocal] = useState(0)
203
+ const [synced, setSynced] = useSyncedTab(block.sync)
204
+ const syncedIndex = synced === null ? -1 : block.tabs.findIndex((t) => t.title === synced)
205
+ const active = Math.min(syncedIndex >= 0 ? syncedIndex : local, Math.max(0, block.tabs.length - 1))
206
+ const baseId = useId()
207
+ const codes = useMemo(() => codeOnlyTabs(block), [block])
208
+ const select = (index: number) => {
209
+ setLocal(index)
210
+ const title = block.tabs[index]?.title
211
+ if (block.sync && title !== undefined) setSynced(title)
212
+ }
213
+ const header = (
214
+ <TabStrip
215
+ titles={block.tabs.map((t) => t.title)}
216
+ active={active}
217
+ onSelect={select}
218
+ label={block.sync ? `${block.sync} options` : "Content tabs"}
219
+ baseId={baseId}
220
+ />
221
+ )
222
+
223
+ if (block.title) {
224
+ const Heading = `h${block.level ?? 2}` as const
225
+ const headingId = slugify(block.title)
226
+ return (
227
+ <section className="docs-tabs-section" data-sync={block.sync} aria-labelledby={headingId}>
228
+ <div className="docs-tabs-section-head">
229
+ <Heading id={headingId} className="docs-tabs-section-title">{block.title}</Heading>
230
+ <PillTabs
231
+ options={block.tabs.map((t, i) => ({ id: String(i), label: t.title }))}
232
+ value={String(active)}
233
+ onChange={(id) => select(Number(id))}
234
+ label={`${block.title}: ${block.sync ? `${block.sync} options` : "options"}`}
235
+ tabId={(id) => `${baseId}-tab-${id}`}
236
+ panelId={() => `${baseId}-panel`}
237
+ />
238
+ </div>
239
+ <div className="docs-tabs-section-body" role="tabpanel" id={`${baseId}-panel`} aria-labelledby={`${baseId}-tab-${active}`}>
240
+ <Blocks blocks={block.tabs[active]?.children ?? []} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
241
+ </div>
242
+ </section>
243
+ )
244
+ }
169
245
 
170
246
  if (codes) {
171
247
  const code = codes[active]
@@ -239,15 +315,18 @@ function BlockView({ block, liveRenderer, sourceRenderer }: { block: Block } & R
239
315
  })
240
316
  }
241
317
  return (
242
- <div className="docs-code">
243
- {(block.title || block.language) && (
318
+ <div className="docs-code" data-language={block.language ?? undefined}>
319
+ {/* A header bar only when there's a title (file name) to show; otherwise just the code
320
+ with a floating copy button. */}
321
+ {block.title ? (
244
322
  <div className="docs-code-header">
245
323
  <span>{block.title}</span>
246
324
  <span className="docs-code-lang">{block.language}</span>
247
- <CopyButton text={block.code} label={block.title ? `Copy ${block.title}` : "Copy code"} />
325
+ <CopyButton text={block.code} label={`Copy ${block.title}`} />
248
326
  </div>
327
+ ) : (
328
+ <CopyButton text={block.code} className="docs-code-copy-float" />
249
329
  )}
250
- {!block.title && !block.language ? <CopyButton text={block.code} className="docs-code-copy-float" /> : null}
251
330
  <pre className={block.lineNumbers ? "docs-code-numbered" : ""}>
252
331
  <HighlightedCode
253
332
  code={block.code}
@@ -270,6 +349,8 @@ function BlockView({ block, liveRenderer, sourceRenderer }: { block: Block } & R
270
349
  }
271
350
  case "tabs":
272
351
  return <Tabs block={block} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
352
+ case "command":
353
+ return block.command ? <CommandBox block={block} /> : null
273
354
  case "expandable":
274
355
  return (
275
356
  <details className="docs-expandable">
@@ -466,6 +547,15 @@ function Blocks({ blocks, inline, liveRenderer, sourceRenderer }: { blocks: Bloc
466
547
  export interface DocRenderOptions {
467
548
  /** Resolves `{% demo %}` blocks. Provided through context, so nested renderers inherit it. */
468
549
  demoResolver?: DemoResolver
550
+ /**
551
+ * Runs the Preview of `{% demo %}` blocks that carry their files inline and have no
552
+ * resolver behind them. `PlaygroundStreamdown` (the package root's `GitbookStreamdown`)
553
+ * supplies an almost-node runtime by default; see `createAlmostNodeDemoRuntime`.
554
+ * Without one, such demos show their code with a short note.
555
+ */
556
+ demoRuntime?: InlineDemoRuntime
557
+ /** Extra npm dependencies inline demos may import (`{ "@brett_lamy/ui": "^1.2.0" }`). */
558
+ demoDependencies?: Record<string, string>
469
559
  /**
470
560
  * Render "Copy page ▾" (Copy / View as Markdown, Open in ChatGPT / Claude) above the content.
471
561
  * `true` uses the defaults; an object passes options to `DocPageActions`.
@@ -473,8 +563,27 @@ export interface DocRenderOptions {
473
563
  pageActions?: boolean | DocPageActionsOptions
474
564
  }
475
565
 
476
- function withDemoResolver(resolver: DemoResolver | undefined, children: ReactNode) {
477
- return resolver ? <DocstreamDemoContext.Provider value={resolver}>{children}</DocstreamDemoContext.Provider> : children
566
+ function withDemoContext({ demoResolver, demoRuntime, demoDependencies }: DocRenderOptions, children: ReactNode) {
567
+ let out = children
568
+ if (demoRuntime || demoDependencies) {
569
+ out = <DemoRuntimeProvider runtime={demoRuntime} dependencies={demoDependencies}>{out}</DemoRuntimeProvider>
570
+ }
571
+ return demoResolver ? <DocstreamDemoContext.Provider value={demoResolver}>{out}</DocstreamDemoContext.Provider> : out
572
+ }
573
+
574
+ /** Merges runtime options over the inherited ones, keeping a stable context value. */
575
+ function DemoRuntimeProvider({ runtime, dependencies, children }: { runtime?: InlineDemoRuntime; dependencies?: Record<string, string>; children: ReactNode }) {
576
+ const inherited = useContext(DocstreamDemoRuntimeContext)
577
+ const depsKey = dependencies ? JSON.stringify(dependencies) : ""
578
+ const value = useMemo(
579
+ () => ({
580
+ runtime: runtime ?? inherited.runtime,
581
+ dependencies: dependencies || inherited.dependencies ? { ...inherited.dependencies, ...dependencies } : undefined,
582
+ }),
583
+ // eslint-disable-next-line react-hooks/exhaustive-deps
584
+ [runtime, inherited, depsKey],
585
+ )
586
+ return <DocstreamDemoRuntimeContext.Provider value={value}>{children}</DocstreamDemoRuntimeContext.Provider>
478
587
  }
479
588
 
480
589
  export function PageActionsBar({ markdown, options }: { markdown: string | (() => string); options: boolean | DocPageActionsOptions | undefined }) {
@@ -493,12 +602,14 @@ export function MarkdownContent({
493
602
  liveRenderer,
494
603
  sourceRenderer,
495
604
  demoResolver,
605
+ demoRuntime,
606
+ demoDependencies,
496
607
  pageActions,
497
608
  className,
498
609
  }: { markdown: string; className?: string } & Renderers & DocRenderOptions) {
499
610
  const doc = useMemo(() => parseMarkdown(markdown), [markdown])
500
- return withDemoResolver(
501
- demoResolver,
611
+ return withDemoContext(
612
+ { demoResolver, demoRuntime, demoDependencies },
502
613
  <div data-docstream="" className={className}>
503
614
  <PageActionsBar markdown={markdown} options={pageActions} />
504
615
  <Blocks blocks={doc.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
@@ -512,11 +623,13 @@ export function DocsRenderer({
512
623
  liveRenderer,
513
624
  sourceRenderer,
514
625
  demoResolver,
626
+ demoRuntime,
627
+ demoDependencies,
515
628
  pageActions,
516
629
  markdown,
517
630
  }: { doc: DocumentNode; /** Source for page actions; defaults to `serializeMarkdown(doc)`. */ markdown?: string } & Renderers & DocRenderOptions) {
518
- return withDemoResolver(
519
- demoResolver,
631
+ return withDemoContext(
632
+ { demoResolver, demoRuntime, demoDependencies },
520
633
  <article className="docs-article">
521
634
  <PageActionsBar markdown={markdown ?? (() => serializeMarkdown(doc))} options={pageActions} />
522
635
  <Blocks blocks={doc.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
@@ -1,5 +1,6 @@
1
- /* "Copy page ▾": copy the page as Markdown (with {% demo %} blocks resolved into their real files),
2
- view it as Markdown, or open it in ChatGPT / Claude. */
1
+ /* "Copy page ▾": copy the page as Markdown, view it as Markdown, or open it in ChatGPT / Claude.
2
+ Copy and View give renderable docstream Markdown: each {% demo %} keeps its tag and carries its
3
+ real files inline, so pasting the page into docstream renders the same page (demos included). */
3
4
  import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react"
4
5
  import { AnimatePresence, motion, useReducedMotion } from "framer-motion"
5
6
  import { ArrowUpRight, ChevronDown, FileText } from "lucide-react"
@@ -25,7 +26,10 @@ export interface DocPageActionsOptions {
25
26
  actions?: DocPageAction[]
26
27
  /** Final touch on the copied Markdown (e.g. append a "Source:" footer). */
27
28
  transform?: (markdown: string) => string
28
- /** Options for resolving `{% demo %}` blocks. */
29
+ /**
30
+ * Options for resolving `{% demo %}` blocks. The default `format: "inline"` keeps the page
31
+ * renderable by docstream; `{ format: "plain" }` gives the pre-1.2 prose + fences form.
32
+ */
29
33
  demoOptions?: ResolveDemosOptions
30
34
  /** Overrides the resolver from context. */
31
35
  resolver?: DemoResolver
@@ -62,7 +66,7 @@ interface MenuItem {
62
66
  run?: () => void
63
67
  }
64
68
 
65
- /** Resolves the page Markdown (demos inlined) for copying or exporting. */
69
+ /** The page as renderable Markdown (demo files inline) for copying or exporting. */
66
70
  async function pageMarkdown(props: DocPageActionsProps, resolver: DemoResolver | undefined) {
67
71
  const source = typeof props.markdown === "function" ? await props.markdown() : props.markdown
68
72
  const resolved = await resolveDemosToMarkdown(source, resolver, props.demoOptions)
@@ -106,7 +110,7 @@ export function DocPageActions(props: DocPageActionsProps) {
106
110
  }
107
111
 
108
112
  const all: MenuItem[] = [
109
- { id: "copy", icon: <CopyIcon copied={false} />, label: "Copy as Markdown", hint: "Copy this page for LLMs", run: () => void copyPage() },
113
+ { id: "copy", icon: <CopyIcon copied={false} />, label: "Copy as Markdown", hint: "Copy this page, demos included", run: () => void copyPage() },
110
114
  props.markdownUrl
111
115
  ? { id: "view", icon: <FileText width={15} height={15} aria-hidden="true" />, label: "View as Markdown", hint: "Open the page as plain text", href: props.markdownUrl }
112
116
  : { id: "view", icon: <FileText width={15} height={15} aria-hidden="true" />, label: "View as Markdown", hint: "Open the page as plain text", run: () => void viewMarkdown() },
@@ -122,7 +126,7 @@ export function DocPageActions(props: DocPageActionsProps) {
122
126
  }
123
127
 
124
128
  const onMenuKey = (event: KeyboardEvent<HTMLDivElement>) => {
125
- const entries = [...event.currentTarget.querySelectorAll<HTMLElement>("[role=menuitem]")]
129
+ const entries = Array.from(event.currentTarget.querySelectorAll<HTMLElement>("[role=menuitem]"))
126
130
  const index = entries.indexOf(document.activeElement as HTMLElement)
127
131
  let next = -1
128
132
  if (event.key === "ArrowDown") next = (index + 1) % entries.length