@brett_lamy/docstream 0.7.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.
- package/README.md +102 -0
- package/package.json +6 -2
- package/src/demo/DemoViewer.tsx +705 -0
- package/src/demo/context.ts +13 -0
- package/src/demo/glob.ts +188 -0
- package/src/demo/index.ts +15 -0
- package/src/demo/markdown.ts +102 -0
- package/src/demo/types.ts +53 -0
- package/src/docs/CollapsibleCode.tsx +54 -0
- package/src/docs/DocsRenderer.tsx +147 -36
- package/src/docs/PageActions.tsx +219 -0
- package/src/docs/controls.tsx +138 -0
- package/src/docs/copy.tsx +82 -0
- package/src/docs/tabs-sync.ts +61 -0
- package/src/gitbook/ast.ts +35 -0
- package/src/gitbook/index.ts +1 -1
- package/src/gitbook/parse.ts +48 -1
- package/src/gitbook/serialize.ts +16 -1
- package/src/index.ts +35 -2
- package/src/playground/PlaygroundStreamdown.tsx +14 -3
- package/src/playground/ReactCodePreview.tsx +8 -37
- package/src/streamdown.tsx +21 -3
- package/src/styles.css +917 -1
|
@@ -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
|
+
}
|
package/src/demo/glob.ts
ADDED
|
@@ -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 {
|
|
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 [
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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({
|
|
409
|
-
|
|
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
|
}
|