@brett_lamy/docstream 1.19.1 → 1.20.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 +24 -0
- package/dist/demo/DemoViewer.d.ts.map +1 -1
- package/dist/demo/DemoViewer.js +20 -5
- package/dist/demo/DemoViewer.js.map +1 -1
- package/dist/demo/types.d.ts +2 -0
- package/dist/demo/types.d.ts.map +1 -1
- package/dist/doc-env.d.ts +60 -0
- package/dist/doc-env.d.ts.map +1 -0
- package/dist/doc-env.js +123 -0
- package/dist/doc-env.js.map +1 -0
- package/dist/docs/DocsRenderer.d.ts +14 -2
- package/dist/docs/DocsRenderer.d.ts.map +1 -1
- package/dist/docs/DocsRenderer.js +34 -12
- package/dist/docs/DocsRenderer.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/playground/InlineDemoPreview.d.ts +1 -1
- package/dist/playground/InlineDemoPreview.d.ts.map +1 -1
- package/dist/playground/InlineDemoPreview.js +5 -5
- package/dist/playground/InlineDemoPreview.js.map +1 -1
- package/dist/playground/PlaygroundStreamdown.d.ts.map +1 -1
- package/dist/playground/PlaygroundStreamdown.js +7 -1
- package/dist/playground/PlaygroundStreamdown.js.map +1 -1
- package/dist/playground/project.d.ts +9 -0
- package/dist/playground/project.d.ts.map +1 -1
- package/dist/playground/project.js +18 -0
- package/dist/playground/project.js.map +1 -1
- package/dist/site/DocsSite.d.ts +15 -0
- package/dist/site/DocsSite.d.ts.map +1 -1
- package/dist/site/DocsSite.js +51 -8
- package/dist/site/DocsSite.js.map +1 -1
- package/dist/site/index.d.ts +2 -2
- package/dist/site/index.d.ts.map +1 -1
- package/dist/site/index.js +1 -1
- package/dist/site/index.js.map +1 -1
- package/dist/site/site.css +67 -0
- package/dist/site/tree.d.ts +1 -1
- package/dist/site/tree.d.ts.map +1 -1
- package/dist/site/tree.js +3 -2
- package/dist/site/tree.js.map +1 -1
- package/dist/streamdown.d.ts +1 -1
- package/dist/streamdown.d.ts.map +1 -1
- package/dist/streamdown.js +2 -2
- package/dist/streamdown.js.map +1 -1
- package/dist/styles.css +89 -0
- package/package.json +1 -1
- package/src/demo/DemoViewer.tsx +23 -4
- package/src/demo/types.ts +2 -0
- package/src/doc-env.tsx +198 -0
- package/src/docs/DocsRenderer.tsx +48 -12
- package/src/index.ts +3 -1
- package/src/playground/InlineDemoPreview.tsx +5 -4
- package/src/playground/PlaygroundStreamdown.tsx +11 -2
- package/src/playground/project.ts +20 -0
- package/src/site/DocsSite.tsx +79 -5
- package/src/site/index.ts +2 -2
- package/src/site/site.css +67 -0
- package/src/site/tree.ts +3 -2
- package/src/streamdown.tsx +4 -0
- package/src/styles.css +89 -0
package/dist/styles.css
CHANGED
|
@@ -3214,6 +3214,95 @@
|
|
|
3214
3214
|
line-height: 1.5;
|
|
3215
3215
|
}
|
|
3216
3216
|
|
|
3217
|
+
/* An example waiting for the page's env variables (frontmatter `env`). */
|
|
3218
|
+
.docs-demo-canvas-env {
|
|
3219
|
+
display: grid;
|
|
3220
|
+
place-items: center;
|
|
3221
|
+
padding: 20px 14px;
|
|
3222
|
+
}
|
|
3223
|
+
.docs-env-gate {
|
|
3224
|
+
display: grid;
|
|
3225
|
+
gap: 10px;
|
|
3226
|
+
width: min(100%, 420px);
|
|
3227
|
+
padding: 14px 16px;
|
|
3228
|
+
border: 1px solid var(--gb-border);
|
|
3229
|
+
border-radius: 10px;
|
|
3230
|
+
background: var(--gb-background);
|
|
3231
|
+
color: var(--gb-foreground);
|
|
3232
|
+
font-size: 13.5px;
|
|
3233
|
+
line-height: 1.45;
|
|
3234
|
+
}
|
|
3235
|
+
.docs-env-gate-head {
|
|
3236
|
+
display: flex;
|
|
3237
|
+
align-items: center;
|
|
3238
|
+
gap: 8px;
|
|
3239
|
+
font-weight: 600;
|
|
3240
|
+
}
|
|
3241
|
+
.docs-env-gate-icon {
|
|
3242
|
+
display: inline-grid;
|
|
3243
|
+
place-items: center;
|
|
3244
|
+
width: 26px;
|
|
3245
|
+
height: 26px;
|
|
3246
|
+
border-radius: 50%;
|
|
3247
|
+
background: color-mix(in oklab, var(--gb-primary) 14%, transparent);
|
|
3248
|
+
color: var(--gb-primary);
|
|
3249
|
+
flex: none;
|
|
3250
|
+
}
|
|
3251
|
+
.docs-env-gate-list {
|
|
3252
|
+
display: grid;
|
|
3253
|
+
gap: 4px;
|
|
3254
|
+
margin: 0;
|
|
3255
|
+
padding: 0;
|
|
3256
|
+
list-style: none;
|
|
3257
|
+
}
|
|
3258
|
+
.docs-env-gate-list li {
|
|
3259
|
+
display: flex;
|
|
3260
|
+
align-items: baseline;
|
|
3261
|
+
justify-content: space-between;
|
|
3262
|
+
gap: 12px;
|
|
3263
|
+
}
|
|
3264
|
+
.docs-env-gate-list span,
|
|
3265
|
+
.docs-env-gate-note {
|
|
3266
|
+
color: var(--gb-muted-foreground);
|
|
3267
|
+
font-size: 12.5px;
|
|
3268
|
+
}
|
|
3269
|
+
.docs-env-gate-form {
|
|
3270
|
+
display: grid;
|
|
3271
|
+
gap: 8px;
|
|
3272
|
+
}
|
|
3273
|
+
.docs-env-gate-form label {
|
|
3274
|
+
display: grid;
|
|
3275
|
+
gap: 4px;
|
|
3276
|
+
}
|
|
3277
|
+
.docs-env-gate-form input {
|
|
3278
|
+
font: inherit;
|
|
3279
|
+
padding: 6px 8px;
|
|
3280
|
+
border: 1px solid var(--gb-border);
|
|
3281
|
+
border-radius: 6px;
|
|
3282
|
+
background: transparent;
|
|
3283
|
+
color: inherit;
|
|
3284
|
+
}
|
|
3285
|
+
.docs-env-gate-button {
|
|
3286
|
+
justify-self: start;
|
|
3287
|
+
font: inherit;
|
|
3288
|
+
font-weight: 600;
|
|
3289
|
+
padding: 6px 14px;
|
|
3290
|
+
border: 0;
|
|
3291
|
+
border-radius: 999px;
|
|
3292
|
+
background: var(--gb-primary);
|
|
3293
|
+
color: var(--gb-primary-foreground, #fff);
|
|
3294
|
+
cursor: pointer;
|
|
3295
|
+
}
|
|
3296
|
+
.docs-env-gate-button:disabled {
|
|
3297
|
+
opacity: 0.6;
|
|
3298
|
+
cursor: default;
|
|
3299
|
+
}
|
|
3300
|
+
.docs-env-gate-error {
|
|
3301
|
+
margin: 0;
|
|
3302
|
+
color: var(--gb-destructive, #d93025);
|
|
3303
|
+
font-size: 12.5px;
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3217
3306
|
.docs-demo-loading {
|
|
3218
3307
|
display: grid;
|
|
3219
3308
|
place-items: center;
|
package/package.json
CHANGED
package/src/demo/DemoViewer.tsx
CHANGED
|
@@ -42,6 +42,7 @@ import { PathBreadcrumb } from "./PathBreadcrumb"
|
|
|
42
42
|
import { languageForPath } from "./glob"
|
|
43
43
|
import { useDemoVFSContext, useDemoVFSEntry, type DemoVFS } from "./vfs"
|
|
44
44
|
import { cssToSurface, surfaceStyle, surfaceToCss } from "./surface"
|
|
45
|
+
import { EnvGate, useEnvGate } from "../doc-env"
|
|
45
46
|
import type { DemoComponent, DemoFile, DemoMeta, DemoResolver, DemoVariant, InlineDemoRuntime } from "./types"
|
|
46
47
|
|
|
47
48
|
export interface DemoViewerProps {
|
|
@@ -275,6 +276,7 @@ type PreviewSource =
|
|
|
275
276
|
| { kind: "runtime"; render: (variant: string | undefined) => ReactNode }
|
|
276
277
|
| { kind: "streaming" }
|
|
277
278
|
| { kind: "unavailable" }
|
|
279
|
+
| { kind: "env"; names: readonly string[]; title: string }
|
|
278
280
|
|
|
279
281
|
export const NO_RUNTIME_NOTE = "Live preview needs the playground runtime. The code is shown instead."
|
|
280
282
|
|
|
@@ -292,6 +294,13 @@ function DemoCanvas({ src, preview, variant, bleed, look }: {
|
|
|
292
294
|
bleed?: boolean
|
|
293
295
|
look?: CanvasLook
|
|
294
296
|
}) {
|
|
297
|
+
if (preview.kind === "env") {
|
|
298
|
+
return (
|
|
299
|
+
<div className="docs-demo-canvas docs-demo-canvas-env" data-variant={variant}>
|
|
300
|
+
<EnvGate names={preview.names} title={preview.title} />
|
|
301
|
+
</div>
|
|
302
|
+
)
|
|
303
|
+
}
|
|
295
304
|
if (preview.kind === "unavailable" || preview.kind === "streaming") {
|
|
296
305
|
return (
|
|
297
306
|
<div className="docs-demo-note" role="note" data-variant={variant}>
|
|
@@ -733,7 +742,13 @@ export function DemoViewer(props: DemoViewerProps) {
|
|
|
733
742
|
const hookResolver = tryResolver ? resolver : undefined
|
|
734
743
|
const meta = useMeta(hookResolver, src)
|
|
735
744
|
const resolvedFiles = useFiles(hookResolver, src, near)
|
|
736
|
-
|
|
745
|
+
// Declared env variables the demo's sources use gate it: the module isn't even imported until they're loaded.
|
|
746
|
+
const sourceTexts = useMemo(() => {
|
|
747
|
+
const list = resolvedFiles.status === "ready" ? resolvedFiles.value : !tryResolver || resolvedFiles.status === "error" ? inlineFiles : null
|
|
748
|
+
return list ? list.map((f) => f.content) : null
|
|
749
|
+
}, [tryResolver, inlineFiles, resolvedFiles])
|
|
750
|
+
const envGate = useEnvGate(sourceTexts)
|
|
751
|
+
const component = useComponent(hookResolver, src, near && envGate.open)
|
|
737
752
|
const resolverFailed = meta.status === "error" || resolvedFiles.status === "error" || component.status === "error"
|
|
738
753
|
const mode: "resolver" | "inline" | "none" =
|
|
739
754
|
tryResolver && !(inlineFiles && resolverFailed) ? "resolver" : inlineFiles ? "inline" : "none"
|
|
@@ -766,13 +781,17 @@ export function DemoViewer(props: DemoViewerProps) {
|
|
|
766
781
|
const baseId = useId()
|
|
767
782
|
|
|
768
783
|
const { runtime, dependencies } = runtimeOptions
|
|
784
|
+
const envValues = envGate.values
|
|
785
|
+
const envMissing = envGate.missing
|
|
769
786
|
const preview: PreviewSource = useMemo(() => {
|
|
787
|
+
if (mode === "inline" && props.streaming) return { kind: "streaming" }
|
|
788
|
+
if (envMissing?.length) return { kind: "env", names: envMissing, title }
|
|
770
789
|
if (mode !== "inline") return { kind: "component", component }
|
|
771
|
-
if (props.streaming) return { kind: "streaming" }
|
|
772
790
|
if (!runtime || !inlineFiles) return { kind: "unavailable" }
|
|
773
791
|
const entry = inlineFiles[0].path
|
|
774
|
-
|
|
775
|
-
|
|
792
|
+
const env = Object.keys(envValues).length ? { env: envValues } : {}
|
|
793
|
+
return { kind: "runtime", render: (v) => runtime({ src, title, files: inlineFiles, entry, variant: v, dependencies, ...env }) }
|
|
794
|
+
}, [mode, component, props.streaming, runtime, inlineFiles, src, title, dependencies, envMissing, envValues])
|
|
776
795
|
const runnable = preview.kind !== "unavailable"
|
|
777
796
|
|
|
778
797
|
if (mode === "none") {
|
package/src/demo/types.ts
CHANGED
|
@@ -80,6 +80,8 @@ export interface InlineDemoRuntimeProps {
|
|
|
80
80
|
variant?: string
|
|
81
81
|
/** Extra npm dependencies (`{ "@brett_lamy/ui": "^1.2.0" }`) the files may import. */
|
|
82
82
|
dependencies?: Record<string, string>
|
|
83
|
+
/** The page's environment variables this demo uses (frontmatter `env`), as `process.env` for the demo. */
|
|
84
|
+
env?: Readonly<Record<string, string>>
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
/**
|
package/src/doc-env.tsx
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/* Environment variables for a document's examples.
|
|
2
|
+
|
|
3
|
+
A page declares the variables its examples need in its frontmatter:
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
env: [GITHUB_TOKEN, NETLIFY_AUTH_TOKEN]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
An example (a `{% demo %}` or a live code fence) whose source mentions one of them does not
|
|
10
|
+
run straight away: it shows a gate that asks the host for the values — the host's
|
|
11
|
+
`DocstreamEnvHost.request` can read them from a keychain or start a login — and runs once it
|
|
12
|
+
has them. In-page demos read the values with `useDocstreamEnv()`; examples that run in
|
|
13
|
+
almost-node get them as `process.env.NAME`. Without a host the gate asks for the values
|
|
14
|
+
directly and keeps them in memory for the page. */
|
|
15
|
+
import { createContext, useCallback, useContext, useMemo, useRef, useState, type FormEvent, type ReactNode } from "react"
|
|
16
|
+
|
|
17
|
+
export type DocstreamEnvValues = Readonly<Record<string, string>>
|
|
18
|
+
|
|
19
|
+
/** How the host supplies values: a keychain, a login, a secrets manager. */
|
|
20
|
+
export interface DocstreamEnvHost {
|
|
21
|
+
/**
|
|
22
|
+
* Resolves with the values it could get (unlocking a keychain or signing in as needed). Names it
|
|
23
|
+
* leaves out stay missing and the example stays gated; a rejection is shown on the gate.
|
|
24
|
+
*/
|
|
25
|
+
request: (names: readonly string[]) => Promise<Record<string, string>>
|
|
26
|
+
/** The gate's button, e.g. "Load from Keychain". Default "Load Variables". */
|
|
27
|
+
label?: string
|
|
28
|
+
/** A short line about one variable, e.g. "Sign in with GitHub" — shown beside its name. */
|
|
29
|
+
describe?: (name: string) => string | undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DocstreamEnv {
|
|
33
|
+
/** Variables this document declares. */
|
|
34
|
+
declared: readonly string[]
|
|
35
|
+
/** Values loaded so far (shared by every example on the page). */
|
|
36
|
+
values: DocstreamEnvValues
|
|
37
|
+
/** Asks the host (or the reader) for `names`; resolves with every value now known. */
|
|
38
|
+
request: (names: readonly string[]) => Promise<DocstreamEnvValues>
|
|
39
|
+
host?: DocstreamEnvHost
|
|
40
|
+
/** Keeps values the reader typed into a host-less gate. */
|
|
41
|
+
provide: (values: Record<string, string>) => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const EMPTY: DocstreamEnvValues = Object.freeze({})
|
|
45
|
+
const noRequest = () => Promise.resolve(EMPTY)
|
|
46
|
+
|
|
47
|
+
const DocstreamEnvContext = createContext<DocstreamEnv>({ declared: [], values: EMPTY, request: noRequest, provide: () => {} })
|
|
48
|
+
|
|
49
|
+
/** The page's variables and the values loaded so far. Demos call it to read their secrets. */
|
|
50
|
+
export function useDocstreamEnv(): DocstreamEnv {
|
|
51
|
+
return useContext(DocstreamEnvContext)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reads `env` from parsed frontmatter values: a list, or one comma/space separated string. */
|
|
55
|
+
export function envNamesFromProperties(values: Record<string, unknown> | undefined): string[] {
|
|
56
|
+
const raw = values?.env
|
|
57
|
+
const list = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[\s,]+/) : []
|
|
58
|
+
return [...new Set(list.filter((v): v is string => typeof v === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(v)))]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The declared names `sources` mention (as whole identifiers), in declaration order. */
|
|
62
|
+
export function usedEnvNames(sources: readonly string[], declared: readonly string[]): string[] {
|
|
63
|
+
if (!declared.length) return []
|
|
64
|
+
const text = sources.join("\n")
|
|
65
|
+
return declared.filter((name) => new RegExp(`(^|[^A-Za-z0-9_$])${name}(?![A-Za-z0-9_$])`).test(text))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface DocstreamEnvProviderProps {
|
|
69
|
+
/** Names declared by the document (merged with any declared further up). */
|
|
70
|
+
declared?: readonly string[]
|
|
71
|
+
host?: DocstreamEnvHost
|
|
72
|
+
children?: ReactNode
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Holds a page's variables. The renderers add one from the frontmatter's `env`; a host may wrap
|
|
77
|
+
* the page in another to supply `host` (or pass `envHost` to the renderer). Values loaded by an
|
|
78
|
+
* outer provider are shared with inner ones.
|
|
79
|
+
*/
|
|
80
|
+
export function DocstreamEnvProvider({ declared = [], host, children }: DocstreamEnvProviderProps) {
|
|
81
|
+
const outer = useContext(DocstreamEnvContext)
|
|
82
|
+
const [own, setOwn] = useState<DocstreamEnvValues>(EMPTY)
|
|
83
|
+
const effectiveHost = host ?? outer.host
|
|
84
|
+
const values = useMemo(() => (own === EMPTY ? outer.values : { ...outer.values, ...own }), [outer.values, own])
|
|
85
|
+
const valuesRef = useRef(values)
|
|
86
|
+
valuesRef.current = values
|
|
87
|
+
const declaredKey = declared.join(",")
|
|
88
|
+
const allDeclared = useMemo(
|
|
89
|
+
() => [...new Set([...outer.declared, ...declared])],
|
|
90
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
91
|
+
[outer.declared, declaredKey],
|
|
92
|
+
)
|
|
93
|
+
const provide = useCallback((next: Record<string, string>) => setOwn((v) => ({ ...v, ...next })), [])
|
|
94
|
+
const request = useCallback(async (names: readonly string[]) => {
|
|
95
|
+
const missing = names.filter((n) => valuesRef.current[n] === undefined)
|
|
96
|
+
if (!missing.length || !effectiveHost) return valuesRef.current
|
|
97
|
+
const got = await effectiveHost.request(missing)
|
|
98
|
+
const kept = Object.fromEntries(Object.entries(got).filter(([k, v]) => missing.includes(k) && typeof v === "string"))
|
|
99
|
+
setOwn((v) => ({ ...v, ...kept }))
|
|
100
|
+
return { ...valuesRef.current, ...kept }
|
|
101
|
+
}, [effectiveHost])
|
|
102
|
+
const value = useMemo<DocstreamEnv>(
|
|
103
|
+
() => ({ declared: allDeclared, values, request, provide, ...(effectiveHost ? { host: effectiveHost } : {}) }),
|
|
104
|
+
[allDeclared, values, request, provide, effectiveHost],
|
|
105
|
+
)
|
|
106
|
+
return <DocstreamEnvContext.Provider value={value}>{children}</DocstreamEnvContext.Provider>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Which of an example's sources' declared variables are still missing. */
|
|
110
|
+
export function useEnvGate(sources: readonly string[] | null) {
|
|
111
|
+
const env = useDocstreamEnv()
|
|
112
|
+
const key = sources ? sources.join("\u0000") : null
|
|
113
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
114
|
+
const used = useMemo(() => (sources ? usedEnvNames(sources, env.declared) : null), [key, env.declared])
|
|
115
|
+
// A page that declares nothing never waits for its examples' sources.
|
|
116
|
+
const missing = !env.declared.length ? [] : used ? used.filter((n) => env.values[n] === undefined) : null
|
|
117
|
+
const values = useMemo(() => {
|
|
118
|
+
if (!used?.length) return EMPTY
|
|
119
|
+
return Object.fromEntries(used.map((n) => [n, env.values[n]!]).filter(([, v]) => v !== undefined)) as DocstreamEnvValues
|
|
120
|
+
}, [used, env.values])
|
|
121
|
+
return {
|
|
122
|
+
/** Unknown until the sources are; then the declared names they use. */
|
|
123
|
+
used,
|
|
124
|
+
/** null while the sources are loading. */
|
|
125
|
+
missing,
|
|
126
|
+
/** The example may run: no declared variable used, or all of them loaded. */
|
|
127
|
+
open: missing !== null && missing.length === 0,
|
|
128
|
+
/** The values of the variables this example uses. */
|
|
129
|
+
values,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The card an example shows until its variables are loaded. */
|
|
134
|
+
export function EnvGate({ names, title }: { names: readonly string[]; title?: string }) {
|
|
135
|
+
const env = useDocstreamEnv()
|
|
136
|
+
const [busy, setBusy] = useState(false)
|
|
137
|
+
const [error, setError] = useState<string | null>(null)
|
|
138
|
+
const [typed, setTyped] = useState<Record<string, string>>({})
|
|
139
|
+
const host = env.host
|
|
140
|
+
|
|
141
|
+
const load = async () => {
|
|
142
|
+
setBusy(true)
|
|
143
|
+
setError(null)
|
|
144
|
+
try {
|
|
145
|
+
const got = await env.request(names)
|
|
146
|
+
const still = names.filter((n) => got[n] === undefined)
|
|
147
|
+
if (still.length) setError(`Still missing ${still.join(", ")}.`)
|
|
148
|
+
} catch (cause) {
|
|
149
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
150
|
+
} finally {
|
|
151
|
+
setBusy(false)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const submit = (event: FormEvent) => {
|
|
155
|
+
event.preventDefault()
|
|
156
|
+
const filled = Object.fromEntries(names.map((n) => [n, typed[n] ?? ""]).filter(([, v]) => v))
|
|
157
|
+
if (Object.keys(filled).length === names.length) env.provide(filled)
|
|
158
|
+
else setError("Fill in every variable to run the example.")
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div className="docs-env-gate" role="group" aria-label={`${title ?? "Example"} needs environment variables`} data-docstream-env-gate="">
|
|
163
|
+
<div className="docs-env-gate-head">
|
|
164
|
+
<span className="docs-env-gate-icon" aria-hidden="true">
|
|
165
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
166
|
+
<circle cx="7.5" cy="15.5" r="5.5" /><path d="m21 2-9.6 9.6" /><path d="m15.5 7.5 3 3L22 7l-3-3" />
|
|
167
|
+
</svg>
|
|
168
|
+
</span>
|
|
169
|
+
<span>This example needs {names.length === 1 ? "a variable" : `${names.length} variables`} before it runs.</span>
|
|
170
|
+
</div>
|
|
171
|
+
{host ? (
|
|
172
|
+
<>
|
|
173
|
+
<ul className="docs-env-gate-list">
|
|
174
|
+
{names.map((n) => (
|
|
175
|
+
<li key={n}><code>{n}</code>{host.describe?.(n) ? <span>{host.describe(n)}</span> : null}</li>
|
|
176
|
+
))}
|
|
177
|
+
</ul>
|
|
178
|
+
<button type="button" className="docs-env-gate-button" onClick={() => void load()} disabled={busy}>
|
|
179
|
+
{busy ? "Waiting…" : host.label ?? "Load Variables"}
|
|
180
|
+
</button>
|
|
181
|
+
</>
|
|
182
|
+
) : (
|
|
183
|
+
<form className="docs-env-gate-form" onSubmit={submit}>
|
|
184
|
+
{names.map((n) => (
|
|
185
|
+
<label key={n}>
|
|
186
|
+
<code>{n}</code>
|
|
187
|
+
<input type="password" autoComplete="off" spellCheck={false} value={typed[n] ?? ""}
|
|
188
|
+
onChange={(e) => setTyped((t) => ({ ...t, [n]: e.target.value }))} />
|
|
189
|
+
</label>
|
|
190
|
+
))}
|
|
191
|
+
<button type="submit" className="docs-env-gate-button">Run Example</button>
|
|
192
|
+
<span className="docs-env-gate-note">Kept in memory for this page only.</span>
|
|
193
|
+
</form>
|
|
194
|
+
)}
|
|
195
|
+
{error ? <p className="docs-env-gate-error" role="alert">{error}</p> : null}
|
|
196
|
+
</div>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
@@ -40,6 +40,8 @@ import { ImageThumbnail, leadingThumbnail, thumbnailLabel } from "./ImageThumbna
|
|
|
40
40
|
import { DocumentProperties } from "./DocumentProperties"
|
|
41
41
|
import { TaskCheckbox, TaskStateChangeContext, type TaskStateChangeHandler } from "./TaskCheckbox"
|
|
42
42
|
import { getTaskState } from "../gitbook/tasks"
|
|
43
|
+
import { readDocumentProperties } from "../gitbook/properties"
|
|
44
|
+
import { DocstreamEnvProvider, EnvGate, envNamesFromProperties, useEnvGate, type DocstreamEnvHost } from "../doc-env"
|
|
43
45
|
import { plainText } from "../gitbook/inline"
|
|
44
46
|
|
|
45
47
|
type MathRenderer = (formula: string) => string
|
|
@@ -88,6 +90,8 @@ export interface LivePreviewProps {
|
|
|
88
90
|
title: string
|
|
89
91
|
collapsedCodeLines?: number
|
|
90
92
|
expandedCodeLines?: number
|
|
93
|
+
/** The page's environment variables this example uses (frontmatter `env`), once loaded. */
|
|
94
|
+
env?: Readonly<Record<string, string>>
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
export type LivePreviewRenderer = (props: LivePreviewProps) => ReactNode
|
|
@@ -380,14 +384,7 @@ function BlockView({ block, liveRenderer, sourceRenderer }: { block: Block } & R
|
|
|
380
384
|
return <StrudelPlayer code={block.code} {...(block.title ? { title: block.title } : {})} />
|
|
381
385
|
}
|
|
382
386
|
if (block.live && isReactLanguage(block.language) && liveRenderer) {
|
|
383
|
-
|
|
384
|
-
return liveRenderer({
|
|
385
|
-
files: { [entry]: block.code },
|
|
386
|
-
entry,
|
|
387
|
-
title: block.title ?? "Live React preview",
|
|
388
|
-
collapsedCodeLines: block.collapsedCodeLines,
|
|
389
|
-
expandedCodeLines: block.expandedCodeLines,
|
|
390
|
-
})
|
|
387
|
+
return <LiveFence block={block} liveRenderer={liveRenderer} />
|
|
391
388
|
}
|
|
392
389
|
return (
|
|
393
390
|
<div className="docs-code" data-language={block.language ?? undefined}>
|
|
@@ -673,6 +670,23 @@ function withoutLinks(nodes: Inline[]): Inline[] {
|
|
|
673
670
|
})
|
|
674
671
|
}
|
|
675
672
|
|
|
673
|
+
/** A live code fence: gated on the page's env variables it mentions, then handed to the host's renderer. */
|
|
674
|
+
function LiveFence({ block, liveRenderer }: { block: CodeBlockNode; liveRenderer: LivePreviewRenderer }) {
|
|
675
|
+
const sources = useMemo(() => [block.code], [block.code])
|
|
676
|
+
const gate = useEnvGate(sources)
|
|
677
|
+
const title = block.title ?? "Live React preview"
|
|
678
|
+
if (gate.missing?.length) return <EnvGate names={gate.missing} title={title} />
|
|
679
|
+
const entry = block.entry ?? "/src/main.jsx"
|
|
680
|
+
return liveRenderer({
|
|
681
|
+
files: { [entry]: block.code },
|
|
682
|
+
entry,
|
|
683
|
+
title,
|
|
684
|
+
collapsedCodeLines: block.collapsedCodeLines,
|
|
685
|
+
expandedCodeLines: block.expandedCodeLines,
|
|
686
|
+
...(gate.used?.length ? { env: gate.values } : {}),
|
|
687
|
+
})
|
|
688
|
+
}
|
|
689
|
+
|
|
676
690
|
export interface DocRenderOptions {
|
|
677
691
|
/** Make rendered task checkboxes interactive. Update the supplied item and re-render to persist a choice. */
|
|
678
692
|
onTaskStateChange?: TaskStateChangeHandler
|
|
@@ -699,6 +713,24 @@ export interface DocRenderOptions {
|
|
|
699
713
|
* `true` uses the defaults; an object passes options to `DocPageActions`.
|
|
700
714
|
*/
|
|
701
715
|
pageActions?: boolean | DocPageActionsOptions
|
|
716
|
+
/**
|
|
717
|
+
* Supplies the values of the environment variables the page declares (frontmatter `env`), e.g.
|
|
718
|
+
* from a keychain or a login. Without one, gated examples ask the reader for the values.
|
|
719
|
+
*/
|
|
720
|
+
envHost?: DocstreamEnvHost
|
|
721
|
+
/** Variables to declare in addition to the frontmatter's `env`. */
|
|
722
|
+
env?: readonly string[]
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** The variables a document declares in its frontmatter's `env`. */
|
|
726
|
+
export function documentEnv(doc: DocumentNode): string[] {
|
|
727
|
+
return doc.properties ? envNamesFromProperties(readDocumentProperties(doc.properties).values) : []
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function withEnv(doc: DocumentNode, { envHost, env }: DocRenderOptions, children: ReactNode) {
|
|
731
|
+
const declared = [...new Set([...(env ?? []), ...documentEnv(doc)])]
|
|
732
|
+
if (!declared.length && !envHost) return children
|
|
733
|
+
return <DocstreamEnvProvider declared={declared} host={envHost}>{children}</DocstreamEnvProvider>
|
|
702
734
|
}
|
|
703
735
|
|
|
704
736
|
function withDemoContext({ demoResolver, demoRuntime, demoDependencies, demoVFS, onTaskStateChange }: DocRenderOptions, children: ReactNode) {
|
|
@@ -749,16 +781,18 @@ export function MarkdownContent({
|
|
|
749
781
|
className,
|
|
750
782
|
documentProperties = true,
|
|
751
783
|
onTaskStateChange,
|
|
784
|
+
envHost,
|
|
785
|
+
env,
|
|
752
786
|
}: { markdown: string; className?: string } & Renderers & DocRenderOptions) {
|
|
753
787
|
const doc = useMemo(() => parseMarkdown(markdown), [markdown])
|
|
754
788
|
return withDemoContext(
|
|
755
789
|
{ demoResolver, demoRuntime, demoDependencies, demoVFS, onTaskStateChange },
|
|
756
|
-
<div data-docstream="" className={className}>
|
|
790
|
+
withEnv(doc, { envHost, env }, <div data-docstream="" className={className}>
|
|
757
791
|
<PageActionsBar markdown={markdown} options={pageActions} />
|
|
758
792
|
{documentProperties && doc.properties ? <DocumentProperties properties={doc.properties} defaultOpen={typeof documentProperties === "object" && documentProperties.defaultOpen} /> : null}
|
|
759
793
|
<Blocks blocks={doc.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
|
|
760
794
|
<CitationSources citations={doc.citations} />
|
|
761
|
-
</div
|
|
795
|
+
</div>),
|
|
762
796
|
)
|
|
763
797
|
}
|
|
764
798
|
|
|
@@ -774,15 +808,17 @@ export function DocsRenderer({
|
|
|
774
808
|
markdown,
|
|
775
809
|
documentProperties = true,
|
|
776
810
|
onTaskStateChange,
|
|
811
|
+
envHost,
|
|
812
|
+
env,
|
|
777
813
|
}: { doc: DocumentNode; /** Source for page actions; defaults to `serializeMarkdown(doc)`. */ markdown?: string } & Renderers & DocRenderOptions) {
|
|
778
814
|
return withDemoContext(
|
|
779
815
|
{ demoResolver, demoRuntime, demoDependencies, demoVFS, onTaskStateChange },
|
|
780
|
-
<article className="docs-article">
|
|
816
|
+
withEnv(doc, { envHost, env }, <article className="docs-article">
|
|
781
817
|
<PageActionsBar markdown={markdown ?? (() => serializeMarkdown(doc))} options={pageActions} />
|
|
782
818
|
{documentProperties && doc.properties ? <DocumentProperties properties={doc.properties} defaultOpen={typeof documentProperties === "object" && documentProperties.defaultOpen} /> : null}
|
|
783
819
|
<Blocks blocks={doc.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
|
|
784
820
|
<CitationSources citations={doc.citations} />
|
|
785
|
-
</article
|
|
821
|
+
</article>),
|
|
786
822
|
)
|
|
787
823
|
}
|
|
788
824
|
|
package/src/index.ts
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// inline-file demos run only when the host passes a runtime — see `@brett_lamy/docstream/playground`.
|
|
3
3
|
export { GitbookStreamdown } from "./streamdown"
|
|
4
4
|
export type { GitbookStreamdownProps } from "./streamdown"
|
|
5
|
-
export { DocsRenderer, MarkdownContent, MathFormula } from "./docs/DocsRenderer"
|
|
5
|
+
export { DocsRenderer, MarkdownContent, MathFormula, documentEnv } from "./docs/DocsRenderer"
|
|
6
6
|
export { TaskCheckbox, TASK_STATE_ICONS } from "./docs/TaskCheckbox"
|
|
7
7
|
export type { TaskCheckboxProps, TaskStateChangeHandler } from "./docs/TaskCheckbox"
|
|
8
8
|
export { TASK_STATES, getTaskState, taskMarker, taskStateFromMarker } from "./gitbook/tasks"
|
|
9
|
+
export { DocstreamEnvProvider, EnvGate, envNamesFromProperties, useDocstreamEnv, useEnvGate, usedEnvNames } from "./doc-env"
|
|
10
|
+
export type { DocstreamEnv, DocstreamEnvHost, DocstreamEnvProviderProps, DocstreamEnvValues } from "./doc-env"
|
|
9
11
|
export { DocumentProperties } from "./docs/DocumentProperties"
|
|
10
12
|
export type { DocumentPropertiesProps } from "./docs/DocumentProperties"
|
|
11
13
|
export { readDocumentProperties, removeDocumentProperty, setDocumentProperty } from "./gitbook/properties"
|
|
@@ -16,8 +16,8 @@ export interface InlineDemoPreviewProps extends InlineDemoRuntimeProps {
|
|
|
16
16
|
workspaceOptions?: Partial<AlmostNodeWorkspaceOptions>
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
function filesKey(files: DemoFile[], entry: string, dependencies: Record<string, string> | undefined) {
|
|
20
|
-
return JSON.stringify([entry, dependencies ?? null, files.map((f) => [f.path, f.content])])
|
|
19
|
+
function filesKey(files: DemoFile[], entry: string, dependencies: Record<string, string> | undefined, env: InlineDemoRuntimeProps["env"]) {
|
|
20
|
+
return JSON.stringify([entry, dependencies ?? null, env ?? null, files.map((f) => [f.path, f.content])])
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export function InlineDemoPreview({
|
|
@@ -26,16 +26,17 @@ export function InlineDemoPreview({
|
|
|
26
26
|
entry,
|
|
27
27
|
variant,
|
|
28
28
|
dependencies,
|
|
29
|
+
env,
|
|
29
30
|
port = 4173,
|
|
30
31
|
sandbox = "allow-scripts allow-same-origin allow-forms allow-modals",
|
|
31
32
|
workspaceOptions,
|
|
32
33
|
}: InlineDemoPreviewProps) {
|
|
33
34
|
// Content-keyed so re-renders (variant switches, parent state) never restart Vite.
|
|
34
|
-
const key = filesKey(files, entry, dependencies)
|
|
35
|
+
const key = filesKey(files, entry, dependencies, env)
|
|
35
36
|
const project = useMemo(
|
|
36
37
|
() => {
|
|
37
38
|
try {
|
|
38
|
-
return { value: createInlineDemoProject(files, { entry, dependencies }) }
|
|
39
|
+
return { value: createInlineDemoProject(files, { entry, dependencies, ...(env ? { env } : {}) }) }
|
|
39
40
|
} catch (error) {
|
|
40
41
|
return { error: error instanceof Error ? error.message : String(error) }
|
|
41
42
|
}
|
|
@@ -5,6 +5,7 @@ import { DocstreamDemoRuntimeContext } from "../demo/context"
|
|
|
5
5
|
import { GitbookStreamdown, type GitbookStreamdownProps } from "../streamdown"
|
|
6
6
|
import { almostNodeDemoRuntime } from "./InlineDemoPreview"
|
|
7
7
|
import { ReactCodePreview } from "./ReactCodePreview"
|
|
8
|
+
import { injectEnv } from "./project"
|
|
8
9
|
|
|
9
10
|
export type PlaygroundStreamdownProps = GitbookStreamdownProps
|
|
10
11
|
|
|
@@ -12,9 +13,11 @@ export type PlaygroundStreamdownProps = GitbookStreamdownProps
|
|
|
12
13
|
* A `LivePreviewRenderer` that runs `live` React/JSX/TSX code fences in almost-node.
|
|
13
14
|
* Pass it as `liveRenderer` to the root `GitbookStreamdown` / `DocsRenderer` (or the editor).
|
|
14
15
|
*/
|
|
15
|
-
export const almostNodeLiveRenderer: LivePreviewRenderer = ({ files, entry, title, collapsedCodeLines, expandedCodeLines }) => (
|
|
16
|
+
export const almostNodeLiveRenderer: LivePreviewRenderer = ({ files, entry, title, collapsedCodeLines, expandedCodeLines, env }) => (
|
|
16
17
|
<ReactCodePreview
|
|
17
|
-
files={files}
|
|
18
|
+
files={env ? withEnv(files, entry, env) : files}
|
|
19
|
+
// The code panel shows the fence as written, not the env import.
|
|
20
|
+
{...(env ? { code: files[entry] } : {})}
|
|
18
21
|
entry={entry}
|
|
19
22
|
title={title}
|
|
20
23
|
collapsedCodeLines={collapsedCodeLines}
|
|
@@ -22,6 +25,12 @@ export const almostNodeLiveRenderer: LivePreviewRenderer = ({ files, entry, titl
|
|
|
22
25
|
/>
|
|
23
26
|
)
|
|
24
27
|
|
|
28
|
+
function withEnv(files: Record<string, string>, entry: string, env: Readonly<Record<string, string>>) {
|
|
29
|
+
const map: Record<string, string> = { ...files }
|
|
30
|
+
injectEnv(map, entry, env)
|
|
31
|
+
return map
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
/**
|
|
26
35
|
* `GitbookStreamdown` with the almost-node playground wired in: live code fences and
|
|
27
36
|
* `{% demo %}` blocks with inline files (and no resolver) run in almost-node
|
|
@@ -118,6 +118,8 @@ export interface InlineDemoProjectOptions {
|
|
|
118
118
|
entry?: string
|
|
119
119
|
/** Extra npm dependencies (merged over a `package.json` among the files, if any). */
|
|
120
120
|
dependencies?: Record<string, string>
|
|
121
|
+
/** Environment variables, set as `process.env` before the demo runs (see `injectEnv`). */
|
|
122
|
+
env?: Readonly<Record<string, string>>
|
|
121
123
|
}
|
|
122
124
|
|
|
123
125
|
export interface InlineDemoProject {
|
|
@@ -190,5 +192,23 @@ createRoot(document.getElementById("root")).render(
|
|
|
190
192
|
`
|
|
191
193
|
}
|
|
192
194
|
const entry = wrapped ? WRAPPER_ENTRY : entryPath
|
|
195
|
+
if (options.env && Object.keys(options.env).length) injectEnv(map, entry, options.env)
|
|
193
196
|
return { files: createReactDemoFiles(map, { entry, dependencies }), entry, wrapped }
|
|
194
197
|
}
|
|
198
|
+
|
|
199
|
+
/** Where `injectEnv` writes the values; the entry imports it first. */
|
|
200
|
+
export const ENV_MODULE = "/src/docstream-env.js"
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Makes `env` the project's `process.env` (and `globalThis.__DOCSTREAM_ENV__`): writes a module that
|
|
204
|
+
* sets them and imports it at the top of `entry`, so it runs before anything the entry imports.
|
|
205
|
+
*/
|
|
206
|
+
export function injectEnv(map: Record<string, AlmostNodeFileContent>, entry: string, env: Readonly<Record<string, string>>) {
|
|
207
|
+
map[ENV_MODULE] = `const env = ${JSON.stringify(env)}
|
|
208
|
+
globalThis.process = globalThis.process ?? { env: {} }
|
|
209
|
+
globalThis.process.env = Object.assign(globalThis.process.env ?? {}, env)
|
|
210
|
+
globalThis.__DOCSTREAM_ENV__ = env
|
|
211
|
+
`
|
|
212
|
+
const source = map[entry]
|
|
213
|
+
if (typeof source === "string") map[entry] = `import ${JSON.stringify(ENV_MODULE)}\n${source}`
|
|
214
|
+
}
|