@brett_lamy/docstream
v1.35.0
Published
GitBook-aware readonly markdown and AI stream renderer.
Readme
@brett_lamy/docstream
GitBook-aware markdown rendering for React applications and AI streaming surfaces.
@brett_lamy/docstream combines a small GitBook-flavored markdown parser with React renderers that can display docs blocks while a response is still streaming. It is designed for read-only documentation previews, AI answer panes, and apps that need GitBook-specific syntax without embedding a full editor.
Features
- React renderer for GitBook-style markdown blocks.
- Streaming-friendly
GitbookStreamdowncomponent inspired byvercel/streamdown. - Parser and serializer for round-tripping supported GitBook syntax — byte-for-byte:
serializeMarkdown(parseMarkdown(md)) === mdfor Markdown and GitBook pages (the author's emphasis markers, escapes, link forms and reference definitions, Markdown vs HTML images, blank lines between blocks, list markers, fences, table layout and tag lines as written). Layout-only fields (gap,raw,opening, …) are recorded only where the source differs from the default output, and are ignored once they no longer fit an edited node. - Syntax-highlighted code blocks through
gpu-lexer; Mermaid diagrams (drawn by Pele) and KaTeX math load on first use. - GitBook block support for hints, tabs, expandables, steppers, embeds, content refs, columns, figures, tables, math, dividers, updates, and OpenAPI operations.
- Embed blocks render YouTube/direct video URLs through the built-in
VideoEmbedand public Loop QA task, journey, exploration, or test-run URLs throughReplayPreview. - CSS exported as a stable package entrypoint so host apps can theme with CSS variables or shadcn-style design tokens.
- Attribute-aware direct video embeds for muted, looping inline clips in long-form posts.
- Optional
VizEmbedintegration for mounting deterministic@brett_lamy/viz-enginescenes in a document. - Interactive OpenTUI previews in
tuifences and AI responses, with explicit Run, Stop, source, and runtime status. - Vite source mounts for rendering and editing real component or Storybook files without copying implementations into Markdown.
DocsSite(@brett_lamy/docstream/site): a GitBook-style documentation site from a folder of Markdown — sidebar from the file tree orSUMMARY.md, ⌘K search, an outline, breadcrumbs, previous / next and in-place relative links.
Installation
pnpm add @brett_lamy/docstream
# or: npm install @brett_lamy/docstreamReact and React DOM are peer dependencies and must be provided by your app.
Nothing in docstream's dependency tree runs an install script, so
pnpm add succeeds with no build-script prompts (no ERR_PNPM_IGNORED_BUILDS).
Heavy renderers are loaded only when a page needs them: Pele for
```mermaid fences (only the diagram types the page uses), KaTeX for math
blocks, rrweb for replay event lists.
```mermaid fences are drawn by Pele, which turns Mermaid
text into SVG synchronously, without Mermaid or a browser layout pass. Each
diagram is a card: the source on the left (with a copy button), the drawing on
the right with Open in Playground and the render time; the panes stack on a
narrow page. Pele reads its colors from --pele-* variables, which docstream
maps to its theme on .docs-mermaid-preview. Math blocks ($$ … $$) use the same card: the TeX on the left, KaTeX's
typesetting on the right. The card is side by side when it is at least 640px
wide and stacks below that (a container query, so it follows the card, not the
window). SplitCard, SplitSource and SplitResult build it.
PeleDiagram (the drawing pane)
and usePele(code, ref) are exported for hosts that show the source
themselves, as the editor does. Pele supports every diagram type built into
Mermaid, though its layout is its own. A diagram it can't draw shows Pele's
error as text.
Two features are opt-in, each behind its own entry and an optional peer dependency that the rest of the package never references:
| Entry | Install to use it | What it adds |
| --- | --- | --- |
| @brett_lamy/docstream/playground | @agent-wasm/core (almost-node, an in-browser Node runtime) | Live React code fences and runnable inline {% demo %} files |
| @brett_lamy/docstream/viz | @brett_lamy/viz-engine | VizEmbed |
pnpm add @agent-wasm/core # only if you import @brett_lamy/docstream/playgroundWithout the playground, live code fences render as highlighted code (or through
a liveRenderer you pass), and inline-file demos open on their Code view with a
short "needs the playground runtime" note.
Basic Setup
Import the package CSS once near your app entrypoint:
import "@brett_lamy/docstream/styles.css"The stylesheet provides heading hierarchy and consistent spacing for the
DocsRenderer, MarkdownContent, and streaming render paths. Host CSS can
override those defaults through the normal cascade.
If your TypeScript app checks CSS side-effect imports, include Vite's standard environment declaration or an equivalent CSS module declaration:
/// <reference types="vite/client" />Render Streaming Markdown
Use GitbookStreamdown when markdown may arrive incrementally from an AI stream. The component accepts either markdown or string children.
The package root's GitbookStreamdown is the same component as the
@brett_lamy/docstream/streamdown entry. For live code fences and runnable
inline demos, import the playground variant instead (requires @agent-wasm/core):
import { GitbookStreamdown } from "@brett_lamy/docstream/playground" // = PlaygroundStreamdownimport { GitbookStreamdown } from "@brett_lamy/docstream"
import "@brett_lamy/docstream/styles.css"
export function Answer({ text, isStreaming }: { text: string; isStreaming: boolean }) {
return (
<GitbookStreamdown markdown={text} isStreaming={isStreaming} />
)
}isStreaming adds aria-busy and a data-streaming attribute to the wrapper. isAnimating is also accepted for compatibility with stream UI state.
Docs Sites
DocsSite publishes a folder of Markdown as a GitBook-style site. Give it every file you
have — it keeps the .md files, builds the sidebar from the folders (or a GitBook
SUMMARY.md) and renders each page through docstream. The docstream site
(https://blamy.github.io/docstream/) is one: the repo's demo/docs/ folder.
import { createRoot } from "react-dom/client"
import { DocsSite } from "@brett_lamy/docstream/site"
import "@brett_lamy/docstream/styles.css"
const files = import.meta.glob("./docs/**/*.md", { query: "?raw", import: "default", eager: true })
createRoot(document.getElementById("root")!).render(<DocsSite files={files} title="Acme" />)README.md/index.mdis a folder's page (the root one is home); top-level folders are sidebar groups; numeric prefixes (01-intro.md) order entries and are dropped from URLs; frontmattertitle,description,orderandhiddenrefine it.- Relative links (
[Install](../guides/install.md#npm)) navigate in place. - ⌘K full-text search, an "On this page" outline, breadcrumbs, previous / next, "Copy page", optional "Edit this page", light / dark, and a phone layout driven by the site's own width.
routing="hash"(default, any static host),"history"(withbasePath) or"memory"(an embedded site). EveryMarkdownContentrenderer prop (demoResolver,liveRenderer, …) passes through.buildDocsTree(files)is the pure tree builder behind it, for static builds and link checks.
Render Parsed Documents
Use parseMarkdown and DocsRenderer when you want to parse once, inspect the AST, or serialize it later.
import { DocsRenderer, parseMarkdown, serializeMarkdown } from "@brett_lamy/docstream"
const doc = parseMarkdown(markdown)
const roundTripped = serializeMarkdown(doc)
export function Preview() {
return <DocsRenderer doc={doc} />
}Markdown Helper Component
MarkdownContent parses and renders a markdown string in one step:
import { MarkdownContent } from "@brett_lamy/docstream"
export function Preview({ markdown }: { markdown: string }) {
return <MarkdownContent markdown={markdown} />
}Document properties and task states
YAML frontmatter is first-party document metadata (doc.properties), separate from the body.
The renderer shows a compact, collapsed property sheet. Use documentProperties={false} to hide it
or documentProperties={{ defaultOpen: true }} to expand it. Comments, key order, quoting and line
endings survive body edits. readDocumentProperties, setDocumentProperty and
removeDocumentProperty are also available from @brett_lamy/docstream/gitbook.
DocumentProperties from @brett_lamy/docstream/properties accepts an optional onChange
for an editable sheet with text, number, boolean and structured values.
Task lists understand [ ] pending, [~] in progress, [?] implemented/evidence needed,
[b] blocked, [d] parked/verification debt, [!] refuted, [x] verified and [-] cancelled.
Each state has a distinct icon and state-name tooltip. With onTaskStateChange={(item, state) => …},
rendered checkboxes become interactive: update the item in your AST and re-render to persist it.
A normal click clears any marked state, then checks the empty box as [x]; right-click or
Shift+F10 opens the icon state picker. TaskCheckbox and TASK_STATE_ICONS are exported from
@brett_lamy/docstream/tasks; TASK_STATES, getTaskState and taskMarker from /gitbook.
Environment variables for examples
A page lists the environment variables its examples need in its frontmatter:
---
env: [GITHUB_TOKEN, NETLIFY_AUTH_TOKEN]
---A {% demo %} or live code fence whose source mentions one of them (as a whole word) doesn't run
when the page loads. It shows a gate, and its module isn't imported, until the values are loaded:
- From the host: pass
envHosttoGitbookStreamdown/DocsRenderer/MarkdownContent/DocsSite, aDocstreamEnvHost. Itsrequest(names)resolves with the values it could get, from a keychain or after a login;labelnames the gate's button anddescribe(name)adds a hint beside each variable. - Without a host: the gate asks the reader for the values and keeps them in memory for the page.
For a passkey keychain, use createEnvKeychain and keychainEnvHost. Values are kept encrypted
with a key derived from the passkey's PRF output, and the names are readable while locked. A press
unlocks it with one biometric prompt, signs in (obtain) only for what it doesn't hold, saves that,
and answers with everything it holds, so every example those values cover opens together.
In-page demos read the values with useDocstreamEnv().values. Inline demos that run in almost-node,
and live code fences, get them as process.env.NAME (injectEnv writes a module the entry imports
first). Values loaded once are shared by every example on the page. env can also be passed as a
render option, and DocsSite reads it from each page's frontmatter.
Video and Replay QA embeds
embed blocks accept direct .mp4, .webm, and .ogg assets, YouTube URLs,
and public Loop QA project URLs. Loop QA project URLs are converted to the
chrome-free /p/... route automatically:
{% embed url="https://loop-qa.replay.io/projects/project-id/journeys/journey-id" /%}An embed of an rrweb recording file (a URL ending in .rrweb, holding the JSON event
array or { events }) plays it in the same replay player. The URL goes through the host's
AssetResolverProvider, like STL files and images, and the file is fetched only when the
player nears the viewport:
{% embed url="./recording.rrweb" title="Checkout session" %}ReplayFile (url plus the player options) is that embed as a component, and
isRrwebUrl(url) the test the renderer uses; both are exported from the root and
@brett_lamy/docstream/replay.
The replay component is also available directly. ReplayPreview takes either
events (an rrweb recording you hold: the full player) or source (a page URL, an
event array, or an events endpoint):
import { ReplayPreview } from "@brett_lamy/docstream"
<ReplayPreview source="https://loop-qa.replay.io/projects/project-id/tasks/task-id" />
<ReplayPreview events={events} initialTime={6400} />The player is a browser frame (URL bar that follows the recording's navigations, led by
the path, query and hash once the page is past the site root, with the host dimmed),
a page-shaped poster until it is ready, play/pause, a scrubber with the recording's
moments on a rail above it (clicks, console errors, failed requests, navigations,
custom events; press one to jump), speed, fullscreen, and a drawn cursor whose clicks
ripple. Keys work inside the player: Space/K, ←/→ (5 s), J/L (10 s), </> speed,
F fullscreen, Home/End. chrome is "browser" (default), "minimal" or "none";
controls={false} with chrome="none" is a still frame of the page at initialTime.
playerRef gives { play, pause, seek, toggle, getCurrentTime }. Other props: url,
title, trafficLights, autoplay, loop, speeds, markers, markerKinds,
cursor, fit ("width" | "contain"), poster, lazy, onTimeUpdate,
onPlayingChange, onMarkerPress, className, style. formatReplayTime(ms) gives
m:ss. replayDemoEvents (a small synthetic recording) is exported from
@brett_lamy/docstream/replay/demo, outside the main entry.
The player isn't tied to rrweb. stage puts a stage of your own in rrweb's place, with the same chrome, scrubber, keys
and markers, and rrweb is never loaded. devtools takes the Console and Network lists as data, in ReplayDevtools'
shape, instead of working them out from events, and elements says where the Elements tab reads from: a Document,
which can be one rebuilt from a recording, and where each element really is on the page, for its outline.
import { ReplayPreview, type ReplayCustomStage } from "@brett_lamy/docstream/replay"
// `component` gets `onReady`, `onTimeUpdate`, `onPlayingChange`, `onFinish`, `speed`, `overlay` and `className`, and
// answers to a `ReplayStageHandle` through its ref. Report the time after every move, whoever made it.
const stage: ReplayCustomStage = { component: FramesStage, duration, width, height, href }
<ReplayPreview
stage={stage}
devtools={{ console: messages, network: requests }}
elements={{ getDocument: () => rebuilt, getElementRect: (el) => rectangles.get(el) ?? null }}
/>With no elements, a stage whose handle has getDocument gets an Elements tab from that, and one without has no tab.
replayDevtoolsCountsAt and replayDevtoolsCutAt answer "how many so far" and "has the list changed" in a few binary
searches, and the panel builds its lists only when it's open and they've changed, so a long recording can drive this on
every frame. The panel lays out the latest 2,000 rows of each list and says how many more there are: it has no
windowing, so a console that has to hold a whole hour belongs in a virtualized list of your own.
The player is styled by styles.css in plain CSS, inside the docstream.replay cascade
layer and under zero-specificity :where() selectors, so your own rules (and utility
classes such as w-full or pointer-events-none) override it; if your utilities layer
is declared earlier than this stylesheet, put @layer docstream.replay; before it.
Colors come from the shadcn-style tokens --card, --background, --foreground,
--muted, --muted-foreground, --border, --primary, --ring, --destructive,
--success, --warning and --tertiary-foreground, each with a neutral fallback.
Parts are addressable as [data-slot="replay-preview"] (with data-state
loading | paused | playing and data-chrome), -bar, -stage, -poster, -pointer,
-controls, -scrubber and -markers.
rrweb (and its stylesheet) is imported only by the lazily loaded player chunk, so pages
that only embed Loop QA URLs never download it. To build custom player chrome, use
the headless ReplayStage (the fitted page, driven through a ref and callbacks) with the
pure timeline helpers, none of which load rrweb:
import { ReplayStage, getReplayMarkers, getReplayPointerTrack, replayPointerAt } from "@brett_lamy/docstream/replay"
const stage = useRef<ReplayStageHandle>(null)
<ReplayStage ref={stage} events={events} fit="contain" showMouse={false}
onTimeUpdate={setTime} onPlayingChange={setPlaying} overlay={<MyCursor at={replayPointerAt(track, time)} />} />
// markers: getReplayMarkers(events) → clicks, console errors, failed requests, navigations, custom eventsTerminal previews in docs and AI answers
Add tui to a TypeScript, TSX, JavaScript, or JSX fence (or use tui as the language).
MarkdownContent, DocsRenderer, and GitbookStreamdown render a bounded terminal preview
with an expandable source panel. Readers click Run, then click the terminal or Focus terminal
to use the keyboard. Stop destroys the program; failures show their message and Retry.
```typescript tui title="Hello terminal"
import { createCliRenderer, TextRenderable } from "@opentui/core"
const renderer = await createCliRenderer({ exitOnCtrlC: false })
const text = new TextRenderable(renderer, { id: "hello", content: "Hello from OpenTUI!" })
renderer.root.add(text)
```The preview loads the OpenTUI browser runtime
only after Run. It uses an opaque-origin sandbox="allow-scripts" iframe and sends only
the example source, language, and terminal theme. It never forwards the document's environment
variables. A frame load is not success: the runtime must acknowledge that it rendered the current
request. Startup and rendering timeouts report errors. Source changes, Stop, and unmount dispose
the iframe, message listeners, and timers.
GitbookStreamdown isStreaming disables Run while an answer is arriving. Unclosed fences also
stay inert. Completing a response enables Run without executing it automatically.
For response attachments, import the component directly; it needs no optional runtime peer or
stylesheet. TuiEmbed is an alias of TuiPreview:
import { TuiPreview } from "@brett_lamy/docstream/tui"
<TuiPreview code={generatedSource} language="tsx" isStreaming={isStreaming} height={360} />Hosts can configure a trusted self-hosted runtime, including its deployment prefix, or disable previews:
<GitbookStreamdown markdown={answer} isStreaming={isStreaming}
tui={{ runtimeUrl: "https://example.com/opentui/workbench/example/", height: 320 }} />
<MarkdownContent markdown={page} tui={false} />Runtime URLs can be absolute or relative to the host page and must resolve to HTTP(S) without credentials. height is bounded to
160–720 pixels; the frame follows its container's width. theme takes { mode, background, foreground }.
startupTimeoutMs and renderTimeoutMs default to 30 seconds each (bounded to 1–120 seconds).
The component's onStatusChange reports idle, starting, running, error, or stopped;
the same state is exposed on data-tui-state for browser verification.
3D models and Strudel music
An embed whose URL ends in .stl renders an interactive WebGL viewer (binary or ASCII
STL, no dependencies): drag or arrow keys to orbit, pinch / ⌘-scroll or +/− to zoom,
double-click or 0 to reset. Relative paths resolve like images.
{% embed url="models/bracket.stl" title="Mounting bracket" %}A strudel code fence, or an embed of a .strudel file, renders an audio player for a
Strudel pattern: play/stop, a scrolling piano roll and a code toggle.
Strudel runs in the page (@strudel/web, AGPL-3.0, loaded on the first play); one pattern
plays at a time. The Tidal dirt-samples load on first play, so s("bd sd") works as on strudel.cc.
{% code title="Night drive" %}
```strudel
stack(s("bd*2 [~ bd] sd ~"), note("<c3 a2 f2 g2>").s("sawtooth").lpf(800))
```
{% endcode %}
{% embed url="music/loop.strudel" title="Loop" %}Both are components too: StlViewer (@brett_lamy/docstream/stl) and StrudelPlayer
(@brett_lamy/docstream/strudel, code or src).
Strudel rack and syntax tree
The player's sliders button opens a rack: equipment generated from the code. Each track
(stack(...) arguments or $: lines) becomes a device with mute/solo, a gain fader, its synth
and key, a step grid for its mini-notation (click a step to rest it, double-click to type) and a
knob per effect (lpf, room, delay, … — add more from its menu); the master strip has the
tempo. Every control rewrites only the characters it owns, and edits play live.
The rack sits on a full syntax tree you can use directly (@brett_lamy/docstream/strudel):
import { parseStrudel, parseMini, setMethod, mixCode, formatNumber } from "@brett_lamy/docstream/strudel"
const song = parseStrudel(code) // acorn JS tree + tempo, tracks, master chain, every mini string
song.tracks[0].chain.methods // [{ name: "lpf", args: [{ kind: "number", value: 800, start, end }] }, …]
parseMini("bd*2 [~ sd] <c e>") // sequences, [ ] < > { }%n , | ., * / ! @ _ ? : .. (k,n,r) — with ranges
setMethod(code, song.tracks[1].chain, "room", formatNumber(0.5)) // exact edit, adds the call if missing
mixCode(song, { solo: new Set([0]) }) // the code with other tracks silencedThe master strip's Rack | Grid switch puts the modules on a masonry grid: drag a module's
title to move it, drag its corner to resize it (arrow keys work on both handles).
<StrudelRack layout="grid" persistKey="my-song" /> starts there and remembers the arrangement.
StrudelRack, RackGrid, StepGrid, Knob and Fader are exported for custom studios. The parser
(acorn) loads only when a rack opens.
GitBook Syntax
The parser supports normal Markdown plus GitBook-style block tags.
Hints
{% hint style="info" %}
Helpful context for the reader.
{% endhint %}Supported styles are info, success, warning, and danger.
Code fences
```ts title="client.ts"
const client = createClient()
```Titled (file) fences show line numbers; untitled fences don't. lineNumbers="true"
or lineNumbers="false" overrides either way, and an explicit attribute is kept on
serialize even when it matches the default.
Tabs
{% tabs %}
{% tab title="TypeScript" %}
```ts
export const ok = true
```
{% endtab %}
{% tab title="JSON" %}
```json
{ "ok": true }
```
{% endtab %}
{% endtabs %}Expandables
{% expandable title="More details" %}
Hidden content goes here.
{% endexpandable %}Steppers
{% stepper %}
{% step %}
Create a token.
{% endstep %}
{% step %}
Call the API.
{% endstep %}
{% endstepper %}OpenAPI Operations
{% openapi-operation spec="petstore" path="/pet/{petId}" method="get" %}
[petstore](specs/petstore.json)
{% endopenapi-operation %}The body is one link to the spec (JSON or YAML); spec is a name that round-trips but isn't
read. The link goes through the asset resolver (see Assets and OpenAPI Specs),
so a relative path can name a file next to the page — inside DocsSite, a file in its VFS. Without
a link the block asks for a spec URL; a spec that fails to load shows the error.
Inline video clips
Direct media embeds can opt into browser-safe inline playback. muted is
important when autoplay is enabled:
{% embed url="/generated/example/clips/offsets.mp4" title="A reader resumes from its bookmark" autoplay="true" loop="true" muted="true" controls="false" %}A tag must fit on one line: split across lines, it parses as an ordinary paragraph.
The attributes round-trip through parseMarkdown and serializeMarkdown and
are rendered as a native <video playsInline> element.
VizEngine scenes
Install the optional peer dependency when a document needs a live, seekable scene rather than a rendered clip:
npm install @brett_lamy/docstream @brett_lamy/viz-engineimport { VizEmbed } from "@brett_lamy/docstream/viz"
import "@brett_lamy/docstream/styles.css"
<VizEmbed scene={scene} title="The same event, replayed into two projections" />VizEmbed keeps the scene's timeline deterministic and delegates the clock to
VizEngine, so a reader can pause or scrub the same explanation used to produce
the short clip.
File-backed components and stories
Markdown is the composition layer. Keep component and Storybook implementations in normal source files, then mount their directory in Vite:
// vite.config.ts
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
import { docstreamSources } from "@brett_lamy/docstream/vite"
export default defineConfig({
plugins: [
react(),
docstreamSources({
mounts: [{ name: "ui", root: "src/components" }],
}),
],
})The dev server answers GET {apiBase}/source?mount=…&path=… (read), PUT with JSON
{ content } (edit an existing file) and POST with a raw body (create or replace a file,
folders included: uploads such as .rrweb recordings). Writes need a writable mount;
apiBase defaults to /@docstream. A PUT reloads the page; set reloadOnWrite: false on a
mount whose files hot-update on their own (Markdown an editor autosaves) to skip that.
Reference a named component export or a CSF story from Markdown:
{% source-ref mount="ui" path="Button.tsx" export="Button" kind="component" title="Button" %}
{% source-ref mount="ui" path="Button.stories.tsx" export="Primary" kind="story" title="Primary button" %}Render references using the Vite client. The AST keeps mount, path,
exportName, and kind, so provenance survives parse/edit/serialize cycles:
import { MarkdownContent, SourcePreview, createViteSourceClient } from "@brett_lamy/docstream"
const client = createViteSourceClient()
export function Guide({ markdown }: { markdown: string }) {
return (
<MarkdownContent
markdown={markdown}
sourceRenderer={(reference) => <SourcePreview reference={reference} client={client} />}
/>
)
}client.read(reference) returns the current file plus provenance.
client.write(reference, content) writes back to the mounted real file. The
plugin validates the mount boundary, only edits existing files, and notifies
Vite after a save so the preview reloads through Vite's normal module pipeline.
Set writable: false on a mount when a documentation site should only render.
The Vite endpoints exist only during development. For a deployed static site, bundle the referenced modules and use the read-only production client:
import { createBundledSourceClient } from "@brett_lamy/docstream"
import * as ButtonModule from "./components/Button"
const client = createBundledSourceClient({
"ui:Button.tsx": ButtonModule,
})Entries may also be lazy import functions. Keys can be mount:path (recommended)
or just path when names cannot collide.
File-backed live demos
{% demo %} puts a live example into a page while the page stays one Markdown
document. The example is a folder of real files. The component renders in-page
(same React tree, lazily loaded near the viewport), and the Code view shows the
actual file contents.
{% demo src="button/sizes" %}
{% demo src="blocks/inbox" title="Inbox" description="…" height="420" layout="auto" variants="full:Full,compact:Compact" viewport="tablet" %}Only src (<page>/<example>) is required. title, description, height
and variants default to the folder's meta.json. layout="auto" (the default)
uses the multi-file viewer for two or more source files: Preview | Code,
desktop/tablet/phone widths, a draggable resize handle with a width readout,
variants, copy, a fullscreen overlay and an optional deep link. Code view has a
file tree (entry first) with a copy button per file. A single file gets the
single-file card: the preview with a collapsible code panel underneath.
variant is passed to the component as a prop. Frames are CSS size containers,
so a demo's @container queries respond to the preview width. The tag
round-trips through parseMarkdown/serializeMarkdown, and while streaming, a
half-arrived tag is hidden rather than shown as text.
examples/button/sizes/
index.tsx ← default export: ({ variant }) => …
Sizes.css
meta.json ← { title, description, height, variants: [{ id, label }], entry, layout, bleed,
className, surface, status, variantsWidth }meta.height is a minimum for single-file previews (in-page components may grow
past it; without a height they size to their content — hosts can set a floor with
--docs-demo-preview-min-height) and the stage height for the multi-file viewer.
className / surface (a style object, CSS custom properties welcome) style the
preview canvas, bleed drops its padding, status shows a small label in the
header ("Live", "Needs network"), and variantsWidth sizes the variant switch.
The canvas is block flow: block-level demo roots fill its width and lay out as on a
page (max-width: 480px; margin: 0 auto centres at 480px); intrinsically sized
(inline-level) ones — a button, a badge, an image — are centred.
Every meta.json field has a tag attribute of the same name:
{% demo src="button/sizes" title="Sizes \"S\" to \"L\"" status="Live" bleed="true" className="brand"
surface="background: var(--brand-50); --demo-gap: 12px" variantsWidth="220" %}(one line in practice). surface is written as CSS declarations; bleed="false"
switches off a meta.json bleed: true. Precedence: a tag attribute wins,
then meta.json, then the built-in default — field by field. Copy page folds
meta.json into the tag, so the copied page renders the same without the resolver.
Attribute values are double-quoted; inside them a double quote is written \", a
backslash \\, and %} as %\}. The serializer escapes, the parser unescapes
(other backslashes are kept as written).
Demos that carry their files (block form)
A {% demo %} can carry its files inline, one titled fence per file (entry
first, or entry="App.tsx"), closed by {% enddemo %}. A file that contains
fences gets a longer fence. This is what Copy page produces, so a copied page
renders back as the same page anywhere:
{% demo src="composer/scroll-fab" title="Scroll → FAB" height="380" variants="full:Full,compact:Compact" %}
```tsx title="index.tsx"
import { items } from "./data"
export default function ScrollFab({ variant }) { … }
```
```ts title="data.ts"
export const items = ["a", "b"]
```
````md title="README.md"
```sh
npm start
```
````
{% enddemo %}The self-closing tag ({% demo … %} or {% demo … /%}) keeps working. A tag
followed by a blank line and an ordinary code block (no {% enddemo %}) means
what it did before 1.2: a demo, then a code block. While streaming, a block whose
{% enddemo %} hasn't arrived is open: its code shows, its preview waits.
| The block has… | and there is… | Preview | Code view |
| --- | --- | --- | --- |
| src only | a resolver that knows src | the component, in-page | the resolver's files |
| inline files | a resolver that knows src (its list() includes it, and it loads) | the component, in-page | the resolver's files (they win) |
| inline files | no resolver, or one that doesn't know src | the files, run by the demoRuntime | the inline files |
| inline files | no runtime (e.g. the /streamdown entry) | a one-line note; the viewer opens on Code | the inline files |
| src only | no resolver | a placeholder chip | — |
PlaygroundStreamdown (also exported as GitbookStreamdown from
@brett_lamy/docstream/playground) runs inline demos in almost-node by default.
Elsewhere pass demoRuntime (and liveRenderer for live code fences):
import { createAlmostNodeDemoRuntime } from "@brett_lamy/docstream/playground"
const demoRuntime = createAlmostNodeDemoRuntime({ workspaceOptions: { basePath: "/docs" } })
// live code fences: import { almostNodeLiveRenderer } from "@brett_lamy/docstream/playground"
<MarkdownContent markdown={page} demoResolver={demoResolver} demoRuntime={demoRuntime}
demoDependencies={{ "@brett_lamy/ui": "^1.2.0" }} />How it runs (createInlineDemoProject): the files go under /src/demo/; a tiny
wrapper entry imports the entry file's default export (or first exported
function) and renders it with variant from the iframe's ?variant= query, so
switching variants reloads the iframe, not Vite. An entry that mounts itself
(createRoot(…)) is used directly and gets no variant. npm imports: react
and react-dom are provided; demoDependencies (or a package.json among the
files) declares others. Limits: almost-node resolves bare imports from esm.sh at
the declared major version, so packages must work as browser ESM there; CSS
imported from packages may not load (import the CSS file from the demo instead,
or inline it); each inline demo boots its own container (the fullscreen overlay
boots a second one). preloadDemos(resolver, srcs) settles resolver demos before
the first render (server rendering, static export).
Copy a demo, paste it elsewhere (clipboard + demo VFS)
Every demo viewer has a Copy demo button. It writes one clipboard item with
three representations, through react-aria's useClipboard:
| Type | Content |
| --- | --- |
| text/plain, text/markdown | renderable docstream Markdown: the block with its files inline (resolver files for src-only demos, meta.json folded into the tag) |
| application/x-docstream+json (DOCSTREAM_CLIPBOARD_TYPE) | { kind: "docstream", version: 1, markdown, files: { [src]: DemoFile[] }, meta: { [src]: DemoMeta } } — the exact Markdown (a src-only block stays src-only) plus every demo's files and meta |
Pasting into a docstream editor (@brett_lamy/docstream-editor 1.5+) recreates
the demo with its files; pasting anywhere else gets the Markdown. Where the
browser's copy command is unavailable the button copies the Markdown only
(editors still parse its inline-file demos).
Pasted demo files live in a demo VFS — an in-memory { [src]: { files, meta } }
store. Give it to a renderer (demoVFS) or an editor, or share one between
several:
import { MarkdownContent, useDemoVFS, useDemoVFSFiles } from "@brett_lamy/docstream"
const vfs = useDemoVFS() // stable per component; createDemoVFS() outside React
const files = useDemoVFSFiles(vfs) // { [src]: DemoFile[] }, re-renders on change — persist it
<MarkdownContent markdown={page} demoResolver={demoResolver} demoRuntime={demoRuntime} demoVFS={vfs} />A demo block resolves in this order: the host demoResolver when it knows src
→ the block's inline files, else the VFS files for src, run by the
demoRuntime (almost-node from /playground when installed) → the Code view
with the "needs runtime" note. vfs.resolver({ resolver, runtime }) is the same
layering as a DemoResolver (files / meta from the host when it knows
src, else from the VFS; load runs VFS files through the runtime).
vfs.subscribe(change => …), get/set/delete/list/files() manage it directly.
Building blocks (@brett_lamy/docstream/demo): createDocstreamClipboard(doc, { lookup, serialize })
/ createDocstreamClipboardFromMarkdown, docstreamClipboardItems (react-aria
DragItems), writeDocstreamClipboard(dataTransfer, clip),
readDocstreamClipboard(dataTransfer), readDocstreamClipboardItems(dropItems),
parseDocstreamClipboard(json), docstreamPayloadFromMarkdown,
useDocstreamCopy / CopyDemoButton, and prefetchDemoSources(resolver, srcs) +
peekDemoSource(resolver, src) to read a resolver's files synchronously (a copy
event can't wait).
Tell docstream where demos live with a DemoResolver. With Vite, three
import.meta.glob maps are enough:
import { MarkdownContent, createGlobDemoResolver, demoHref } from "@brett_lamy/docstream"
const demoResolver = createGlobDemoResolver({
root: "./examples", // ids are <page>/<example> below this prefix
modules: import.meta.glob("./examples/*/*/index.tsx"),
sources: import.meta.glob("./examples/*/*/**/*.{ts,tsx,css,json}", { query: "?raw", import: "default" }),
metas: import.meta.glob("./examples/*/*/meta.json", { eager: true, import: "default" }),
href: (src) => demoHref(src), // optional "?demo=<src>" deep link → route it to <DemoFullscreen src={src} />
})
<MarkdownContent markdown={page} demoResolver={demoResolver} pageActions />demoResolver is accepted by DocsRenderer, MarkdownContent,
GitbookStreamdown and the playground renderer, and it's provided through
DocstreamDemoContext, so nested renderers inherit it. You can also write a
resolver by hand:
{ list?(), meta(src), files(src), load(src), href?(src, { variant }) }.
createGlobDemoResolver is plain TypeScript, so it's exported from the package
root and from @brett_lamy/docstream/demo, not from the Node-only ./vite entry.
Page actions
pageActions renders a shadcn-style Copy page ▾ above the content, with
Copy as Markdown, View as Markdown, Open in ChatGPT and Open in Claude. Pass
true, or pass options such as { markdownUrl, pageUrl, prompt, actions, transform }.
You can also place <DocPageActions markdown={page} /> yourself. Copy and View
give renderable docstream Markdown: every {% demo %} keeps its tag (with its
meta.json defaults folded in as attributes) and gains its files inline, and
everything else is returned byte-for-byte, so pasting the copy into docstream
renders the same page — with or without the resolver. Open in ChatGPT / Claude
send the page URL. The same resolution is a pure helper for build-time .md or
llms.txt generation; the plain form is only produced when you ask for it:
import { resolveDemosToMarkdown } from "@brett_lamy/docstream/demo"
const md = await resolveDemosToMarkdown(page, demoResolver) // renderable (default)
const prompt = await resolveDemosToMarkdown(page, demoResolver, { format: "plain" })
// plain: "**Example — Title**" + fences per demo, commands as npm `sh` fences,
// titled tab sets as a heading + a bold label per tab — for LLM prompts only.Synced tabs (install instructions)
When every tab in a tab set is a single code block, the tabs render as one code
block, with pill tabs, a terminal glyph for shell code, and a copy button in its
header. Add sync="<key>" and every tab set with the same key follows the
reader's choice. The choice is matched by tab title and is remembered in
localStorage across the page and across visits:
{% tabs sync="pm" %}
{% tab title="npm" %}
```sh
npm install @brett_lamy/docstream
```
{% endtab %}
{% tab title="pnpm" %}
```sh
pnpm add @brett_lamy/docstream
```
{% endtab %}
{% endtabs %}Every code block has a copy button. Untitled blocks show just the code with the
button floating top-right; a header bar appears only when the block has a
title (file name).
Installation sections and command boxes
{% command %} holds one npm / npx command. docstream derives the pnpm,
yarn and bun forms and shows them as tabs (pnpm first) with a terminal glyph and
a copy button; the reader's manager is synced page-wide and across visits (key
pm, shared with {% tabs sync="pm" %}). Derivations: npm install|i x → pnpm
add x / yarn add x / bun add x (-D, -E, -O, -g mapped), npm install
→ <pm> install, npm uninstall x → <pm> remove x, npx x / npm exec x →
pnpm dlx x / yarn dlx x / bunx x, npm run s → pnpm s / yarn s / bun
run s, npm test|start → pnpm test / bun run test, npm create x → <pm>
create x; other lines (comments, cd) are kept, a && b is converted per part.
Override a manager verbatim with pnpm="…", yarn="…" or bun="…"; use another
sync group with sync="…". Multi-line commands use the block form:
{% command %}npm install @brett_lamy/ui{% endcommand %}
{% command bun="bun add -d vitest" %}
npm install -D vitest
npm run test -- --watch
{% endcommand %}A tab set with a title renders as a section: the title is a real heading
(h2, or level="3"/"4") with an anchor id, the tab switch sits as pill tabs
on the right of the heading row, and the active tab's blocks follow in the page
flow (no card around them). documentOutline(doc) lists these titles with the
page's headings for a table of contents.
{% tabs title="Installation" sync="install-method" %}
{% tab title="npm" %}
{% command %}npm install @brett_lamy/ui{% endcommand %}
Import the stylesheet once at your app's entry, then the parts:
```tsx
import '@brett_lamy/ui/styles.css'
import { Composer } from '@brett_lamy/ui'
```
{% endtab %}
{% tab title="shadcn CLI" %}
{% command %}npx shadcn@latest add https://blamy.github.io/ui/r/composer.json{% endcommand %}
{% endtab %}
{% endtabs %}Both round-trip through parseMarkdown/serializeMarkdown, are kept as they are
by Copy page, and a half-streamed one-line {% command %} is held back until it
closes.
Thumbnail images
An image whose Markdown title starts with thumbnail renders as a thumbnail: a small framed
image, an optional badge (the text after thumbnail:), and a card with the full image on hover or
focus. Any other renderer shows an ordinary image with a tooltip.
 The console Overview loads with an Unauthorized banner.
{% stepper %}
{% step %}
###  0:39.8
The console Overview loads with an Unauthorized banner.
{% endstep %}
{% endstepper %}First in a paragraph or list item, the text runs beside the thumbnail. First in a stepper step's
heading, the image takes a column of its own and the step's title and body sit beside it; the step's
title is the heading text without the image. thumbnailLabel(image) (the badge, or null),
leadingThumbnail(inlines) and ImageThumbnail are exported from the root; thumbnailLabel and
stepTitleText from /gitbook. Size it with --docs-thumb-width (default 168px) and
--docs-thumb-card-width (720px).
Assets and OpenAPI Specs
Relative image, embed and OpenAPI spec paths can be resolved against a file in a GitHub
repository. setAssetBase(repo, branch, filePath) takes the repository, the branch and the path
of the Markdown file being rendered; relative paths then resolve next to that file on
raw.githubusercontent.com. Pass nulls to clear it.
import { resolveAsset, setAssetBase } from "@brett_lamy/docstream"
// Rendering docs/guide/intro.md from BLamy/docstream@main:
setAssetBase("BLamy/docstream", "main", "docs/guide/intro.md")
resolveAsset("images/flow.png")
// → "https://raw.githubusercontent.com/BLamy/docstream/main/docs/guide/images/flow.png"
resolveAsset("https://cdn.example.com/a.png") // http(s), data: and blob: URLs pass throughAn app that opens Markdown from a folder can resolve each asset itself. Wrap the rendered document in
AssetResolverProvider; images, figures, .stl, .strudel and .rrweb embeds and OpenAPI spec links ask it for the URL to load (a blob:
URL of the file, say). Return nothing to fall back to resolveAsset. A promise is fine: STL and Strudel
embeds show a placeholder until it settles.
import { AssetResolverProvider, MarkdownContent } from "@brett_lamy/docstream"
<AssetResolverProvider resolve={(url) => (url.startsWith("./") ? blobUrlFor(url) : null)}>
<MarkdownContent markdown={markdown} />
</AssetResolverProvider>useAsset(url) gives the same answer inside your own components (undefined while pending).
API Reference
Components
GitbookStreamdown: Parses and renders markdown for read-only stream output.DocsRenderer: Renders a parsedDocumentNode.MarkdownContent: Parses and renders a markdown string.ReplayPreview/ReplayEmbed: A session-replay player for rrwebevents, or an embed of public Loop QA pages / CORS-enabled event data viasource.VideoEmbed: Renders direct video assets or hosted video players.StlViewer: Interactive WebGL preview of an.stlmodel (parseStl,isStlUrlalongside).StrudelPlayer: Audio player for a Strudel pattern (code) or.strudelfile (src), with an optional equipment rack.StrudelRack: Equipment generated from Strudel code (controlled:code,onChange);parseStrudel/parseMiniare its syntax tree.OpenApiOperation: Renders a parsed OpenAPI operation block.SourcePreview: Imports and renders a mounted component or CSF story export.createViteSourceClient: Reads, writes, and imports files exposed by the Vite plugin.createBundledSourceClient: Imports bundled source modules in read-only production builds.DemoViewer/DemoFullscreen: The{% demo %}viewer, and one demo filling the viewport (for?demo=deep links).createGlobDemoResolver,demoHref,demoFromSearch: Build aDemoResolverfromimport.meta.globmaps; deep-link helpers.resolveDemosToMarkdown(markdown, resolver, { format }): Gives each{% demo %}its real files inline — renderable docstream by default,format: "plain"for LLM prompts (pure, async).inlineDemoMarkdown(node, files, meta): One demo as a pasteable block-form{% demo %}.preloadDemos(resolver, srcs): Settle resolver demos before the first render (SSR, static export).createAlmostNodeDemoRuntime(options)/InlineDemoPreview(/playground): Run inline demo files in almost-node.createInlineDemoProject(files, { entry, dependencies })(root and/playground) builds the runnable project as plain data.PlaygroundStreamdown,almostNodeLiveRenderer,ReactDemo/ReactCodePreview,createAlmostNodeWorkspace,useAlmostNodeServer(/playgroundonly): the almost-node playground.DocPageActions: The "Copy page ▾" menu.
Parser and Serializer
parseMarkdown(markdown): Converts a full markdown document into aDocumentNode.parseBlocks(lines): Parses an array of lines (markdown.split("\n")) into block nodes, without frontmatter or document-level citations.serializeMarkdown(doc): Converts aDocumentNodeback to markdown.serializeBlocks(blocks): Serializes block nodes.parseInline(markdown): Parses inline markdown nodes.serializeInline(nodes): Serializes inline nodes.plainText(nodes): Extracts plain text from inline nodes.refDefinitions/footnoteDefinitions: Module-levelMaps of the reference-link and footnote definitions, filled byparseMarkdownand read byparseInline(callparseMarkdownfirst when parsing inline text that uses[text][ref]or[^1]).packageManagerCommands(npmCommand, overrides): The npm / pnpm / yarn / bun forms of a command.documentOutline(doc)/slugify(text): Headings (incl. titled tab sets) with anchor ids, for a TOC.flattenForPlainMarkdown(doc): docstream-only blocks (commands, titled tabs) as plain Markdown.
Types
All AST types are exported from the package root, including DocumentNode, Block, Inline, and HintStyle.
Styling and Theming
The package CSS is intentionally token-driven. It uses normal CSS variables and class names so host apps can align the renderer with a shadcn-style theme.
:root {
--background: #ffffff;
--foreground: #0f172a;
--card: #ffffff;
--border: #e2e8f0;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--primary: #2563eb;
}The tokens are read as complete colors (var(--border)), so give each one a full color —
hex, rgb(), hsl(), oklch(). Bare HSL channels (214.3 31.8% 91.4%, the shadcn v3
convention) aren't colors on their own; wrap them (--border: hsl(214.3 31.8% 91.4%)) or set
docstream's own --gb-* tokens instead.
Import the CSS once, then set tokens globally in your app. The command box's
terminal glyph has its own tokens (--gb-term-bg, --gb-term-fg), so setting
panel colors to currentColor doesn't blank it. Components also expose stable classes such as docs-code, docs-tabs, docs-hint, docs-table, and docs-openapi.
Code highlighting follows the surrounding color-scheme: set color-scheme: dark on a dark page (or any dark container) and code switches to the dark palette. Each token color is a custom property (--docs-tok-keyword, --docs-tok-string, --docs-tok-type, --docs-tok-function, --docs-tok-number, --docs-tok-constant, --docs-tok-operator, --docs-tok-comment) if you want your own palette.
Live demos with in-page components
ReactDemo from @brett_lamy/docstream/playground boots an almost-node Vite server for self-contained projects. When the components already run on your page, pass preview instead: you keep the same frame, header, and collapsible code panel without a runtime.
Runtime previews require the almost-node service worker at /__sw__.js. Add
almostnodePlugin() from @agent-wasm/core/vite to a Vite development server;
for a production host, serve getServiceWorkerContent() from
@agent-wasm/core/next as that JavaScript asset. With a deployment subpath, pass
workspaceOptions={{ basePath: "/docs" }} and serve /docs/__sw__.js.
Docstream waits for the worker to control the page before opening the preview,
without reloading the reader's document.
import { ReactDemo } from "@brett_lamy/docstream/playground"
<ReactDemo
title="Counter"
status={false}
preview={<Counter />}
height="auto"
code={`import { Counter } from "my-ui"\n\nexport default () => <Counter />`}
language="tsx"
/>Bundler Notes
Since 1.4 the package ships compiled ES modules (ES2022, react-jsx) with type
declarations and declaration maps — one file per source module in dist/,
dependencies left as imports, lazy features (Pele, KaTeX, rrweb, the
playground's almost-node) still behind dynamic import():
{
"exports": {
".": {
"source": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./styles.css": "./dist/styles.css"
}
}No bundler configuration is needed: Vite pre-bundles docstream like any other
dependency (no optimizeDeps entries), Next.js and webpack consume it as plain
ESM, and your type-check reads .d.ts files instead of docstream's sources. The
@brett_lamy/docstream/vite entry is a Node-only Vite plugin; import it from
vite.config.ts, never from browser code.
The TypeScript sources are published too, behind a source export condition, for
monorepos that want to compile docstream from source (e.g. Vite
resolve.conditions: ["source", ...] or TypeScript customConditions).
Migrating to 1.5
No breaking changes. New: Copy demo (clipboard with the demo's files and an
application/x-docstream+json payload), the demo VFS (demoVFS on the
renderers, createDemoVFS / useDemoVFS), and the clipboard helpers above.
react-aria is now a dependency (for useClipboard); hosts on
react-aria-components share it. The single-file demo card gains the Copy demo
button; the multi-file viewer's "Copy demo as Markdown" is now "Copy demo" (the
same Markdown as text/plain, plus the payload).
Migrating to 1.4
No API changes. Exports now resolve to dist/*.js + dist/*.d.ts instead of
src/*.ts(x); drop any optimizeDeps.include / exclude or
transpilePackages entries you added for docstream, and any lib / types
settings you added only so your type-check could compile docstream's sources.
Migrating to 1.3
1.3 moves everything that touches almost-node (@agent-wasm/core) out of the
package root, so installing docstream no longer drags a WASM Node runtime and
native compressors into every app. @agent-wasm/core is now an optional peer.
| 1.2 (root import) | 1.3 |
| --- | --- |
| GitbookStreamdown (ran live code + inline demos in almost-node) | Root GitbookStreamdown renders them as code unless you pass liveRenderer / demoRuntime. For the old behaviour: import { GitbookStreamdown } from "@brett_lamy/docstream/playground" and install @agent-wasm/core. |
| ReactCodePreview, ReactDemo, InlineDemoPreview, almostNodeDemoRuntime, createAlmostNodeDemoRuntime, createAlmostNodeFilesystem, createAlmostNodeWorkspace and their option/prop types | @brett_lamy/docstream/playground |
| VizEmbed, VizEmbedProps | @brett_lamy/docstream/viz, and install @brett_lamy/viz-engine (now an optional peer, no longer a dependency) |
| Streamdown, StreamdownProps (re-export of streamdown) | Removed: install streamdown and import it directly |
createInlineDemoProject, createReactDemoFiles and the AlmostNodeFile* data
types stay on the root (they are plain data helpers). Math blocks now show their
TeX source for a moment while KaTeX loads.
Related Package
Use @brett_lamy/docstream-editor when you need the editable TipTap experience for the same document model.
XState and tscircuit fences
Use an xstate fence for a JSON machine config or static TypeScript createMachine({...}) source, and a tscircuit fence for circuit TSX (circuit.add(<board>...</board>)). Both render the source beside its preview, like Mermaid. XState actions and guards are never executed. Circuit code runs in the hosted cross-origin RunFrame, which requires a network connection. Its compiler starts in an isolated worker using a tested, pinned release; circuit source never executes in the host page. Runtime failures have a retry control.
The React components XStateDiagram, XStatePreview, TsCircuit, and TsCircuitPreview are available from the root entry and the lightweight /xstate and /tscircuit entries. Result-only previews support custom live editors.
