@brett_lamy/docstream 1.0.0 → 1.1.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.
@@ -0,0 +1,13 @@
1
+ import { createContext, useContext } from "react"
2
+ import type { DemoResolver } from "./types"
3
+
4
+ /**
5
+ * The host's demo resolver. Renderers given a `demoResolver` prop provide it, so
6
+ * nested renderers (Markdown inside tabs, hints, a demo's own Markdown) inherit it.
7
+ */
8
+ export const DocstreamDemoContext = createContext<DemoResolver | undefined>(undefined)
9
+
10
+ export function useDemoResolver(override?: DemoResolver): DemoResolver | undefined {
11
+ const inherited = useContext(DocstreamDemoContext)
12
+ return override ?? inherited
13
+ }
@@ -0,0 +1,188 @@
1
+ import type { DemoComponent, DemoFile, DemoMeta, DemoResolver } from "./types"
2
+
3
+ type Lazy<T> = () => Promise<T>
4
+ type MaybeLazy<T> = T | Lazy<T>
5
+
6
+ export interface GlobDemoResolverOptions {
7
+ /**
8
+ * Entry modules, e.g. `import.meta.glob("./examples/*\/*\/index.tsx")`.
9
+ * Lazy (the default for `import.meta.glob`) or eager module objects both work.
10
+ */
11
+ modules: Record<string, MaybeLazy<unknown>>
12
+ /**
13
+ * Raw file contents, e.g.
14
+ * `import.meta.glob("./examples/**\/*.{ts,tsx,css,json}", { query: "?raw", import: "default" })`.
15
+ */
16
+ sources: Record<string, MaybeLazy<string>>
17
+ /**
18
+ * Folder metadata, e.g.
19
+ * `import.meta.glob("./examples/*\/*\/meta.json", { eager: true, import: "default" })`.
20
+ */
21
+ metas?: Record<string, MaybeLazy<DemoMeta | { default: DemoMeta }>>
22
+ /**
23
+ * The glob prefix the `<page>/<example>` ids are relative to, e.g. `"./examples"`.
24
+ * When omitted, a demo's id is the last two folders of its entry path.
25
+ */
26
+ root?: string
27
+ /** Default entry file name. Defaults to `index.tsx`; a folder's `meta.entry` wins. */
28
+ entry?: string
29
+ /** Hide files from the Code view (`meta.json` is always hidden). */
30
+ exclude?: (path: string, src: string) => boolean
31
+ /** Deep link for a demo alone, e.g. `(src) => demoHref(src)`. */
32
+ href?: DemoResolver["href"]
33
+ }
34
+
35
+ const LANGUAGES: Record<string, string> = { mjs: "js", cjs: "js", mts: "ts", cts: "ts", md: "markdown", yml: "yaml" }
36
+
37
+ export function languageForPath(path: string): string | undefined {
38
+ const ext = path.split("/").pop()?.split(".").pop()?.toLowerCase()
39
+ if (!ext || ext === path) return undefined
40
+ return LANGUAGES[ext] ?? ext
41
+ }
42
+
43
+ const unwrap = async <T,>(value: MaybeLazy<T>): Promise<T> =>
44
+ typeof value === "function" ? (value as Lazy<T>)() : value
45
+
46
+ function trimSlashes(path: string) {
47
+ return path.replace(/\\/g, "/").replace(/\/+$/, "")
48
+ }
49
+
50
+ /** Picks the component out of a loaded module: `default`, else the first function export. */
51
+ export function demoComponentFrom(module: unknown, src = "demo"): DemoComponent {
52
+ if (typeof module === "function") return module as DemoComponent
53
+ const record = (module ?? {}) as Record<string, unknown>
54
+ const candidate =
55
+ typeof record.default === "function"
56
+ ? record.default
57
+ : Object.values(record).find((value) => typeof value === "function")
58
+ if (typeof candidate !== "function") throw new Error(`Demo ${src} has no default-exported component`)
59
+ return candidate as DemoComponent
60
+ }
61
+
62
+ /** Orders files entry first, then top-level before nested, then by path. */
63
+ export function sortDemoFiles(files: DemoFile[], entry = "index.tsx"): DemoFile[] {
64
+ const depth = (path: string) => path.split("/").length
65
+ return [...files].sort((a, b) => {
66
+ if (a.path === entry) return -1
67
+ if (b.path === entry) return 1
68
+ return depth(a.path) - depth(b.path) || a.path.localeCompare(b.path)
69
+ })
70
+ }
71
+
72
+ /**
73
+ * Build a `DemoResolver` from Vite `import.meta.glob` maps. Each `<page>/<example>`
74
+ * folder holds an entry module (default export = the demo component, which receives
75
+ * `variant`), any number of source files, and an optional `meta.json`.
76
+ */
77
+ export function createGlobDemoResolver(options: GlobDemoResolverOptions): DemoResolver {
78
+ const defaultEntry = options.entry ?? "index.tsx"
79
+ const root = options.root === undefined ? undefined : trimSlashes(options.root)
80
+
81
+ // Demo ids come from the entry modules: with `root`, the first two folders under it;
82
+ // without, the last two folders of each entry path.
83
+ const folders = new Map<string, string>() // src → folder prefix (with trailing slash)
84
+ for (const key of Object.keys(options.modules)) {
85
+ const path = key.replace(/\\/g, "/")
86
+ if (root !== undefined) {
87
+ const prefix = root ? `${root}/` : ""
88
+ if (!path.startsWith(prefix)) continue
89
+ const parts = path.slice(prefix.length).split("/")
90
+ if (parts.length < 3) continue
91
+ const src = `${parts[0]}/${parts[1]}`
92
+ if (!folders.has(src)) folders.set(src, `${prefix}${src}/`)
93
+ } else {
94
+ const parts = path.split("/").slice(0, -1)
95
+ if (parts.length < 2) continue
96
+ const src = parts.slice(-2).join("/")
97
+ if (!folders.has(src)) folders.set(src, `${parts.join("/")}/`)
98
+ }
99
+ }
100
+ /** The key's path relative to the demo folder, or null when it lives elsewhere. */
101
+ const inFolder = (src: string, key: string) => {
102
+ const prefix = folders.get(src)
103
+ const path = key.replace(/\\/g, "/")
104
+ return prefix && path.startsWith(prefix) ? path.slice(prefix.length) : null
105
+ }
106
+
107
+ const metaCache = new Map<string, Promise<DemoMeta | undefined>>()
108
+ const filesCache = new Map<string, Promise<DemoFile[]>>()
109
+ const loadCache = new Map<string, Promise<DemoComponent>>()
110
+
111
+ const meta = (src: string) => {
112
+ let hit = metaCache.get(src)
113
+ if (!hit) {
114
+ const key = Object.keys(options.metas ?? {}).find((k) => inFolder(src, k) === "meta.json")
115
+ hit = key
116
+ ? unwrap(options.metas![key]).then((m) => {
117
+ const value = m as DemoMeta & { default?: DemoMeta }
118
+ return value && typeof value === "object" && "default" in value && value.default ? value.default : value
119
+ })
120
+ : Promise.resolve(undefined)
121
+ metaCache.set(src, hit)
122
+ }
123
+ return hit
124
+ }
125
+
126
+ const entryFor = async (src: string) => (await meta(src))?.entry ?? defaultEntry
127
+
128
+ return {
129
+ list: () => [...folders.keys()].sort(),
130
+ meta,
131
+ files(src) {
132
+ let hit = filesCache.get(src)
133
+ if (!hit) {
134
+ hit = (async () => {
135
+ const entry = await entryFor(src)
136
+ const found = Object.entries(options.sources)
137
+ .map(([key, value]) => ({ path: inFolder(src, key), value }))
138
+ .filter((f): f is { path: string; value: MaybeLazy<string> } =>
139
+ !!f.path && f.path !== "meta.json" && !options.exclude?.(f.path, src))
140
+ const files = await Promise.all(found.map(async ({ path, value }) => {
141
+ const content = await unwrap(value)
142
+ return { path, content: typeof content === "string" ? content : String(content), language: languageForPath(path) }
143
+ }))
144
+ return sortDemoFiles(files, entry)
145
+ })()
146
+ filesCache.set(src, hit)
147
+ }
148
+ return hit
149
+ },
150
+ load(src) {
151
+ let hit = loadCache.get(src)
152
+ if (!hit) {
153
+ hit = (async () => {
154
+ const entry = await entryFor(src)
155
+ const keys = Object.keys(options.modules).filter((k) => inFolder(src, k) !== null)
156
+ const key = keys.find((k) => inFolder(src, k) === entry) ?? keys[0]
157
+ if (!key) throw new Error(`No demo module found for ${src}`)
158
+ return demoComponentFrom(await unwrap(options.modules[key]), src)
159
+ })()
160
+ hit.catch(() => loadCache.delete(src))
161
+ loadCache.set(src, hit)
162
+ }
163
+ return hit
164
+ },
165
+ ...(options.href ? { href: options.href } : {}),
166
+ }
167
+ }
168
+
169
+ /**
170
+ * The conventional deep link for one demo: `?demo=<src>` on the current page
171
+ * (or `base`). Hosts route it to `<DemoFullscreen>`; see `demoFromSearch`.
172
+ */
173
+ export function demoHref(src: string, options: { base?: string; variant?: string; param?: string } = {}): string {
174
+ const base = options.base ?? (typeof location === "undefined" ? "/" : location.pathname)
175
+ const params = new URLSearchParams({ [options.param ?? "demo"]: src })
176
+ if (options.variant) params.set("variant", options.variant)
177
+ return `${base}?${params.toString().replace(/%2F/gi, "/")}`
178
+ }
179
+
180
+ /** Reads the `?demo=<src>` (and optional `variant`) deep link. */
181
+ export function demoFromSearch(search?: string, param = "demo"): { src: string; variant?: string } | null {
182
+ const query = search ?? (typeof location === "undefined" ? "" : location.search)
183
+ const params = new URLSearchParams(query)
184
+ const src = params.get(param)
185
+ if (!src) return null
186
+ const variant = params.get("variant")
187
+ return variant ? { src, variant } : { src }
188
+ }
@@ -0,0 +1,15 @@
1
+ export { DocstreamDemoContext, useDemoResolver } from "./context"
2
+ export { DemoBlock, DemoFullscreen, DemoViewer } from "./DemoViewer"
3
+ export type { DemoFullscreenProps, DemoViewerProps } from "./DemoViewer"
4
+ export {
5
+ createGlobDemoResolver,
6
+ demoComponentFrom,
7
+ demoFromSearch,
8
+ demoHref,
9
+ languageForPath,
10
+ sortDemoFiles,
11
+ } from "./glob"
12
+ export type { GlobDemoResolverOptions } from "./glob"
13
+ export { defaultDemoMarkdown, resolveDemosToMarkdown } from "./markdown"
14
+ export type { ResolveDemosOptions, ResolvedDemo } from "./markdown"
15
+ export type { DemoComponent, DemoComponentProps, DemoFile, DemoMeta, DemoResolver, DemoVariant } from "./types"
@@ -0,0 +1,102 @@
1
+ import type { DemoNode } from "../gitbook/ast"
2
+ import { parseMarkdown } from "../gitbook/parse"
3
+ import type { DemoFile, DemoMeta, DemoResolver } from "./types"
4
+ import { languageForPath } from "./glob"
5
+
6
+ export interface ResolveDemosOptions {
7
+ /** Title shown on each file fence. Defaults to `<src>/<path>`. */
8
+ filePath?: (src: string, file: DemoFile) => string
9
+ /**
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).
12
+ */
13
+ render?: (demo: ResolvedDemo) => string
14
+ }
15
+
16
+ export interface ResolvedDemo {
17
+ node: DemoNode
18
+ meta: DemoMeta | undefined
19
+ files: DemoFile[]
20
+ title: string
21
+ }
22
+
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
+ }
31
+
32
+ function titleFor(src: string, node: DemoNode, meta: DemoMeta | undefined) {
33
+ return node.title ?? meta?.title ?? src.split("/").pop() ?? src
34
+ }
35
+
36
+ export function defaultDemoMarkdown(demo: ResolvedDemo, filePath?: ResolveDemosOptions["filePath"]): string {
37
+ const { node, meta, files, title } = demo
38
+ const description = node.description ?? meta?.description
39
+ const variants = node.variants ?? meta?.variants
40
+ const parts = [`**Example — ${title}**`]
41
+ if (description) parts.push(description)
42
+ if (variants?.length) {
43
+ parts.push(`Variants (the \`variant\` prop): ${variants.map((v) => `\`${v.id}\`${v.label !== v.id ? ` (${v.label})` : ""}`).join(", ")}.`)
44
+ }
45
+ if (!files.length) parts.push(`> Demo \`${node.src}\` has no source files.`)
46
+ for (const file of files) {
47
+ const fence = fenceFor(file.content)
48
+ const language = file.language ?? languageForPath(file.path) ?? ""
49
+ const title = filePath ? filePath(node.src, file) : `${node.src}/${file.path}`
50
+ const body = file.content.replace(/\n+$/, "")
51
+ parts.push(`${fence}${language} title="${title}"\n${body}\n${fence}`)
52
+ }
53
+ return parts.join("\n\n")
54
+ }
55
+
56
+ /**
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.
62
+ */
63
+ export async function resolveDemosToMarkdown(
64
+ markdown: string,
65
+ resolver: DemoResolver | undefined,
66
+ options: ResolveDemosOptions = {},
67
+ ): Promise<string> {
68
+ const lines = markdown.split("\n")
69
+ const jobs: { index: number; drop: number; promise: Promise<string> }[] = []
70
+ let fence: string | null = null
71
+ for (let i = 0; i < lines.length; i++) {
72
+ 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]
81
+ 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) })
84
+ }
85
+ if (!jobs.length) return markdown
86
+ const rendered = await Promise.all(jobs.map((job) => job.promise))
87
+ for (let j = jobs.length - 1; j >= 0; j--) {
88
+ lines.splice(jobs[j].index, 1 + jobs[j].drop, rendered[j])
89
+ }
90
+ return lines.join("\n")
91
+ }
92
+
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)}`
101
+ }
102
+ }
@@ -0,0 +1,53 @@
1
+ import type { ComponentType } from "react"
2
+ import type { DemoLayout, DemoVariantOption } from "../gitbook/ast"
3
+
4
+ export type DemoVariant = DemoVariantOption
5
+
6
+ /** Defaults for a demo folder, usually its `meta.json`. `{% demo %}` attributes override them. */
7
+ export interface DemoMeta {
8
+ title?: string
9
+ description?: string
10
+ /** Preview height: pixels, or any CSS length (`"60vh"`, `"auto"`). */
11
+ height?: number | string
12
+ variants?: DemoVariant[]
13
+ /** Entry file, relative to the demo folder. Defaults to `index.tsx`. */
14
+ entry?: string
15
+ layout?: DemoLayout
16
+ /** Drop the padded preview surface for demos that bring their own full-bleed frame. */
17
+ bleed?: boolean
18
+ }
19
+
20
+ export interface DemoFile {
21
+ /** Path relative to the demo folder (`index.tsx`, `parts/Card.tsx`). */
22
+ path: string
23
+ content: string
24
+ /** Highlighting language; defaults to the file extension. */
25
+ language?: string
26
+ }
27
+
28
+ /** Props the loaded demo component receives. */
29
+ export interface DemoComponentProps {
30
+ variant?: string
31
+ }
32
+
33
+ export type DemoComponent = ComponentType<DemoComponentProps>
34
+
35
+ /**
36
+ * How a host turns `{% demo src="<page>/<example>" %}` into something renderable.
37
+ * `createGlobDemoResolver` builds one from Vite `import.meta.glob` maps.
38
+ */
39
+ export interface DemoResolver {
40
+ /** Every known demo id (optional; used by tooling and llms.txt generators). */
41
+ list?(): string[]
42
+ meta(src: string): Promise<DemoMeta | undefined>
43
+ /** The demo's source files, entry first. These are exactly what the Code view shows. */
44
+ files(src: string): Promise<DemoFile[]>
45
+ /** The component to render in-page. */
46
+ load(src: string): Promise<DemoComponent>
47
+ /**
48
+ * Optional deep link that renders the demo alone (e.g. `?demo=<src>`); the host
49
+ * routes it to `<DemoFullscreen>`. When present, the viewer shows an
50
+ * "Open in new tab" link next to its in-page fullscreen button.
51
+ */
52
+ href?(src: string, options?: { variant?: string }): string | undefined
53
+ }
@@ -0,0 +1,54 @@
1
+ import { useId, useState, type CSSProperties } from "react"
2
+ import { HighlightedCode } from "./HighlightedCode"
3
+ import { CopyButton } from "./copy"
4
+
5
+ export interface CollapsibleCodeProps {
6
+ code: string
7
+ language: string | null
8
+ /** Lines visible while collapsed. Defaults to 3. */
9
+ collapsedLines?: number
10
+ /** Lines visible when expanded before the panel scrolls. Defaults to 30. */
11
+ expandedLines?: number
12
+ /** Show a copy button in the panel corner. Defaults to true. */
13
+ copy?: boolean
14
+ copyLabel?: string
15
+ }
16
+
17
+ function positiveLineCount(value: number | undefined, fallback: number): number {
18
+ return value !== undefined && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
19
+ }
20
+
21
+ /** The line-numbered code panel under a demo: a few lines, then "View Code" to expand. */
22
+ export function CollapsibleCode({ code, language, collapsedLines, expandedLines, copy = true, copyLabel }: CollapsibleCodeProps) {
23
+ const [expanded, setExpanded] = useState(false)
24
+ const panelId = useId()
25
+ const collapsed = positiveLineCount(collapsedLines, 3)
26
+ const expandedCount = Math.max(collapsed, positiveLineCount(expandedLines, 30))
27
+ const total = code.split("\n").length
28
+ const needsToggle = total > collapsed
29
+ const isOpen = expanded || !needsToggle
30
+ return (
31
+ <div className="docs-react-demo-code" data-expanded={isOpen || undefined}>
32
+ <pre
33
+ id={panelId}
34
+ className={isOpen ? "docs-react-demo-code-body docs-react-demo-code-body-expanded" : "docs-react-demo-code-body"}
35
+ data-toggle={needsToggle || undefined}
36
+ style={{ "--docs-react-demo-code-lines": isOpen ? expandedCount : collapsed } as CSSProperties}
37
+ >
38
+ <HighlightedCode code={code} language={language} lineNumbers />
39
+ </pre>
40
+ {copy ? <CopyButton text={code} label={copyLabel} className="docs-react-demo-code-copy" /> : null}
41
+ {needsToggle ? (
42
+ <button
43
+ type="button"
44
+ className="docs-react-demo-code-toggle"
45
+ aria-controls={panelId}
46
+ aria-expanded={expanded}
47
+ onClick={() => setExpanded((value) => !value)}
48
+ >
49
+ {expanded ? "Hide Code" : "View Code"}
50
+ </button>
51
+ ) : null}
52
+ </div>
53
+ )
54
+ }
@@ -1,5 +1,5 @@
1
- import { useState, type ReactNode } from "react"
2
- import { LayoutGroup, motion } from "framer-motion"
1
+ import { useId, useMemo, useState, type ReactNode } from "react"
2
+ import { motion, useReducedMotion } from "framer-motion"
3
3
  import "katex/dist/katex.min.css"
4
4
  import {
5
5
  AlertTriangle,
@@ -7,12 +7,21 @@ import {
7
7
  ChevronRight,
8
8
  File,
9
9
  Info,
10
+ Terminal,
10
11
  XCircle,
11
12
  } from "lucide-react"
12
13
 
13
- import type { Block, DocumentNode, Inline, SourceRefNode } from "../gitbook/ast"
14
+ import type { Block, CodeBlockNode, DocumentNode, Inline, SourceRefNode } from "../gitbook/ast"
14
15
  import { resolveAsset } from "../assets"
15
16
  import { parseMarkdown } from "../gitbook/parse"
17
+ import { serializeMarkdown } from "../gitbook/serialize"
18
+ import { DocstreamDemoContext } from "../demo/context"
19
+ import { DemoBlock } from "../demo/DemoViewer"
20
+ import type { DemoResolver } from "../demo/types"
21
+ import { CopyButton } from "./copy"
22
+ import { rovingKeyDown } from "./controls"
23
+ import { DocPageActions, type DocPageActionsOptions } from "./PageActions"
24
+ import { useSyncedTab } from "./tabs-sync"
16
25
  import { ReplayPreview, isReplayQaUrl } from "../replay"
17
26
  import { VideoEmbed } from "../video"
18
27
  import { OpenApiOperation } from "../openapi/OpenApiOperation"
@@ -97,34 +106,96 @@ interface Renderers {
97
106
  sourceRenderer?: SourceReferenceRenderer
98
107
  }
99
108
 
109
+ const SHELL_LANGUAGES = new Set(["sh", "bash", "shell", "zsh", "console", "shellsession", "powershell", "ps1", "bat", "cmd"])
110
+
111
+ /** A tab set whose every tab is exactly one code block renders as one code block with tabs in its header. */
112
+ function codeOnlyTabs(block: Extract<Block, { type: "tabs" }>): CodeBlockNode[] | null {
113
+ if (!block.tabs.length) return null
114
+ const codes: CodeBlockNode[] = []
115
+ for (const tab of block.tabs) {
116
+ const only = tab.children.length === 1 ? tab.children[0] : null
117
+ if (!only || only.type !== "code" || only.live || only.language === "mermaid") return null
118
+ codes.push(only)
119
+ }
120
+ return codes
121
+ }
122
+
100
123
  function Tabs({ block, liveRenderer, sourceRenderer }: { block: Extract<Block, { type: "tabs" }> } & Renderers) {
101
- const [active, setActive] = useState(0)
102
- return (
103
- <div className="docs-tabs">
104
- <LayoutGroup>
105
- <div className="docs-tabs-header" role="tablist" aria-label="Content tabs">
106
- {block.tabs.map((t, i) => (
107
- <button
108
- key={i}
109
- type="button"
110
- role="tab"
111
- aria-selected={i === active}
112
- className={i === active ? "docs-tab-active" : ""}
113
- onClick={() => setActive(i)}
114
- >
115
- {i === active ? (
116
- <motion.span
117
- className="docs-tab-indicator"
118
- layoutId="docs-tab-indicator"
119
- transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
120
- />
121
- ) : null}
122
- <span className="docs-tab-label">{t.title}</span>
123
- </button>
124
- ))}
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()
129
+ 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 = (
138
+ <div
139
+ className="docs-tabs-header"
140
+ role="tablist"
141
+ aria-label={block.sync ? `${block.sync} options` : "Content tabs"}
142
+ onKeyDown={(event) => rovingKeyDown(event, ids, String(active), (id) => select(Number(id)))}
143
+ >
144
+ {block.tabs.map((t, i) => (
145
+ <button
146
+ key={i}
147
+ id={`${baseId}-tab-${i}`}
148
+ type="button"
149
+ role="tab"
150
+ data-roving=""
151
+ aria-selected={i === active}
152
+ aria-controls={`${baseId}-panel`}
153
+ tabIndex={i === active ? 0 : -1}
154
+ className={i === active ? "docs-tab-active" : ""}
155
+ onClick={() => select(i)}
156
+ >
157
+ {i === active ? (
158
+ <motion.span
159
+ className="docs-tab-indicator"
160
+ layoutId={`docs-tab-indicator-${baseId}`}
161
+ transition={reduced ? { duration: 0 } : { type: "spring", bounce: 0.2, duration: 0.6 }}
162
+ />
163
+ ) : null}
164
+ <span className="docs-tab-label">{t.title}</span>
165
+ </button>
166
+ ))}
167
+ </div>
168
+ )
169
+
170
+ if (codes) {
171
+ const code = codes[active]
172
+ const shell = codes.every((c) => c.language && SHELL_LANGUAGES.has(c.language.toLowerCase()))
173
+ const numbered = code.lineNumbers && code.code.includes("\n") && !shell
174
+ return (
175
+ <div className="docs-tabs docs-tabs-code" data-sync={block.sync}>
176
+ <div className="docs-tabs-code-head">
177
+ {shell ? (
178
+ <span className="docs-tabs-code-term" aria-hidden="true">
179
+ <Terminal width={12} height={12} strokeWidth={2.4} />
180
+ </span>
181
+ ) : null}
182
+ {header}
183
+ <span className="docs-tabs-code-title">{code.title}</span>
184
+ <CopyButton text={code.code} label={`Copy ${block.tabs[active]?.title ?? "code"}`} />
125
185
  </div>
126
- </LayoutGroup>
127
- <div className="docs-tabs-body" role="tabpanel">
186
+ <div className="docs-tabs-code-body" role="tabpanel" id={`${baseId}-panel`} aria-labelledby={`${baseId}-tab-${active}`}>
187
+ <pre className={numbered ? "docs-code-numbered" : ""}>
188
+ <HighlightedCode code={code.code} language={code.language} lineNumbers={numbered} />
189
+ </pre>
190
+ </div>
191
+ </div>
192
+ )
193
+ }
194
+
195
+ return (
196
+ <div className="docs-tabs" data-sync={block.sync}>
197
+ {header}
198
+ <div className="docs-tabs-body" role="tabpanel" id={`${baseId}-panel`} aria-labelledby={`${baseId}-tab-${active}`}>
128
199
  <Blocks blocks={block.tabs[active]?.children ?? []} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
129
200
  </div>
130
201
  </div>
@@ -173,8 +244,10 @@ function BlockView({ block, liveRenderer, sourceRenderer }: { block: Block } & R
173
244
  <div className="docs-code-header">
174
245
  <span>{block.title}</span>
175
246
  <span className="docs-code-lang">{block.language}</span>
247
+ <CopyButton text={block.code} label={block.title ? `Copy ${block.title}` : "Copy code"} />
176
248
  </div>
177
249
  )}
250
+ {!block.title && !block.language ? <CopyButton text={block.code} className="docs-code-copy-float" /> : null}
178
251
  <pre className={block.lineNumbers ? "docs-code-numbered" : ""}>
179
252
  <HighlightedCode
180
253
  code={block.code}
@@ -265,6 +338,8 @@ function BlockView({ block, liveRenderer, sourceRenderer }: { block: Block } & R
265
338
  <code>{block.mount}:{block.path}</code>
266
339
  </div>
267
340
  )
341
+ case "demo":
342
+ return <DemoBlock node={block} />
268
343
  case "columns":
269
344
  return (
270
345
  <div className="docs-columns">
@@ -388,28 +463,64 @@ function Blocks({ blocks, inline, liveRenderer, sourceRenderer }: { blocks: Bloc
388
463
  )
389
464
  }
390
465
 
466
+ export interface DocRenderOptions {
467
+ /** Resolves `{% demo %}` blocks. Provided through context, so nested renderers inherit it. */
468
+ demoResolver?: DemoResolver
469
+ /**
470
+ * Render "Copy page ▾" (Copy / View as Markdown, Open in ChatGPT / Claude) above the content.
471
+ * `true` uses the defaults; an object passes options to `DocPageActions`.
472
+ */
473
+ pageActions?: boolean | DocPageActionsOptions
474
+ }
475
+
476
+ function withDemoResolver(resolver: DemoResolver | undefined, children: ReactNode) {
477
+ return resolver ? <DocstreamDemoContext.Provider value={resolver}>{children}</DocstreamDemoContext.Provider> : children
478
+ }
479
+
480
+ export function PageActionsBar({ markdown, options }: { markdown: string | (() => string); options: boolean | DocPageActionsOptions | undefined }) {
481
+ if (!options) return null
482
+ return (
483
+ <div className="docs-page-actions-bar">
484
+ <DocPageActions markdown={markdown} {...(options === true ? {} : options)} />
485
+ </div>
486
+ )
487
+ }
488
+
391
489
  // Renders a markdown string inline — used for embedded markdown like
392
490
  // OpenAPI descriptions, which can themselves contain GitBook blocks.
393
491
  export function MarkdownContent({
394
492
  markdown,
395
493
  liveRenderer,
396
494
  sourceRenderer,
495
+ demoResolver,
496
+ pageActions,
397
497
  className,
398
- }: { markdown: string; className?: string } & Renderers) {
399
- const doc = parseMarkdown(markdown)
400
- return (
498
+ }: { markdown: string; className?: string } & Renderers & DocRenderOptions) {
499
+ const doc = useMemo(() => parseMarkdown(markdown), [markdown])
500
+ return withDemoResolver(
501
+ demoResolver,
401
502
  <div data-docstream="" className={className}>
503
+ <PageActionsBar markdown={markdown} options={pageActions} />
402
504
  <Blocks blocks={doc.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
403
505
  <CitationSources citations={doc.citations} />
404
- </div>
506
+ </div>,
405
507
  )
406
508
  }
407
509
 
408
- export function DocsRenderer({ doc, liveRenderer, sourceRenderer }: { doc: DocumentNode } & Renderers) {
409
- return (
510
+ export function DocsRenderer({
511
+ doc,
512
+ liveRenderer,
513
+ sourceRenderer,
514
+ demoResolver,
515
+ pageActions,
516
+ markdown,
517
+ }: { doc: DocumentNode; /** Source for page actions; defaults to `serializeMarkdown(doc)`. */ markdown?: string } & Renderers & DocRenderOptions) {
518
+ return withDemoResolver(
519
+ demoResolver,
410
520
  <article className="docs-article">
521
+ <PageActionsBar markdown={markdown ?? (() => serializeMarkdown(doc))} options={pageActions} />
411
522
  <Blocks blocks={doc.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
412
523
  <CitationSources citations={doc.citations} />
413
- </article>
524
+ </article>,
414
525
  )
415
526
  }