@brett_lamy/docstream 1.2.4 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -9
- package/package.json +9 -3
- package/src/demo/types.ts +2 -2
- package/src/docs/DocsRenderer.tsx +31 -7
- package/src/docs/math.ts +3 -0
- package/src/index.ts +10 -28
- package/src/playground/PlaygroundStreamdown.tsx +28 -61
- package/src/playground/filesystem.ts +10 -190
- package/src/playground/index.ts +4 -0
- package/src/playground/project.ts +194 -0
- package/src/replay/NativeReplay.tsx +236 -0
- package/src/replay/ReplayPreview.tsx +7 -225
- package/src/styles.css +0 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ GitBook-aware markdown rendering for React applications and AI streaming surface
|
|
|
13
13
|
Markdown vs HTML images, blank lines between blocks, list markers, fences, table layout and tag lines as written).
|
|
14
14
|
Layout-only fields (`gap`, `raw`, `opening`, …) are recorded only where the source differs from the default
|
|
15
15
|
output, and are ignored once they no longer fit an edited node.
|
|
16
|
-
- Syntax-highlighted code blocks through `
|
|
16
|
+
- Syntax-highlighted code blocks through `gpu-lexer`; Mermaid diagrams and KaTeX math load on first use.
|
|
17
17
|
- GitBook block support for hints, tabs, expandables, steppers, embeds, content refs, columns, figures, tables, math, dividers, updates, and OpenAPI operations.
|
|
18
18
|
- CSS exported as a stable package entrypoint so host apps can theme with CSS variables or shadcn-style design tokens.
|
|
19
19
|
- Attribute-aware direct video embeds for muted, looping inline clips in long-form posts.
|
|
@@ -23,10 +23,31 @@ GitBook-aware markdown rendering for React applications and AI streaming surface
|
|
|
23
23
|
## Installation
|
|
24
24
|
|
|
25
25
|
```sh
|
|
26
|
-
|
|
26
|
+
pnpm add @brett_lamy/docstream
|
|
27
|
+
# or: npm install @brett_lamy/docstream
|
|
27
28
|
```
|
|
28
29
|
|
|
29
|
-
React
|
|
30
|
+
React and React DOM are peer dependencies and must be provided by your app.
|
|
31
|
+
Nothing in docstream's dependency tree runs an install script, so
|
|
32
|
+
`pnpm add` succeeds with no build-script prompts (no `ERR_PNPM_IGNORED_BUILDS`).
|
|
33
|
+
Heavy renderers are loaded only when a page needs them: Mermaid for
|
|
34
|
+
` ```mermaid ` fences, KaTeX for math blocks, rrweb for replay event lists.
|
|
35
|
+
|
|
36
|
+
Two features are opt-in, each behind its own entry and an **optional** peer
|
|
37
|
+
dependency that the rest of the package never references:
|
|
38
|
+
|
|
39
|
+
| Entry | Install to use it | What it adds |
|
|
40
|
+
| --- | --- | --- |
|
|
41
|
+
| `@brett_lamy/docstream/playground` | `@agent-wasm/core` (almost-node, an in-browser Node runtime) | Live React code fences and runnable inline `{% demo %}` files |
|
|
42
|
+
| `@brett_lamy/docstream/viz` | `@brett_lamy/viz-engine` | `VizEmbed` |
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
pnpm add @agent-wasm/core # only if you import @brett_lamy/docstream/playground
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Without the playground, live code fences render as highlighted code (or through
|
|
49
|
+
a `liveRenderer` you pass), and inline-file demos open on their Code view with a
|
|
50
|
+
short "needs the playground runtime" note.
|
|
30
51
|
|
|
31
52
|
## Basic Setup
|
|
32
53
|
|
|
@@ -50,11 +71,12 @@ If your TypeScript app checks CSS side-effect imports, include Vite's standard e
|
|
|
50
71
|
|
|
51
72
|
Use `GitbookStreamdown` when markdown may arrive incrementally from an AI stream. The component accepts either `markdown` or string children.
|
|
52
73
|
|
|
53
|
-
|
|
54
|
-
|
|
74
|
+
The package root's `GitbookStreamdown` is the same component as the
|
|
75
|
+
`@brett_lamy/docstream/streamdown` entry. For live code fences and runnable
|
|
76
|
+
inline demos, import the playground variant instead (requires `@agent-wasm/core`):
|
|
55
77
|
|
|
56
78
|
```tsx
|
|
57
|
-
import { GitbookStreamdown } from "@brett_lamy/docstream/
|
|
79
|
+
import { GitbookStreamdown } from "@brett_lamy/docstream/playground" // = PlaygroundStreamdown
|
|
58
80
|
```
|
|
59
81
|
|
|
60
82
|
```tsx
|
|
@@ -367,13 +389,15 @@ what it did before 1.2: a demo, then a code block. While streaming, a block whos
|
|
|
367
389
|
| inline files | no runtime (e.g. the `/streamdown` entry) | a one-line note; the viewer opens on Code | the inline files |
|
|
368
390
|
| `src` only | no resolver | a placeholder chip | — |
|
|
369
391
|
|
|
370
|
-
|
|
371
|
-
in almost-node by default.
|
|
392
|
+
`PlaygroundStreamdown` (also exported as `GitbookStreamdown` from
|
|
393
|
+
`@brett_lamy/docstream/playground`) runs inline demos in almost-node by default.
|
|
394
|
+
Elsewhere pass `demoRuntime` (and `liveRenderer` for live code fences):
|
|
372
395
|
|
|
373
396
|
```tsx
|
|
374
397
|
import { createAlmostNodeDemoRuntime } from "@brett_lamy/docstream/playground"
|
|
375
398
|
|
|
376
399
|
const demoRuntime = createAlmostNodeDemoRuntime({ workspaceOptions: { basePath: "/docs" } })
|
|
400
|
+
// live code fences: import { almostNodeLiveRenderer } from "@brett_lamy/docstream/playground"
|
|
377
401
|
|
|
378
402
|
<MarkdownContent markdown={page} demoResolver={demoResolver} demoRuntime={demoRuntime}
|
|
379
403
|
demoDependencies={{ "@brett_lamy/ui": "^1.2.0" }} />
|
|
@@ -546,7 +570,8 @@ You can also resolve paths yourself with `resolveAsset`.
|
|
|
546
570
|
- `resolveDemosToMarkdown(markdown, resolver, { format })`: Gives each `{% demo %}` its real files inline — renderable docstream by default, `format: "plain"` for LLM prompts (pure, async).
|
|
547
571
|
- `inlineDemoMarkdown(node, files, meta)`: One demo as a pasteable block-form `{% demo %}`.
|
|
548
572
|
- `preloadDemos(resolver, srcs)`: Settle resolver demos before the first render (SSR, static export).
|
|
549
|
-
- `createAlmostNodeDemoRuntime(options)` / `InlineDemoPreview`
|
|
573
|
+
- `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.
|
|
574
|
+
- `PlaygroundStreamdown`, `almostNodeLiveRenderer`, `ReactDemo` / `ReactCodePreview`, `createAlmostNodeWorkspace`, `useAlmostNodeServer` (`/playground` only): the almost-node playground.
|
|
550
575
|
- `DocPageActions`: The "Copy page ▾" menu.
|
|
551
576
|
|
|
552
577
|
### Parser and Serializer
|
|
@@ -623,6 +648,23 @@ This release ships TypeScript and TSX source through ESM exports:
|
|
|
623
648
|
|
|
624
649
|
It is validated with Vite and modern TypeScript `moduleResolution: "Bundler"`. Plain Node.js, CommonJS, or tooling that does not transpile TypeScript in dependencies may need a future precompiled JS build.
|
|
625
650
|
|
|
651
|
+
## Migrating to 1.3
|
|
652
|
+
|
|
653
|
+
1.3 moves everything that touches almost-node (`@agent-wasm/core`) out of the
|
|
654
|
+
package root, so installing docstream no longer drags a WASM Node runtime and
|
|
655
|
+
native compressors into every app. `@agent-wasm/core` is now an optional peer.
|
|
656
|
+
|
|
657
|
+
| 1.2 (root import) | 1.3 |
|
|
658
|
+
| --- | --- |
|
|
659
|
+
| `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`. |
|
|
660
|
+
| `ReactCodePreview`, `ReactDemo`, `InlineDemoPreview`, `almostNodeDemoRuntime`, `createAlmostNodeDemoRuntime`, `createAlmostNodeFilesystem`, `createAlmostNodeWorkspace` and their option/prop types | `@brett_lamy/docstream/playground` |
|
|
661
|
+
| `VizEmbed`, `VizEmbedProps` | `@brett_lamy/docstream/viz`, and install `@brett_lamy/viz-engine` (now an optional peer, no longer a dependency) |
|
|
662
|
+
| `Streamdown`, `StreamdownProps` (re-export of `streamdown`) | Removed: install `streamdown` and import it directly |
|
|
663
|
+
|
|
664
|
+
`createInlineDemoProject`, `createReactDemoFiles` and the `AlmostNodeFile*` data
|
|
665
|
+
types stay on the root (they are plain data helpers). Math blocks now show their
|
|
666
|
+
TeX source for a moment while KaTeX loads.
|
|
667
|
+
|
|
626
668
|
## Related Package
|
|
627
669
|
|
|
628
670
|
Use `@brett_lamy/docstream-editor` when you need the editable TipTap experience for the same document model.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brett_lamy/docstream",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "GitBook-aware readonly markdown and AI stream renderer.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -78,23 +78,28 @@
|
|
|
78
78
|
"./styles.css": "./src/styles.css"
|
|
79
79
|
},
|
|
80
80
|
"dependencies": {
|
|
81
|
-
"@brett_lamy/viz-engine": ">=0.2.0",
|
|
82
81
|
"framer-motion": "^13.1.1",
|
|
83
82
|
"gpu-lexer": "^0.0.2",
|
|
84
83
|
"katex": "^0.18.5",
|
|
85
84
|
"lucide-react": "^1.17.0",
|
|
86
85
|
"mermaid": "^11.15.0",
|
|
87
86
|
"rrweb": "2.0.0-alpha.20",
|
|
88
|
-
"streamdown": "^2.5.0",
|
|
89
87
|
"yaml": "^2.9.0"
|
|
90
88
|
},
|
|
91
89
|
"peerDependencies": {
|
|
92
90
|
"@agent-wasm/core": ">=0.4.0",
|
|
93
91
|
"@brett_lamy/viz-engine": ">=0.2.0",
|
|
94
92
|
"react": ">=18",
|
|
93
|
+
"react-dom": ">=18",
|
|
95
94
|
"vite": ">=5"
|
|
96
95
|
},
|
|
97
96
|
"peerDependenciesMeta": {
|
|
97
|
+
"@agent-wasm/core": {
|
|
98
|
+
"optional": true
|
|
99
|
+
},
|
|
100
|
+
"@brett_lamy/viz-engine": {
|
|
101
|
+
"optional": true
|
|
102
|
+
},
|
|
98
103
|
"vite": {
|
|
99
104
|
"optional": true
|
|
100
105
|
}
|
|
@@ -105,6 +110,7 @@
|
|
|
105
110
|
},
|
|
106
111
|
"devDependencies": {
|
|
107
112
|
"@agent-wasm/core": "^0.4.0",
|
|
113
|
+
"@brett_lamy/viz-engine": "^0.2.1",
|
|
108
114
|
"@types/node": "^24.0.0",
|
|
109
115
|
"@types/react": "^18.3.3",
|
|
110
116
|
"typescript": "^5.5.4",
|
package/src/demo/types.ts
CHANGED
|
@@ -85,8 +85,8 @@ export interface InlineDemoRuntimeProps {
|
|
|
85
85
|
/**
|
|
86
86
|
* Renders the Preview of a `{% demo %}` block that carries its files inline and has
|
|
87
87
|
* no resolver behind it. `@brett_lamy/docstream/playground` provides an almost-node
|
|
88
|
-
* implementation (`createAlmostNodeDemoRuntime`), which `PlaygroundStreamdown`
|
|
89
|
-
* package root
|
|
88
|
+
* implementation (`createAlmostNodeDemoRuntime`), which that entry's `PlaygroundStreamdown`
|
|
89
|
+
* wires in by default. The package root never loads a runtime on its own.
|
|
90
90
|
*/
|
|
91
91
|
export type InlineDemoRuntime = (props: InlineDemoRuntimeProps) => ReactNode
|
|
92
92
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { useContext, useId, useMemo, useState, type ReactNode } from "react"
|
|
1
|
+
import { useContext, useEffect, useId, useMemo, useState, type ReactNode } from "react"
|
|
2
2
|
import { motion, useReducedMotion } from "framer-motion"
|
|
3
|
-
import "katex/dist/katex.min.css"
|
|
4
3
|
import {
|
|
5
4
|
AlertTriangle,
|
|
6
5
|
CheckCircle2,
|
|
@@ -24,16 +23,41 @@ import { CopyButton } from "./copy"
|
|
|
24
23
|
import { PillTabs, rovingKeyDown } from "./controls"
|
|
25
24
|
import { DocPageActions, type DocPageActionsOptions } from "./PageActions"
|
|
26
25
|
import { useSyncedTab } from "./tabs-sync"
|
|
27
|
-
import { ReplayPreview
|
|
26
|
+
import { ReplayPreview } from "../replay/ReplayPreview"
|
|
27
|
+
import { isReplayQaUrl } from "../replay/url"
|
|
28
28
|
import { VideoEmbed } from "../video"
|
|
29
29
|
import { OpenApiOperation } from "../openapi/OpenApiOperation"
|
|
30
30
|
import { Mermaid } from "./Mermaid"
|
|
31
31
|
import { HighlightedCode } from "./HighlightedCode"
|
|
32
32
|
import { CitationSources, InlineReference } from "./reference"
|
|
33
|
-
import { renderMathToHtml } from "./math"
|
|
34
33
|
|
|
34
|
+
type MathRenderer = (formula: string) => string
|
|
35
|
+
let mathRenderer: MathRenderer | null = null
|
|
36
|
+
let mathLoad: Promise<MathRenderer> | null = null
|
|
37
|
+
function loadMathRenderer(): Promise<MathRenderer> {
|
|
38
|
+
mathLoad ??= import("./math").then((m) => (mathRenderer = m.renderMathToHtml))
|
|
39
|
+
return mathLoad
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// KaTeX loads on first use; until then the TeX source shows in place.
|
|
35
43
|
function MathBlock({ formula }: { formula: string }) {
|
|
36
|
-
|
|
44
|
+
const [render, setRender] = useState<MathRenderer | null>(() => mathRenderer)
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (render) return
|
|
47
|
+
let live = true
|
|
48
|
+
loadMathRenderer().then((fn) => live && setRender(() => fn), () => {})
|
|
49
|
+
return () => {
|
|
50
|
+
live = false
|
|
51
|
+
}
|
|
52
|
+
}, [render])
|
|
53
|
+
if (!render) {
|
|
54
|
+
return (
|
|
55
|
+
<div className="docs-math" data-pending="">
|
|
56
|
+
<code>{formula}</code>
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
return <div className="docs-math" dangerouslySetInnerHTML={{ __html: render(formula) }} />
|
|
37
61
|
}
|
|
38
62
|
|
|
39
63
|
export interface LivePreviewProps {
|
|
@@ -561,8 +585,8 @@ export interface DocRenderOptions {
|
|
|
561
585
|
demoResolver?: DemoResolver
|
|
562
586
|
/**
|
|
563
587
|
* Runs the Preview of `{% demo %}` blocks that carry their files inline and have no
|
|
564
|
-
* resolver behind them.
|
|
565
|
-
*
|
|
588
|
+
* resolver behind them. Nothing is supplied by default: pass `createAlmostNodeDemoRuntime()`
|
|
589
|
+
* from `@brett_lamy/docstream/playground` (or use its `PlaygroundStreamdown`) to run them.
|
|
566
590
|
* Without one, such demos show their code with a short note.
|
|
567
591
|
*/
|
|
568
592
|
demoRuntime?: InlineDemoRuntime
|
package/src/docs/math.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
/* KaTeX lives behind this module, which DocsRenderer loads with a dynamic import the
|
|
2
|
+
first time a page has a math block — pages without math never download KaTeX. */
|
|
1
3
|
import katex from "katex"
|
|
4
|
+
import "katex/dist/katex.min.css"
|
|
2
5
|
|
|
3
6
|
export function renderMathToHtml(formula: string): string {
|
|
4
7
|
return katex.renderToString(formula, {
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// The root entry never references `@agent-wasm/core` (almost-node). Live code fences and
|
|
2
|
+
// inline-file demos run only when the host passes a runtime — see `@brett_lamy/docstream/playground`.
|
|
3
|
+
export { GitbookStreamdown } from "./streamdown"
|
|
4
|
+
export type { GitbookStreamdownProps } from "./streamdown"
|
|
3
5
|
export { DocsRenderer, MarkdownContent } from "./docs/DocsRenderer"
|
|
4
6
|
export type { DocRenderOptions, LivePreviewProps, LivePreviewRenderer, SourceReferenceRenderer } from "./docs/DocsRenderer"
|
|
5
7
|
export { DocPageActions } from "./docs/PageActions"
|
|
@@ -47,45 +49,27 @@ export type {
|
|
|
47
49
|
} from "./demo"
|
|
48
50
|
export { CitationSources, DocstreamRefContext, InlineReference } from "./docs/reference"
|
|
49
51
|
export type { CitationPreview, CitationPreviewResolver, DocstreamRefContextValue } from "./docs/reference"
|
|
50
|
-
export { ReplayEmbed, ReplayPreview } from "./replay"
|
|
52
|
+
export { ReplayEmbed, ReplayPreview } from "./replay/ReplayPreview"
|
|
51
53
|
export type {
|
|
52
54
|
ReplayEventsSource,
|
|
53
55
|
ReplayEventsUrlSource,
|
|
54
56
|
ReplayPageSource,
|
|
55
57
|
ReplayPreviewProps,
|
|
56
58
|
ReplaySource,
|
|
57
|
-
} from "./replay"
|
|
58
|
-
export { isReplayQaUrl, normalizeReplayEmbedUrl } from "./replay"
|
|
59
|
+
} from "./replay/ReplayPreview"
|
|
60
|
+
export { isReplayQaUrl, normalizeReplayEmbedUrl } from "./replay/url"
|
|
59
61
|
export { VideoEmbed } from "./video"
|
|
60
62
|
export type { VideoEmbedProps } from "./video"
|
|
61
|
-
export { VizEmbed } from "./viz"
|
|
62
|
-
export type { VizEmbedProps } from "./viz"
|
|
63
63
|
export { OpenApiOperation } from "./openapi/OpenApiOperation"
|
|
64
|
-
export {
|
|
65
|
-
InlineDemoPreview,
|
|
66
|
-
ReactCodePreview,
|
|
67
|
-
ReactDemo,
|
|
68
|
-
almostNodeDemoRuntime,
|
|
69
|
-
createAlmostNodeDemoRuntime,
|
|
70
|
-
createInlineDemoProject,
|
|
71
|
-
createReactDemoFiles,
|
|
72
|
-
createAlmostNodeFilesystem,
|
|
73
|
-
createAlmostNodeWorkspace,
|
|
74
|
-
} from "./playground"
|
|
64
|
+
export { createInlineDemoProject, createReactDemoFiles } from "./playground/project"
|
|
75
65
|
export type {
|
|
76
66
|
AlmostNodeFile,
|
|
77
67
|
AlmostNodeFileContent,
|
|
78
68
|
AlmostNodeFiles,
|
|
79
|
-
AlmostNodeFilesystemOptions,
|
|
80
|
-
AlmostNodeWorkspace,
|
|
81
|
-
AlmostNodeWorkspaceOptions,
|
|
82
|
-
AlmostNodeDemoRuntimeOptions,
|
|
83
|
-
InlineDemoPreviewProps,
|
|
84
69
|
InlineDemoProject,
|
|
85
70
|
InlineDemoProjectOptions,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
} from "./playground"
|
|
71
|
+
ReactDemoFilesOptions,
|
|
72
|
+
} from "./playground/project"
|
|
89
73
|
export { resolveAsset, setAssetBase } from "./assets"
|
|
90
74
|
export { createBundledSourceClient, createViteSourceClient, SourcePreview } from "./source"
|
|
91
75
|
export type {
|
|
@@ -108,8 +92,6 @@ export {
|
|
|
108
92
|
serializeInline,
|
|
109
93
|
serializeReference,
|
|
110
94
|
} from "./gitbook/inline"
|
|
111
|
-
export { Streamdown } from "streamdown"
|
|
112
|
-
export type { StreamdownProps } from "streamdown"
|
|
113
95
|
export type * from "./gitbook/ast"
|
|
114
96
|
export { PACKAGE_MANAGERS, packageManagerCommands } from "./gitbook/package-managers"
|
|
115
97
|
export { flattenBlocks, flattenForPlainMarkdown } from "./gitbook/flatten"
|
|
@@ -1,73 +1,40 @@
|
|
|
1
|
-
import { useContext
|
|
1
|
+
import { useContext } from "react"
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import { parseMarkdown, trimPartialInlineToken } from "../gitbook/parse"
|
|
3
|
+
import type { LivePreviewRenderer } from "../docs/DocsRenderer"
|
|
5
4
|
import { DocstreamDemoRuntimeContext } from "../demo/context"
|
|
5
|
+
import { GitbookStreamdown, type GitbookStreamdownProps } from "../streamdown"
|
|
6
6
|
import { almostNodeDemoRuntime } from "./InlineDemoPreview"
|
|
7
7
|
import { ReactCodePreview } from "./ReactCodePreview"
|
|
8
8
|
|
|
9
|
-
export
|
|
10
|
-
children?: string
|
|
11
|
-
sourceRenderer?: SourceReferenceRenderer
|
|
12
|
-
className?: string
|
|
13
|
-
isAnimating?: boolean
|
|
14
|
-
isStreaming?: boolean
|
|
15
|
-
markdown?: string
|
|
16
|
-
}
|
|
9
|
+
export type PlaygroundStreamdownProps = GitbookStreamdownProps
|
|
17
10
|
|
|
18
11
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* `@brett_lamy/docstream/streamdown` entry instead. Live code fences and
|
|
22
|
-
* `{% demo %}` blocks with inline files (and no resolver) run in almost-node.
|
|
12
|
+
* A `LivePreviewRenderer` that runs `live` React/JSX/TSX code fences in almost-node.
|
|
13
|
+
* Pass it as `liveRenderer` to the root `GitbookStreamdown` / `DocsRenderer` (or the editor).
|
|
23
14
|
*/
|
|
24
|
-
export
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
demoDependencies,
|
|
34
|
-
pageActions,
|
|
35
|
-
}: PlaygroundStreamdownProps) {
|
|
36
|
-
const content = markdown ?? children ?? ""
|
|
37
|
-
// Inline-file demos run in almost-node unless the host (or an outer renderer) chose a runtime.
|
|
38
|
-
const inheritedRuntime = useContext(DocstreamDemoRuntimeContext).runtime
|
|
39
|
-
const runtime = demoRuntime ?? inheritedRuntime ?? almostNodeDemoRuntime
|
|
40
|
-
const doc = useMemo(
|
|
41
|
-
() => parseMarkdown(isStreaming ? trimPartialInlineToken(content) : content),
|
|
42
|
-
[content, isStreaming]
|
|
43
|
-
)
|
|
44
|
-
const liveRenderer: LivePreviewRenderer = ({ files, entry, title, collapsedCodeLines, expandedCodeLines }) => (
|
|
45
|
-
<ReactCodePreview
|
|
46
|
-
files={files}
|
|
47
|
-
entry={entry}
|
|
48
|
-
title={title}
|
|
49
|
-
collapsedCodeLines={collapsedCodeLines}
|
|
50
|
-
expandedCodeLines={expandedCodeLines}
|
|
51
|
-
/>
|
|
52
|
-
)
|
|
15
|
+
export const almostNodeLiveRenderer: LivePreviewRenderer = ({ files, entry, title, collapsedCodeLines, expandedCodeLines }) => (
|
|
16
|
+
<ReactCodePreview
|
|
17
|
+
files={files}
|
|
18
|
+
entry={entry}
|
|
19
|
+
title={title}
|
|
20
|
+
collapsedCodeLines={collapsedCodeLines}
|
|
21
|
+
expandedCodeLines={expandedCodeLines}
|
|
22
|
+
/>
|
|
23
|
+
)
|
|
53
24
|
|
|
25
|
+
/**
|
|
26
|
+
* `GitbookStreamdown` with the almost-node playground wired in: live code fences and
|
|
27
|
+
* `{% demo %}` blocks with inline files (and no resolver) run in almost-node
|
|
28
|
+
* (`@agent-wasm/core`, an optional peer dependency — install it to use this entry).
|
|
29
|
+
* Explicit `liveRenderer` / `demoRuntime` props (or an outer runtime context) still win.
|
|
30
|
+
*/
|
|
31
|
+
export function PlaygroundStreamdown({ liveRenderer, demoRuntime, ...props }: PlaygroundStreamdownProps) {
|
|
32
|
+
const inheritedRuntime = useContext(DocstreamDemoRuntimeContext).runtime
|
|
54
33
|
return (
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
>
|
|
61
|
-
<DocsRenderer
|
|
62
|
-
doc={doc}
|
|
63
|
-
markdown={content}
|
|
64
|
-
liveRenderer={liveRenderer}
|
|
65
|
-
sourceRenderer={sourceRenderer}
|
|
66
|
-
demoResolver={demoResolver}
|
|
67
|
-
demoRuntime={runtime}
|
|
68
|
-
demoDependencies={demoDependencies}
|
|
69
|
-
pageActions={isStreaming ? false : pageActions}
|
|
70
|
-
/>
|
|
71
|
-
</div>
|
|
34
|
+
<GitbookStreamdown
|
|
35
|
+
{...props}
|
|
36
|
+
liveRenderer={liveRenderer ?? almostNodeLiveRenderer}
|
|
37
|
+
demoRuntime={demoRuntime ?? inheritedRuntime ?? almostNodeDemoRuntime}
|
|
38
|
+
/>
|
|
72
39
|
)
|
|
73
40
|
}
|
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
import type { ContainerInstance, ContainerOptions, VirtualFS } from "@agent-wasm/core"
|
|
2
|
+
import {
|
|
3
|
+
normalizeAlmostNodeFiles,
|
|
4
|
+
normalizeAlmostNodePath,
|
|
5
|
+
type AlmostNodeFileContent,
|
|
6
|
+
type AlmostNodeFiles,
|
|
7
|
+
type AlmostNodeFilesystemOptions,
|
|
8
|
+
} from "./project"
|
|
2
9
|
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
export interface AlmostNodeFile {
|
|
6
|
-
path: string
|
|
7
|
-
content: AlmostNodeFileContent
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export type AlmostNodeFiles =
|
|
11
|
-
| Readonly<Record<string, AlmostNodeFileContent>>
|
|
12
|
-
| readonly AlmostNodeFile[]
|
|
13
|
-
|
|
14
|
-
export interface AlmostNodeFilesystemOptions {
|
|
15
|
-
/** Prefix for relative file paths. Defaults to the virtual root. */
|
|
16
|
-
root?: string
|
|
17
|
-
}
|
|
10
|
+
export * from "./project"
|
|
18
11
|
|
|
19
12
|
export interface AlmostNodeWorkspaceOptions extends Omit<ContainerOptions, "cwd"> {
|
|
20
13
|
/** Project root used as the container cwd and Vite root. */
|
|
@@ -44,49 +37,6 @@ async function loadAlmostNode(): Promise<AlmostNodeModule> {
|
|
|
44
37
|
}
|
|
45
38
|
}
|
|
46
39
|
|
|
47
|
-
export function normalizeAlmostNodePath(path: string, root = "/"): string {
|
|
48
|
-
const normalizedRoot = normalizeRoot(root)
|
|
49
|
-
const source = path.replaceAll("\\", "/")
|
|
50
|
-
const absolute = source.startsWith("/") ? source : `${normalizedRoot}/${source}`
|
|
51
|
-
const result = normalizeAbsolutePath(absolute)
|
|
52
|
-
|
|
53
|
-
if (normalizedRoot !== "/" && result !== normalizedRoot && !result.startsWith(`${normalizedRoot}/`)) {
|
|
54
|
-
throw new Error(`File path escapes the almost-node root: ${path}`)
|
|
55
|
-
}
|
|
56
|
-
return result
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function normalizeAbsolutePath(path: string): string {
|
|
60
|
-
const parts: string[] = []
|
|
61
|
-
|
|
62
|
-
for (const part of path.split("/")) {
|
|
63
|
-
if (!part || part === ".") continue
|
|
64
|
-
if (part === "..") {
|
|
65
|
-
if (parts.length > 0) parts.pop()
|
|
66
|
-
continue
|
|
67
|
-
}
|
|
68
|
-
if (part.includes("\0")) throw new Error("almost-node file paths cannot contain null bytes")
|
|
69
|
-
parts.push(part)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const result = `/${parts.join("/")}`
|
|
73
|
-
return result === "" ? "/" : result
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function normalizeAlmostNodeFiles(files: AlmostNodeFiles): AlmostNodeFile[] {
|
|
77
|
-
if (Array.isArray(files)) return files.map((file) => ({ ...file }))
|
|
78
|
-
return Object.entries(files).map(([path, content]) => ({ path, content }))
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export function toAlmostNodeFileMap(files: AlmostNodeFiles): Record<string, AlmostNodeFileContent> {
|
|
82
|
-
return Object.fromEntries(normalizeAlmostNodeFiles(files).map(({ path, content }) => [path, content]))
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function normalizeRoot(root: string): string {
|
|
86
|
-
const normalized = normalizeAbsolutePath(root.replaceAll("\\", "/"))
|
|
87
|
-
return normalized === "/" ? normalized : normalized.replace(/\/$/, "")
|
|
88
|
-
}
|
|
89
|
-
|
|
90
40
|
function parentDirectory(path: string): string {
|
|
91
41
|
const index = path.lastIndexOf("/")
|
|
92
42
|
return index <= 0 ? "/" : path.slice(0, index)
|
|
@@ -110,7 +60,7 @@ export async function createAlmostNodeFilesystem(
|
|
|
110
60
|
options: AlmostNodeFilesystemOptions = {},
|
|
111
61
|
): Promise<VirtualFS> {
|
|
112
62
|
const { VirtualFS } = await loadAlmostNode()
|
|
113
|
-
const root =
|
|
63
|
+
const root = normalizeAlmostNodePath(options.root ?? "/")
|
|
114
64
|
const vfs = new VirtualFS()
|
|
115
65
|
writeFilesToVfs(vfs, files, root)
|
|
116
66
|
return vfs
|
|
@@ -125,7 +75,7 @@ export async function createAlmostNodeWorkspace(
|
|
|
125
75
|
options: AlmostNodeWorkspaceOptions = {},
|
|
126
76
|
): Promise<AlmostNodeWorkspace> {
|
|
127
77
|
const { root: requestedRoot, ...containerOptions } = options
|
|
128
|
-
const root =
|
|
78
|
+
const root = normalizeAlmostNodePath(requestedRoot ?? "/")
|
|
129
79
|
const { createContainer } = await loadAlmostNode()
|
|
130
80
|
const container = createContainer({ ...containerOptions, cwd: root })
|
|
131
81
|
|
|
@@ -152,133 +102,3 @@ export async function createAlmostNodeWorkspace(
|
|
|
152
102
|
}
|
|
153
103
|
}
|
|
154
104
|
|
|
155
|
-
export interface ReactDemoFilesOptions {
|
|
156
|
-
entry?: string
|
|
157
|
-
/** Extra npm dependencies for the generated package.json (ignored when the files bring one). */
|
|
158
|
-
dependencies?: Record<string, string>
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Add the tiny Vite/React shell needed by a file map that only contains source
|
|
163
|
-
* files. Existing package.json and index.html files are always respected.
|
|
164
|
-
*/
|
|
165
|
-
export function createReactDemoFiles(
|
|
166
|
-
files: AlmostNodeFiles,
|
|
167
|
-
options: ReactDemoFilesOptions = {},
|
|
168
|
-
): Record<string, AlmostNodeFileContent> {
|
|
169
|
-
const map = toAlmostNodeFileMap(files)
|
|
170
|
-
const entry = normalizeAlmostNodePath(options.entry ?? "/src/main.jsx")
|
|
171
|
-
const has = (candidate: string) => Object.keys(map).some((path) => normalizeAlmostNodePath(path) === candidate)
|
|
172
|
-
|
|
173
|
-
if (!has("/package.json")) {
|
|
174
|
-
map["/package.json"] = JSON.stringify(
|
|
175
|
-
{
|
|
176
|
-
name: "docstream-react-demo",
|
|
177
|
-
private: true,
|
|
178
|
-
type: "module",
|
|
179
|
-
dependencies: { react: "^19.0.0", "react-dom": "^19.0.0", ...options.dependencies },
|
|
180
|
-
},
|
|
181
|
-
null,
|
|
182
|
-
2,
|
|
183
|
-
)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (!has("/index.html")) {
|
|
187
|
-
const entryUrl = `.${entry}`
|
|
188
|
-
map["/index.html"] = `<!doctype html>
|
|
189
|
-
<html lang="en">
|
|
190
|
-
<head>
|
|
191
|
-
<meta charset="UTF-8" />
|
|
192
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
193
|
-
<title>Docstream React demo</title>
|
|
194
|
-
</head>
|
|
195
|
-
<body>
|
|
196
|
-
<div id="root"></div>
|
|
197
|
-
<script type="module" src="${entryUrl}"></script>
|
|
198
|
-
</body>
|
|
199
|
-
</html>
|
|
200
|
-
`
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
return map
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
export interface InlineDemoProjectOptions {
|
|
207
|
-
/** Entry file among `files` (relative path). Defaults to the first file. */
|
|
208
|
-
entry?: string
|
|
209
|
-
/** Extra npm dependencies (merged over a `package.json` among the files, if any). */
|
|
210
|
-
dependencies?: Record<string, string>
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
export interface InlineDemoProject {
|
|
214
|
-
/** Complete almost-node project: the demo under `/src/demo/`, a wrapper entry, package.json, index.html. */
|
|
215
|
-
files: Record<string, AlmostNodeFileContent>
|
|
216
|
-
/** The Vite entry to hand to `createReactDemoFiles` / `ReactDemo`. */
|
|
217
|
-
entry: string
|
|
218
|
-
/** Whether the entry is wrapped (so `?variant=` reaches the component as its `variant` prop). */
|
|
219
|
-
wrapped: boolean
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** Folder the inline demo's files are written to inside the almost-node project. */
|
|
223
|
-
export const INLINE_DEMO_DIR = "/src/demo"
|
|
224
|
-
const WRAPPER_ENTRY = "/src/__docstream_demo_entry.js"
|
|
225
|
-
|
|
226
|
-
function selfMounting(source: string): boolean {
|
|
227
|
-
return /\bcreateRoot\s*\(|\bReactDOM\.render\s*\(|\bhydrateRoot\s*\(/.test(source)
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Turn a `{% demo %}` block's inline files into a runnable almost-node project.
|
|
232
|
-
*
|
|
233
|
-
* The files go under `/src/demo/` (relative imports between them keep working). A tiny
|
|
234
|
-
* wrapper entry imports the entry file, takes its default export (or the first exported
|
|
235
|
-
* function), and renders it with `variant` read from the page's `?variant=` query — so
|
|
236
|
-
* switching variants just reloads the iframe, no new Vite server. An entry that mounts
|
|
237
|
-
* itself (`createRoot(…)`) is used directly instead, and receives no `variant`.
|
|
238
|
-
*
|
|
239
|
-
* A `package.json` among the files contributes its `dependencies`; `options.dependencies`
|
|
240
|
-
* are merged over them. Bare imports resolve through almost-node (installed packages, else
|
|
241
|
-
* esm.sh at the major version declared here), with `react`/`react-dom` provided.
|
|
242
|
-
*/
|
|
243
|
-
export function createInlineDemoProject(
|
|
244
|
-
files: readonly { path: string; content: string }[],
|
|
245
|
-
options: InlineDemoProjectOptions = {},
|
|
246
|
-
): InlineDemoProject {
|
|
247
|
-
const map: Record<string, AlmostNodeFileContent> = {}
|
|
248
|
-
let packageDeps: Record<string, string> = {}
|
|
249
|
-
for (const file of files) {
|
|
250
|
-
const relative = file.path.replace(/^\.?\/+/, "")
|
|
251
|
-
if (relative === "package.json") {
|
|
252
|
-
try {
|
|
253
|
-
const pkg = JSON.parse(file.content) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
|
|
254
|
-
packageDeps = { ...pkg.devDependencies, ...pkg.dependencies }
|
|
255
|
-
} catch {
|
|
256
|
-
// Not JSON: keep it as a plain file.
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
map[normalizeAlmostNodePath(relative, INLINE_DEMO_DIR)] = file.content
|
|
260
|
-
}
|
|
261
|
-
const entryFile = files.find((f) => f.path === options.entry) ?? files[0]
|
|
262
|
-
if (!entryFile) throw new Error("An inline demo needs at least one file")
|
|
263
|
-
const entryPath = normalizeAlmostNodePath(entryFile.path.replace(/^\.?\/+/, ""), INLINE_DEMO_DIR)
|
|
264
|
-
const dependencies = { ...packageDeps, ...options.dependencies }
|
|
265
|
-
const wrapped = !selfMounting(entryFile.content)
|
|
266
|
-
if (wrapped) {
|
|
267
|
-
const specifier = `.${entryPath.slice("/src".length)}`
|
|
268
|
-
map[WRAPPER_ENTRY] = `import { createElement } from "react"
|
|
269
|
-
import { createRoot } from "react-dom/client"
|
|
270
|
-
import * as demo from ${JSON.stringify(specifier)}
|
|
271
|
-
|
|
272
|
-
const Component = demo.default ?? Object.values(demo).find((value) => typeof value === "function")
|
|
273
|
-
const variant = new URLSearchParams(location.search).get("variant")
|
|
274
|
-
const props = variant === null ? {} : { variant }
|
|
275
|
-
createRoot(document.getElementById("root")).render(
|
|
276
|
-
Component
|
|
277
|
-
? createElement(Component, props)
|
|
278
|
-
: createElement("pre", null, ${JSON.stringify(`${entryFile.path} has no default export to render.`)}),
|
|
279
|
-
)
|
|
280
|
-
`
|
|
281
|
-
}
|
|
282
|
-
const entry = wrapped ? WRAPPER_ENTRY : entryPath
|
|
283
|
-
return { files: createReactDemoFiles(map, { entry, dependencies }), entry, wrapped }
|
|
284
|
-
}
|
package/src/playground/index.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/* `@brett_lamy/docstream/playground` — the only entry that touches almost-node
|
|
2
|
+
(`@agent-wasm/core`, an optional peer dependency). Install it to use anything here. */
|
|
3
|
+
export { PlaygroundStreamdown, PlaygroundStreamdown as GitbookStreamdown, almostNodeLiveRenderer } from "./PlaygroundStreamdown"
|
|
4
|
+
export type { PlaygroundStreamdownProps, PlaygroundStreamdownProps as GitbookStreamdownProps } from "./PlaygroundStreamdown"
|
|
1
5
|
export { ReactCodePreview, ReactDemo, useAlmostNodeServer } from "./ReactCodePreview"
|
|
2
6
|
export type { AlmostNodeServerOptions, AlmostNodeServerState, PreviewState, ReactCodePreviewProps, ReactDemoProps } from "./ReactCodePreview"
|
|
3
7
|
export { InlineDemoPreview, almostNodeDemoRuntime, createAlmostNodeDemoRuntime } from "./InlineDemoPreview"
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/* Pure, runtime-free helpers for describing an almost-node project as a file map.
|
|
2
|
+
Nothing here imports `@agent-wasm/core`, so these are safe to use from the package
|
|
3
|
+
root; the runtime itself lives in ./filesystem (the `/playground` entry). */
|
|
4
|
+
|
|
5
|
+
export type AlmostNodeFileContent = string | Uint8Array
|
|
6
|
+
|
|
7
|
+
export interface AlmostNodeFile {
|
|
8
|
+
path: string
|
|
9
|
+
content: AlmostNodeFileContent
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type AlmostNodeFiles =
|
|
13
|
+
| Readonly<Record<string, AlmostNodeFileContent>>
|
|
14
|
+
| readonly AlmostNodeFile[]
|
|
15
|
+
|
|
16
|
+
export interface AlmostNodeFilesystemOptions {
|
|
17
|
+
/** Prefix for relative file paths. Defaults to the virtual root. */
|
|
18
|
+
root?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function normalizeAlmostNodePath(path: string, root = "/"): string {
|
|
22
|
+
const normalizedRoot = normalizeRoot(root)
|
|
23
|
+
const source = path.replaceAll("\\", "/")
|
|
24
|
+
const absolute = source.startsWith("/") ? source : `${normalizedRoot}/${source}`
|
|
25
|
+
const result = normalizeAbsolutePath(absolute)
|
|
26
|
+
|
|
27
|
+
if (normalizedRoot !== "/" && result !== normalizedRoot && !result.startsWith(`${normalizedRoot}/`)) {
|
|
28
|
+
throw new Error(`File path escapes the almost-node root: ${path}`)
|
|
29
|
+
}
|
|
30
|
+
return result
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeAbsolutePath(path: string): string {
|
|
34
|
+
const parts: string[] = []
|
|
35
|
+
|
|
36
|
+
for (const part of path.split("/")) {
|
|
37
|
+
if (!part || part === ".") continue
|
|
38
|
+
if (part === "..") {
|
|
39
|
+
if (parts.length > 0) parts.pop()
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (part.includes("\0")) throw new Error("almost-node file paths cannot contain null bytes")
|
|
43
|
+
parts.push(part)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const result = `/${parts.join("/")}`
|
|
47
|
+
return result === "" ? "/" : result
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function normalizeAlmostNodeFiles(files: AlmostNodeFiles): AlmostNodeFile[] {
|
|
51
|
+
if (Array.isArray(files)) return files.map((file) => ({ ...file }))
|
|
52
|
+
return Object.entries(files).map(([path, content]) => ({ path, content }))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function toAlmostNodeFileMap(files: AlmostNodeFiles): Record<string, AlmostNodeFileContent> {
|
|
56
|
+
return Object.fromEntries(normalizeAlmostNodeFiles(files).map(({ path, content }) => [path, content]))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeRoot(root: string): string {
|
|
60
|
+
const normalized = normalizeAbsolutePath(root.replaceAll("\\", "/"))
|
|
61
|
+
return normalized === "/" ? normalized : normalized.replace(/\/$/, "")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
export interface ReactDemoFilesOptions {
|
|
66
|
+
entry?: string
|
|
67
|
+
/** Extra npm dependencies for the generated package.json (ignored when the files bring one). */
|
|
68
|
+
dependencies?: Record<string, string>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Add the tiny Vite/React shell needed by a file map that only contains source
|
|
73
|
+
* files. Existing package.json and index.html files are always respected.
|
|
74
|
+
*/
|
|
75
|
+
export function createReactDemoFiles(
|
|
76
|
+
files: AlmostNodeFiles,
|
|
77
|
+
options: ReactDemoFilesOptions = {},
|
|
78
|
+
): Record<string, AlmostNodeFileContent> {
|
|
79
|
+
const map = toAlmostNodeFileMap(files)
|
|
80
|
+
const entry = normalizeAlmostNodePath(options.entry ?? "/src/main.jsx")
|
|
81
|
+
const has = (candidate: string) => Object.keys(map).some((path) => normalizeAlmostNodePath(path) === candidate)
|
|
82
|
+
|
|
83
|
+
if (!has("/package.json")) {
|
|
84
|
+
map["/package.json"] = JSON.stringify(
|
|
85
|
+
{
|
|
86
|
+
name: "docstream-react-demo",
|
|
87
|
+
private: true,
|
|
88
|
+
type: "module",
|
|
89
|
+
dependencies: { react: "^19.0.0", "react-dom": "^19.0.0", ...options.dependencies },
|
|
90
|
+
},
|
|
91
|
+
null,
|
|
92
|
+
2,
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!has("/index.html")) {
|
|
97
|
+
const entryUrl = `.${entry}`
|
|
98
|
+
map["/index.html"] = `<!doctype html>
|
|
99
|
+
<html lang="en">
|
|
100
|
+
<head>
|
|
101
|
+
<meta charset="UTF-8" />
|
|
102
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
103
|
+
<title>Docstream React demo</title>
|
|
104
|
+
</head>
|
|
105
|
+
<body>
|
|
106
|
+
<div id="root"></div>
|
|
107
|
+
<script type="module" src="${entryUrl}"></script>
|
|
108
|
+
</body>
|
|
109
|
+
</html>
|
|
110
|
+
`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return map
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface InlineDemoProjectOptions {
|
|
117
|
+
/** Entry file among `files` (relative path). Defaults to the first file. */
|
|
118
|
+
entry?: string
|
|
119
|
+
/** Extra npm dependencies (merged over a `package.json` among the files, if any). */
|
|
120
|
+
dependencies?: Record<string, string>
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface InlineDemoProject {
|
|
124
|
+
/** Complete almost-node project: the demo under `/src/demo/`, a wrapper entry, package.json, index.html. */
|
|
125
|
+
files: Record<string, AlmostNodeFileContent>
|
|
126
|
+
/** The Vite entry to hand to `createReactDemoFiles` / `ReactDemo`. */
|
|
127
|
+
entry: string
|
|
128
|
+
/** Whether the entry is wrapped (so `?variant=` reaches the component as its `variant` prop). */
|
|
129
|
+
wrapped: boolean
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Folder the inline demo's files are written to inside the almost-node project. */
|
|
133
|
+
export const INLINE_DEMO_DIR = "/src/demo"
|
|
134
|
+
const WRAPPER_ENTRY = "/src/__docstream_demo_entry.js"
|
|
135
|
+
|
|
136
|
+
function selfMounting(source: string): boolean {
|
|
137
|
+
return /\bcreateRoot\s*\(|\bReactDOM\.render\s*\(|\bhydrateRoot\s*\(/.test(source)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Turn a `{% demo %}` block's inline files into a runnable almost-node project.
|
|
142
|
+
*
|
|
143
|
+
* The files go under `/src/demo/` (relative imports between them keep working). A tiny
|
|
144
|
+
* wrapper entry imports the entry file, takes its default export (or the first exported
|
|
145
|
+
* function), and renders it with `variant` read from the page's `?variant=` query — so
|
|
146
|
+
* switching variants just reloads the iframe, no new Vite server. An entry that mounts
|
|
147
|
+
* itself (`createRoot(…)`) is used directly instead, and receives no `variant`.
|
|
148
|
+
*
|
|
149
|
+
* A `package.json` among the files contributes its `dependencies`; `options.dependencies`
|
|
150
|
+
* are merged over them. Bare imports resolve through almost-node (installed packages, else
|
|
151
|
+
* esm.sh at the major version declared here), with `react`/`react-dom` provided.
|
|
152
|
+
*/
|
|
153
|
+
export function createInlineDemoProject(
|
|
154
|
+
files: readonly { path: string; content: string }[],
|
|
155
|
+
options: InlineDemoProjectOptions = {},
|
|
156
|
+
): InlineDemoProject {
|
|
157
|
+
const map: Record<string, AlmostNodeFileContent> = {}
|
|
158
|
+
let packageDeps: Record<string, string> = {}
|
|
159
|
+
for (const file of files) {
|
|
160
|
+
const relative = file.path.replace(/^\.?\/+/, "")
|
|
161
|
+
if (relative === "package.json") {
|
|
162
|
+
try {
|
|
163
|
+
const pkg = JSON.parse(file.content) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
|
|
164
|
+
packageDeps = { ...pkg.devDependencies, ...pkg.dependencies }
|
|
165
|
+
} catch {
|
|
166
|
+
// Not JSON: keep it as a plain file.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
map[normalizeAlmostNodePath(relative, INLINE_DEMO_DIR)] = file.content
|
|
170
|
+
}
|
|
171
|
+
const entryFile = files.find((f) => f.path === options.entry) ?? files[0]
|
|
172
|
+
if (!entryFile) throw new Error("An inline demo needs at least one file")
|
|
173
|
+
const entryPath = normalizeAlmostNodePath(entryFile.path.replace(/^\.?\/+/, ""), INLINE_DEMO_DIR)
|
|
174
|
+
const dependencies = { ...packageDeps, ...options.dependencies }
|
|
175
|
+
const wrapped = !selfMounting(entryFile.content)
|
|
176
|
+
if (wrapped) {
|
|
177
|
+
const specifier = `.${entryPath.slice("/src".length)}`
|
|
178
|
+
map[WRAPPER_ENTRY] = `import { createElement } from "react"
|
|
179
|
+
import { createRoot } from "react-dom/client"
|
|
180
|
+
import * as demo from ${JSON.stringify(specifier)}
|
|
181
|
+
|
|
182
|
+
const Component = demo.default ?? Object.values(demo).find((value) => typeof value === "function")
|
|
183
|
+
const variant = new URLSearchParams(location.search).get("variant")
|
|
184
|
+
const props = variant === null ? {} : { variant }
|
|
185
|
+
createRoot(document.getElementById("root")).render(
|
|
186
|
+
Component
|
|
187
|
+
? createElement(Component, props)
|
|
188
|
+
: createElement("pre", null, ${JSON.stringify(`${entryFile.path} has no default export to render.`)}),
|
|
189
|
+
)
|
|
190
|
+
`
|
|
191
|
+
}
|
|
192
|
+
const entry = wrapped ? WRAPPER_ENTRY : entryPath
|
|
193
|
+
return { files: createReactDemoFiles(map, { entry, dependencies }), entry, wrapped }
|
|
194
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/* The rrweb player. Loaded on demand by ReplayPreview, so pages that only embed Loop QA
|
|
2
|
+
iframes (the markdown `{% embed %}` case) never download rrweb or its stylesheet. */
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { EventType, Replayer } from 'rrweb';
|
|
5
|
+
import type { eventWithTime } from 'rrweb';
|
|
6
|
+
import { sanitizeReplayEvent } from './sanitize';
|
|
7
|
+
import 'rrweb/dist/style.css';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_SPEEDS = [0.5, 1, 2, 4, 8];
|
|
10
|
+
const SKIP_IDLE_THRESHOLD_MS = 5_000;
|
|
11
|
+
|
|
12
|
+
export default function NativeReplay({
|
|
13
|
+
events,
|
|
14
|
+
autoplay = false,
|
|
15
|
+
loop = false,
|
|
16
|
+
speeds = DEFAULT_SPEEDS,
|
|
17
|
+
}: {
|
|
18
|
+
events: eventWithTime[];
|
|
19
|
+
autoplay?: boolean;
|
|
20
|
+
loop?: boolean;
|
|
21
|
+
speeds?: number[];
|
|
22
|
+
}) {
|
|
23
|
+
const stageRef = useRef<HTMLDivElement>(null);
|
|
24
|
+
const playerRef = useRef<HTMLDivElement>(null);
|
|
25
|
+
const replayerRef = useRef<Replayer | null>(null);
|
|
26
|
+
const fedCountRef = useRef(0);
|
|
27
|
+
const [playing, setPlaying] = useState(false);
|
|
28
|
+
const [speed, setSpeed] = useState(1);
|
|
29
|
+
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
|
|
30
|
+
const [scale, setScale] = useState(1);
|
|
31
|
+
const [currentTime, setCurrentTime] = useState(0);
|
|
32
|
+
const [totalTime, setTotalTime] = useState(0);
|
|
33
|
+
const [scrubbing, setScrubbing] = useState<number | null>(null);
|
|
34
|
+
const speedRef = useRef(speed);
|
|
35
|
+
const loopRef = useRef(loop);
|
|
36
|
+
const autoplayRef = useRef(autoplay);
|
|
37
|
+
|
|
38
|
+
const prepared = useMemo(
|
|
39
|
+
() => events.map((event) => sanitizeReplayEvent(event)).sort((a, b) => a.timestamp - b.timestamp),
|
|
40
|
+
[events],
|
|
41
|
+
);
|
|
42
|
+
const hasSnapshot = prepared.some((event) => event.type === EventType.FullSnapshot);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (replayerRef.current && prepared.length < fedCountRef.current) {
|
|
46
|
+
try {
|
|
47
|
+
replayerRef.current.destroy();
|
|
48
|
+
} catch {
|
|
49
|
+
// Ignore stale player teardown.
|
|
50
|
+
}
|
|
51
|
+
replayerRef.current = null;
|
|
52
|
+
fedCountRef.current = 0;
|
|
53
|
+
}
|
|
54
|
+
if (!hasSnapshot || !playerRef.current) return;
|
|
55
|
+
|
|
56
|
+
if (!replayerRef.current) {
|
|
57
|
+
const replayer = new Replayer(prepared, {
|
|
58
|
+
root: playerRef.current,
|
|
59
|
+
liveMode: false,
|
|
60
|
+
speed: speedRef.current,
|
|
61
|
+
skipInactive: true,
|
|
62
|
+
inactivePeriodThreshold: SKIP_IDLE_THRESHOLD_MS,
|
|
63
|
+
mouseTail: false,
|
|
64
|
+
showWarning: false,
|
|
65
|
+
});
|
|
66
|
+
replayer.on('resize', (payload) => {
|
|
67
|
+
const size = payload as { width?: number; height?: number };
|
|
68
|
+
if (size.width && size.height) setDims({ w: size.width, h: size.height });
|
|
69
|
+
});
|
|
70
|
+
replayer.on('finish', () => {
|
|
71
|
+
if (loopRef.current) {
|
|
72
|
+
replayer.play(0);
|
|
73
|
+
setPlaying(true);
|
|
74
|
+
} else {
|
|
75
|
+
setPlaying(false);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
replayer.pause(0);
|
|
79
|
+
replayerRef.current = replayer;
|
|
80
|
+
fedCountRef.current = prepared.length;
|
|
81
|
+
if (autoplayRef.current) {
|
|
82
|
+
replayer.play(0);
|
|
83
|
+
setPlaying(true);
|
|
84
|
+
}
|
|
85
|
+
} else if (prepared.length > fedCountRef.current) {
|
|
86
|
+
for (let i = fedCountRef.current; i < prepared.length; i++) {
|
|
87
|
+
replayerRef.current.addEvent(prepared[i]!);
|
|
88
|
+
}
|
|
89
|
+
fedCountRef.current = prepared.length;
|
|
90
|
+
}
|
|
91
|
+
}, [hasSnapshot, prepared]);
|
|
92
|
+
|
|
93
|
+
useEffect(
|
|
94
|
+
() => () => {
|
|
95
|
+
try {
|
|
96
|
+
replayerRef.current?.destroy();
|
|
97
|
+
} catch {
|
|
98
|
+
// Ignore unmount teardown errors from rrweb.
|
|
99
|
+
}
|
|
100
|
+
replayerRef.current = null;
|
|
101
|
+
fedCountRef.current = 0;
|
|
102
|
+
},
|
|
103
|
+
[],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
speedRef.current = speed;
|
|
108
|
+
replayerRef.current?.setConfig({ speed });
|
|
109
|
+
}, [speed]);
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
loopRef.current = loop;
|
|
113
|
+
}, [loop]);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
autoplayRef.current = autoplay;
|
|
117
|
+
}, [autoplay]);
|
|
118
|
+
|
|
119
|
+
const recalcScale = useCallback(() => {
|
|
120
|
+
if (!dims || !stageRef.current || dims.w <= 0) return;
|
|
121
|
+
setScale(Math.min(1, stageRef.current.clientWidth / dims.w));
|
|
122
|
+
}, [dims]);
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
recalcScale();
|
|
126
|
+
window.addEventListener('resize', recalcScale);
|
|
127
|
+
return () => window.removeEventListener('resize', recalcScale);
|
|
128
|
+
}, [recalcScale]);
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (!hasSnapshot) return;
|
|
132
|
+
const interval = window.setInterval(() => {
|
|
133
|
+
const replayer = replayerRef.current;
|
|
134
|
+
if (!replayer) return;
|
|
135
|
+
try {
|
|
136
|
+
const total = replayer.getMetaData().totalTime;
|
|
137
|
+
const current = Math.max(0, Math.min(replayer.getCurrentTime(), total));
|
|
138
|
+
setTotalTime(total);
|
|
139
|
+
if (scrubbing === null) setCurrentTime(current);
|
|
140
|
+
} catch {
|
|
141
|
+
// rrweb can briefly be unready while a full snapshot is applied.
|
|
142
|
+
}
|
|
143
|
+
}, 200);
|
|
144
|
+
return () => window.clearInterval(interval);
|
|
145
|
+
}, [hasSnapshot, scrubbing]);
|
|
146
|
+
|
|
147
|
+
const seek = useCallback(
|
|
148
|
+
(value: number) => {
|
|
149
|
+
const replayer = replayerRef.current;
|
|
150
|
+
if (!replayer) return;
|
|
151
|
+
const next = Math.max(0, Math.min(value, totalTime));
|
|
152
|
+
if (playing) replayer.play(next);
|
|
153
|
+
else replayer.pause(next);
|
|
154
|
+
setCurrentTime(next);
|
|
155
|
+
},
|
|
156
|
+
[playing, totalTime],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const toggle = () => {
|
|
160
|
+
const replayer = replayerRef.current;
|
|
161
|
+
if (!replayer) return;
|
|
162
|
+
if (playing) {
|
|
163
|
+
replayer.pause();
|
|
164
|
+
setPlaying(false);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
let offset = currentTime;
|
|
168
|
+
try {
|
|
169
|
+
if (offset >= replayer.getMetaData().totalTime - 1) offset = 0;
|
|
170
|
+
} catch {
|
|
171
|
+
offset = 0;
|
|
172
|
+
}
|
|
173
|
+
replayer.play(offset);
|
|
174
|
+
setPlaying(true);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const restart = () => {
|
|
178
|
+
replayerRef.current?.play(0);
|
|
179
|
+
setCurrentTime(0);
|
|
180
|
+
setPlaying(true);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
if (!hasSnapshot) {
|
|
184
|
+
return (
|
|
185
|
+
<div className="replay-preview__empty">
|
|
186
|
+
{events.length ? `Captured ${events.length} events, but no full DOM snapshot is available.` : 'Replay data is not available yet.'}
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<div className="replay-preview__native">
|
|
193
|
+
<div
|
|
194
|
+
ref={stageRef}
|
|
195
|
+
className="replay-preview__stage"
|
|
196
|
+
style={dims ? { height: Math.max(1, Math.round(dims.h * scale)) } : undefined}
|
|
197
|
+
>
|
|
198
|
+
<div
|
|
199
|
+
className="replay-preview__scaler"
|
|
200
|
+
style={{ transform: `scale(${scale})`, transformOrigin: 'top left' }}
|
|
201
|
+
>
|
|
202
|
+
<div ref={playerRef} className="replay-preview__player" aria-hidden="true" />
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
<div className="replay-preview__controls">
|
|
206
|
+
<button type="button" onClick={toggle} aria-label={playing ? 'Pause replay' : 'Play replay'}>
|
|
207
|
+
{playing ? 'Pause' : 'Play'}
|
|
208
|
+
</button>
|
|
209
|
+
<button type="button" onClick={restart} aria-label="Restart replay">
|
|
210
|
+
Restart
|
|
211
|
+
</button>
|
|
212
|
+
<input
|
|
213
|
+
type="range"
|
|
214
|
+
min={0}
|
|
215
|
+
max={Math.max(totalTime, 1)}
|
|
216
|
+
step={1}
|
|
217
|
+
value={scrubbing ?? currentTime}
|
|
218
|
+
onChange={(event) => setScrubbing(Number(event.target.value))}
|
|
219
|
+
onPointerUp={(event) => {
|
|
220
|
+
seek(Number((event.currentTarget as HTMLInputElement).value));
|
|
221
|
+
setScrubbing(null);
|
|
222
|
+
}}
|
|
223
|
+
aria-label="Replay position"
|
|
224
|
+
/>
|
|
225
|
+
<select value={speed} onChange={(event) => setSpeed(Number(event.target.value))} aria-label="Replay speed">
|
|
226
|
+
{speeds.map((option) => (
|
|
227
|
+
<option key={option} value={option}>
|
|
228
|
+
{option}×
|
|
229
|
+
</option>
|
|
230
|
+
))}
|
|
231
|
+
</select>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { EventType, Replayer } from 'rrweb';
|
|
1
|
+
import { Suspense, lazy, useEffect, useState } from 'react';
|
|
3
2
|
import type { eventWithTime } from 'rrweb';
|
|
4
3
|
import { normalizeReplayEmbedUrl } from './url';
|
|
5
|
-
import { sanitizeReplayEvent } from './sanitize';
|
|
6
|
-
import 'rrweb/dist/style.css';
|
|
7
4
|
import './styles.css';
|
|
8
5
|
|
|
9
6
|
export interface ReplayPageSource {
|
|
@@ -45,7 +42,6 @@ export interface ReplayPreviewProps {
|
|
|
45
42
|
}
|
|
46
43
|
|
|
47
44
|
const DEFAULT_SPEEDS = [0.5, 1, 2, 4, 8];
|
|
48
|
-
const SKIP_IDLE_THRESHOLD_MS = 5_000;
|
|
49
45
|
|
|
50
46
|
function sourcePage(source: ReplaySource): ReplayPageSource | null {
|
|
51
47
|
if (typeof source === 'string') return { kind: 'url', url: source };
|
|
@@ -86,228 +82,14 @@ function ReplayFrame({ source, title }: { source: ReplayPageSource; title?: stri
|
|
|
86
82
|
);
|
|
87
83
|
}
|
|
88
84
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
autoplay = false,
|
|
92
|
-
loop = false,
|
|
93
|
-
speeds = DEFAULT_SPEEDS,
|
|
94
|
-
}: {
|
|
95
|
-
events: eventWithTime[];
|
|
96
|
-
autoplay?: boolean;
|
|
97
|
-
loop?: boolean;
|
|
98
|
-
speeds?: number[];
|
|
99
|
-
}) {
|
|
100
|
-
const stageRef = useRef<HTMLDivElement>(null);
|
|
101
|
-
const playerRef = useRef<HTMLDivElement>(null);
|
|
102
|
-
const replayerRef = useRef<Replayer | null>(null);
|
|
103
|
-
const fedCountRef = useRef(0);
|
|
104
|
-
const [playing, setPlaying] = useState(false);
|
|
105
|
-
const [speed, setSpeed] = useState(1);
|
|
106
|
-
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
|
|
107
|
-
const [scale, setScale] = useState(1);
|
|
108
|
-
const [currentTime, setCurrentTime] = useState(0);
|
|
109
|
-
const [totalTime, setTotalTime] = useState(0);
|
|
110
|
-
const [scrubbing, setScrubbing] = useState<number | null>(null);
|
|
111
|
-
const speedRef = useRef(speed);
|
|
112
|
-
const loopRef = useRef(loop);
|
|
113
|
-
const autoplayRef = useRef(autoplay);
|
|
114
|
-
|
|
115
|
-
const prepared = useMemo(
|
|
116
|
-
() => events.map((event) => sanitizeReplayEvent(event)).sort((a, b) => a.timestamp - b.timestamp),
|
|
117
|
-
[events],
|
|
118
|
-
);
|
|
119
|
-
const hasSnapshot = prepared.some((event) => event.type === EventType.FullSnapshot);
|
|
120
|
-
|
|
121
|
-
useEffect(() => {
|
|
122
|
-
if (replayerRef.current && prepared.length < fedCountRef.current) {
|
|
123
|
-
try {
|
|
124
|
-
replayerRef.current.destroy();
|
|
125
|
-
} catch {
|
|
126
|
-
// Ignore stale player teardown.
|
|
127
|
-
}
|
|
128
|
-
replayerRef.current = null;
|
|
129
|
-
fedCountRef.current = 0;
|
|
130
|
-
}
|
|
131
|
-
if (!hasSnapshot || !playerRef.current) return;
|
|
132
|
-
|
|
133
|
-
if (!replayerRef.current) {
|
|
134
|
-
const replayer = new Replayer(prepared, {
|
|
135
|
-
root: playerRef.current,
|
|
136
|
-
liveMode: false,
|
|
137
|
-
speed: speedRef.current,
|
|
138
|
-
skipInactive: true,
|
|
139
|
-
inactivePeriodThreshold: SKIP_IDLE_THRESHOLD_MS,
|
|
140
|
-
mouseTail: false,
|
|
141
|
-
showWarning: false,
|
|
142
|
-
});
|
|
143
|
-
replayer.on('resize', (payload) => {
|
|
144
|
-
const size = payload as { width?: number; height?: number };
|
|
145
|
-
if (size.width && size.height) setDims({ w: size.width, h: size.height });
|
|
146
|
-
});
|
|
147
|
-
replayer.on('finish', () => {
|
|
148
|
-
if (loopRef.current) {
|
|
149
|
-
replayer.play(0);
|
|
150
|
-
setPlaying(true);
|
|
151
|
-
} else {
|
|
152
|
-
setPlaying(false);
|
|
153
|
-
}
|
|
154
|
-
});
|
|
155
|
-
replayer.pause(0);
|
|
156
|
-
replayerRef.current = replayer;
|
|
157
|
-
fedCountRef.current = prepared.length;
|
|
158
|
-
if (autoplayRef.current) {
|
|
159
|
-
replayer.play(0);
|
|
160
|
-
setPlaying(true);
|
|
161
|
-
}
|
|
162
|
-
} else if (prepared.length > fedCountRef.current) {
|
|
163
|
-
for (let i = fedCountRef.current; i < prepared.length; i++) {
|
|
164
|
-
replayerRef.current.addEvent(prepared[i]!);
|
|
165
|
-
}
|
|
166
|
-
fedCountRef.current = prepared.length;
|
|
167
|
-
}
|
|
168
|
-
}, [hasSnapshot, prepared]);
|
|
169
|
-
|
|
170
|
-
useEffect(
|
|
171
|
-
() => () => {
|
|
172
|
-
try {
|
|
173
|
-
replayerRef.current?.destroy();
|
|
174
|
-
} catch {
|
|
175
|
-
// Ignore unmount teardown errors from rrweb.
|
|
176
|
-
}
|
|
177
|
-
replayerRef.current = null;
|
|
178
|
-
fedCountRef.current = 0;
|
|
179
|
-
},
|
|
180
|
-
[],
|
|
181
|
-
);
|
|
182
|
-
|
|
183
|
-
useEffect(() => {
|
|
184
|
-
speedRef.current = speed;
|
|
185
|
-
replayerRef.current?.setConfig({ speed });
|
|
186
|
-
}, [speed]);
|
|
187
|
-
|
|
188
|
-
useEffect(() => {
|
|
189
|
-
loopRef.current = loop;
|
|
190
|
-
}, [loop]);
|
|
191
|
-
|
|
192
|
-
useEffect(() => {
|
|
193
|
-
autoplayRef.current = autoplay;
|
|
194
|
-
}, [autoplay]);
|
|
195
|
-
|
|
196
|
-
const recalcScale = useCallback(() => {
|
|
197
|
-
if (!dims || !stageRef.current || dims.w <= 0) return;
|
|
198
|
-
setScale(Math.min(1, stageRef.current.clientWidth / dims.w));
|
|
199
|
-
}, [dims]);
|
|
200
|
-
|
|
201
|
-
useEffect(() => {
|
|
202
|
-
recalcScale();
|
|
203
|
-
window.addEventListener('resize', recalcScale);
|
|
204
|
-
return () => window.removeEventListener('resize', recalcScale);
|
|
205
|
-
}, [recalcScale]);
|
|
206
|
-
|
|
207
|
-
useEffect(() => {
|
|
208
|
-
if (!hasSnapshot) return;
|
|
209
|
-
const interval = window.setInterval(() => {
|
|
210
|
-
const replayer = replayerRef.current;
|
|
211
|
-
if (!replayer) return;
|
|
212
|
-
try {
|
|
213
|
-
const total = replayer.getMetaData().totalTime;
|
|
214
|
-
const current = Math.max(0, Math.min(replayer.getCurrentTime(), total));
|
|
215
|
-
setTotalTime(total);
|
|
216
|
-
if (scrubbing === null) setCurrentTime(current);
|
|
217
|
-
} catch {
|
|
218
|
-
// rrweb can briefly be unready while a full snapshot is applied.
|
|
219
|
-
}
|
|
220
|
-
}, 200);
|
|
221
|
-
return () => window.clearInterval(interval);
|
|
222
|
-
}, [hasSnapshot, scrubbing]);
|
|
223
|
-
|
|
224
|
-
const seek = useCallback(
|
|
225
|
-
(value: number) => {
|
|
226
|
-
const replayer = replayerRef.current;
|
|
227
|
-
if (!replayer) return;
|
|
228
|
-
const next = Math.max(0, Math.min(value, totalTime));
|
|
229
|
-
if (playing) replayer.play(next);
|
|
230
|
-
else replayer.pause(next);
|
|
231
|
-
setCurrentTime(next);
|
|
232
|
-
},
|
|
233
|
-
[playing, totalTime],
|
|
234
|
-
);
|
|
235
|
-
|
|
236
|
-
const toggle = () => {
|
|
237
|
-
const replayer = replayerRef.current;
|
|
238
|
-
if (!replayer) return;
|
|
239
|
-
if (playing) {
|
|
240
|
-
replayer.pause();
|
|
241
|
-
setPlaying(false);
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
let offset = currentTime;
|
|
245
|
-
try {
|
|
246
|
-
if (offset >= replayer.getMetaData().totalTime - 1) offset = 0;
|
|
247
|
-
} catch {
|
|
248
|
-
offset = 0;
|
|
249
|
-
}
|
|
250
|
-
replayer.play(offset);
|
|
251
|
-
setPlaying(true);
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
const restart = () => {
|
|
255
|
-
replayerRef.current?.play(0);
|
|
256
|
-
setCurrentTime(0);
|
|
257
|
-
setPlaying(true);
|
|
258
|
-
};
|
|
259
|
-
|
|
260
|
-
if (!hasSnapshot) {
|
|
261
|
-
return (
|
|
262
|
-
<div className="replay-preview__empty">
|
|
263
|
-
{events.length ? `Captured ${events.length} events, but no full DOM snapshot is available.` : 'Replay data is not available yet.'}
|
|
264
|
-
</div>
|
|
265
|
-
);
|
|
266
|
-
}
|
|
85
|
+
// rrweb (and its stylesheet) load on first use of an event-based source.
|
|
86
|
+
const LazyNativeReplay = lazy(() => import('./NativeReplay'));
|
|
267
87
|
|
|
88
|
+
function NativeReplay(props: { events: eventWithTime[]; autoplay?: boolean; loop?: boolean; speeds?: number[] }) {
|
|
268
89
|
return (
|
|
269
|
-
<div className="replay-
|
|
270
|
-
<
|
|
271
|
-
|
|
272
|
-
className="replay-preview__stage"
|
|
273
|
-
style={dims ? { height: Math.max(1, Math.round(dims.h * scale)) } : undefined}
|
|
274
|
-
>
|
|
275
|
-
<div
|
|
276
|
-
className="replay-preview__scaler"
|
|
277
|
-
style={{ transform: `scale(${scale})`, transformOrigin: 'top left' }}
|
|
278
|
-
>
|
|
279
|
-
<div ref={playerRef} className="replay-preview__player" aria-hidden="true" />
|
|
280
|
-
</div>
|
|
281
|
-
</div>
|
|
282
|
-
<div className="replay-preview__controls">
|
|
283
|
-
<button type="button" onClick={toggle} aria-label={playing ? 'Pause replay' : 'Play replay'}>
|
|
284
|
-
{playing ? 'Pause' : 'Play'}
|
|
285
|
-
</button>
|
|
286
|
-
<button type="button" onClick={restart} aria-label="Restart replay">
|
|
287
|
-
Restart
|
|
288
|
-
</button>
|
|
289
|
-
<input
|
|
290
|
-
type="range"
|
|
291
|
-
min={0}
|
|
292
|
-
max={Math.max(totalTime, 1)}
|
|
293
|
-
step={1}
|
|
294
|
-
value={scrubbing ?? currentTime}
|
|
295
|
-
onChange={(event) => setScrubbing(Number(event.target.value))}
|
|
296
|
-
onPointerUp={(event) => {
|
|
297
|
-
seek(Number((event.currentTarget as HTMLInputElement).value));
|
|
298
|
-
setScrubbing(null);
|
|
299
|
-
}}
|
|
300
|
-
aria-label="Replay position"
|
|
301
|
-
/>
|
|
302
|
-
<select value={speed} onChange={(event) => setSpeed(Number(event.target.value))} aria-label="Replay speed">
|
|
303
|
-
{speeds.map((option) => (
|
|
304
|
-
<option key={option} value={option}>
|
|
305
|
-
{option}×
|
|
306
|
-
</option>
|
|
307
|
-
))}
|
|
308
|
-
</select>
|
|
309
|
-
</div>
|
|
310
|
-
</div>
|
|
90
|
+
<Suspense fallback={<div className="replay-preview__empty">Loading replay…</div>}>
|
|
91
|
+
<LazyNativeReplay {...props} />
|
|
92
|
+
</Suspense>
|
|
311
93
|
);
|
|
312
94
|
}
|
|
313
95
|
|
package/src/styles.css
CHANGED