@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.
Files changed (62) hide show
  1. package/README.md +24 -0
  2. package/dist/demo/DemoViewer.d.ts.map +1 -1
  3. package/dist/demo/DemoViewer.js +20 -5
  4. package/dist/demo/DemoViewer.js.map +1 -1
  5. package/dist/demo/types.d.ts +2 -0
  6. package/dist/demo/types.d.ts.map +1 -1
  7. package/dist/doc-env.d.ts +60 -0
  8. package/dist/doc-env.d.ts.map +1 -0
  9. package/dist/doc-env.js +123 -0
  10. package/dist/doc-env.js.map +1 -0
  11. package/dist/docs/DocsRenderer.d.ts +14 -2
  12. package/dist/docs/DocsRenderer.d.ts.map +1 -1
  13. package/dist/docs/DocsRenderer.js +34 -12
  14. package/dist/docs/DocsRenderer.js.map +1 -1
  15. package/dist/index.d.ts +3 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/playground/InlineDemoPreview.d.ts +1 -1
  20. package/dist/playground/InlineDemoPreview.d.ts.map +1 -1
  21. package/dist/playground/InlineDemoPreview.js +5 -5
  22. package/dist/playground/InlineDemoPreview.js.map +1 -1
  23. package/dist/playground/PlaygroundStreamdown.d.ts.map +1 -1
  24. package/dist/playground/PlaygroundStreamdown.js +7 -1
  25. package/dist/playground/PlaygroundStreamdown.js.map +1 -1
  26. package/dist/playground/project.d.ts +9 -0
  27. package/dist/playground/project.d.ts.map +1 -1
  28. package/dist/playground/project.js +18 -0
  29. package/dist/playground/project.js.map +1 -1
  30. package/dist/site/DocsSite.d.ts +15 -0
  31. package/dist/site/DocsSite.d.ts.map +1 -1
  32. package/dist/site/DocsSite.js +51 -8
  33. package/dist/site/DocsSite.js.map +1 -1
  34. package/dist/site/index.d.ts +2 -2
  35. package/dist/site/index.d.ts.map +1 -1
  36. package/dist/site/index.js +1 -1
  37. package/dist/site/index.js.map +1 -1
  38. package/dist/site/site.css +67 -0
  39. package/dist/site/tree.d.ts +1 -1
  40. package/dist/site/tree.d.ts.map +1 -1
  41. package/dist/site/tree.js +3 -2
  42. package/dist/site/tree.js.map +1 -1
  43. package/dist/streamdown.d.ts +1 -1
  44. package/dist/streamdown.d.ts.map +1 -1
  45. package/dist/streamdown.js +2 -2
  46. package/dist/streamdown.js.map +1 -1
  47. package/dist/styles.css +89 -0
  48. package/package.json +1 -1
  49. package/src/demo/DemoViewer.tsx +23 -4
  50. package/src/demo/types.ts +2 -0
  51. package/src/doc-env.tsx +198 -0
  52. package/src/docs/DocsRenderer.tsx +48 -12
  53. package/src/index.ts +3 -1
  54. package/src/playground/InlineDemoPreview.tsx +5 -4
  55. package/src/playground/PlaygroundStreamdown.tsx +11 -2
  56. package/src/playground/project.ts +20 -0
  57. package/src/site/DocsSite.tsx +79 -5
  58. package/src/site/index.ts +2 -2
  59. package/src/site/site.css +67 -0
  60. package/src/site/tree.ts +3 -2
  61. package/src/streamdown.tsx +4 -0
  62. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream",
3
- "version": "1.19.1",
3
+ "version": "1.20.1",
4
4
  "description": "GitBook-aware readonly markdown and AI stream renderer.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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
- const component = useComponent(hookResolver, src, near)
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
- return { kind: "runtime", render: (v) => runtime({ src, title, files: inlineFiles, entry, variant: v, dependencies }) }
775
- }, [mode, component, props.streaming, runtime, inlineFiles, src, title, dependencies])
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
  /**
@@ -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
- const entry = block.entry ?? "/src/main.jsx"
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
+ }