@brett_lamy/docstream 1.1.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +147 -9
- package/package.json +3 -1
- package/src/demo/DemoViewer.tsx +213 -46
- package/src/demo/context.ts +17 -1
- package/src/demo/glob.ts +12 -4
- package/src/demo/index.ts +15 -5
- package/src/demo/markdown.ts +138 -41
- package/src/demo/types.ts +46 -2
- package/src/docs/DocsRenderer.tsx +147 -34
- package/src/docs/PageActions.tsx +9 -5
- package/src/docs/controls.tsx +4 -1
- package/src/gitbook/ast.ts +63 -4
- package/src/gitbook/flatten.ts +47 -0
- package/src/gitbook/index.ts +6 -2
- package/src/gitbook/inline.ts +49 -19
- package/src/gitbook/outline.ts +45 -0
- package/src/gitbook/package-managers.ts +98 -0
- package/src/gitbook/parse.ts +179 -8
- package/src/gitbook/serialize.ts +40 -4
- package/src/index.ts +23 -1
- package/src/playground/InlineDemoPreview.tsx +77 -0
- package/src/playground/PlaygroundStreamdown.tsx +12 -2
- package/src/playground/ReactCodePreview.tsx +67 -36
- package/src/playground/filesystem.ts +83 -1
- package/src/playground/index.ts +7 -2
- package/src/streamdown.tsx +6 -2
- package/src/styles.css +117 -8
package/src/demo/markdown.ts
CHANGED
|
@@ -1,14 +1,33 @@
|
|
|
1
|
-
import type { DemoNode } from "../gitbook/ast"
|
|
2
|
-
import {
|
|
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
|
-
/**
|
|
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
|
|
11
|
-
*
|
|
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 =
|
|
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()
|
|
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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
|
|
61
|
-
|
|
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;
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const node =
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
131
|
-
|
|
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={
|
|
142
|
-
onKeyDown={(event) => rovingKeyDown(event, ids, String(active), (id) =>
|
|
139
|
+
aria-label={label}
|
|
140
|
+
onKeyDown={(event) => rovingKeyDown(event, ids, String(active), (id) => onSelect(Number(id)))}
|
|
143
141
|
>
|
|
144
|
-
{
|
|
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={() =>
|
|
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">{
|
|
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
|
-
{
|
|
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={
|
|
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
|
|
477
|
-
|
|
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
|
|
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
|
|
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} />
|
package/src/docs/PageActions.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
/* "Copy page ▾": copy the page as Markdown
|
|
2
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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() },
|
package/src/docs/controls.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* Small accessible controls shared by the demo viewer, tabs and page actions: a pill tab strip
|
|
2
2
|
(tabs semantics, roving focus) and a segmented radio group. */
|
|
3
|
-
import { useId, type KeyboardEvent, type ReactNode } from "react"
|
|
3
|
+
import { useId, type CSSProperties, type KeyboardEvent, type ReactNode } from "react"
|
|
4
4
|
import { motion, useReducedMotion } from "framer-motion"
|
|
5
5
|
|
|
6
6
|
export interface PillOption<T extends string> {
|
|
@@ -96,18 +96,21 @@ export function Segmented<T extends string>({
|
|
|
96
96
|
onChange,
|
|
97
97
|
label,
|
|
98
98
|
className,
|
|
99
|
+
style,
|
|
99
100
|
}: {
|
|
100
101
|
options: readonly PillOption<T>[]
|
|
101
102
|
value: T
|
|
102
103
|
onChange: (id: T) => void
|
|
103
104
|
label: string
|
|
104
105
|
className?: string
|
|
106
|
+
style?: CSSProperties
|
|
105
107
|
}) {
|
|
106
108
|
const layoutId = useId()
|
|
107
109
|
const reduced = useReducedMotion()
|
|
108
110
|
return (
|
|
109
111
|
<div
|
|
110
112
|
className={className ? `docs-pills docs-segmented ${className}` : "docs-pills docs-segmented"}
|
|
113
|
+
style={style}
|
|
111
114
|
role="radiogroup"
|
|
112
115
|
aria-label={label}
|
|
113
116
|
onKeyDown={(event) => rovingKeyDown(event, options.map((o) => o.id), value, onChange)}
|