@brett_lamy/docstream 0.1.0 → 0.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 +295 -0
- package/package.json +35 -3
- package/src/docs/DocsRenderer.tsx +30 -2
- package/src/gitbook/ast.ts +4 -0
- package/src/gitbook/index.ts +6 -0
- package/src/gitbook/parse.ts +33 -5
- package/src/gitbook/serialize.ts +6 -2
- package/src/index.ts +28 -0
- package/src/playground/ReactCodePreview.tsx +161 -0
- package/src/playground/filesystem.ts +202 -0
- package/src/playground/index.ts +19 -0
- package/src/replay/ReplayPreview.tsx +398 -0
- package/src/replay/index.ts +10 -0
- package/src/replay/sanitize.ts +83 -0
- package/src/replay/styles.css +87 -0
- package/src/replay/url.ts +41 -0
- package/src/styles.css +108 -21
- package/src/video/VideoEmbed.tsx +62 -0
- package/src/video/index.ts +2 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { useEffect, useState, type CSSProperties } from "react"
|
|
2
|
+
import {
|
|
3
|
+
createAlmostNodeWorkspace,
|
|
4
|
+
createReactDemoFiles,
|
|
5
|
+
type AlmostNodeFiles,
|
|
6
|
+
type AlmostNodeWorkspace,
|
|
7
|
+
} from "./filesystem"
|
|
8
|
+
|
|
9
|
+
export interface ReactDemoProps {
|
|
10
|
+
/** Complete project file map. Relative paths are rooted at `/`. */
|
|
11
|
+
files: AlmostNodeFiles
|
|
12
|
+
/** Vite entry file. Defaults to `/src/main.jsx`. */
|
|
13
|
+
entry?: string
|
|
14
|
+
/** Preferred virtual port. almost-node picks the next free port if needed. */
|
|
15
|
+
port?: number
|
|
16
|
+
/** Height of the embedded preview. */
|
|
17
|
+
height?: number | string
|
|
18
|
+
/** Label shown above the preview. */
|
|
19
|
+
title?: string
|
|
20
|
+
/** Start the runtime on mount (default true). */
|
|
21
|
+
autoStart?: boolean
|
|
22
|
+
/** iframe sandbox value. The same-origin permission lets the almost-node service worker route the preview. */
|
|
23
|
+
sandbox?: string
|
|
24
|
+
className?: string
|
|
25
|
+
onReady?: (url: string) => void
|
|
26
|
+
onError?: (error: Error) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ReactCodePreviewProps = ReactDemoProps
|
|
30
|
+
|
|
31
|
+
type PreviewState = "idle" | "starting" | "ready" | "error"
|
|
32
|
+
|
|
33
|
+
const DETACHED_SERVER_ENV = { ALMOSTNODE_DETACH_DEV_SERVERS: "1" }
|
|
34
|
+
|
|
35
|
+
function errorFromUnknown(error: unknown): Error {
|
|
36
|
+
return error instanceof Error ? error : new Error(String(error))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function frameHeight(height: number | string): CSSProperties {
|
|
40
|
+
return { height: typeof height === "number" ? `${height}px` : height }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Render a multi-file React/JSX/TSX project through almost-node's Vite server. */
|
|
44
|
+
export function ReactDemo({
|
|
45
|
+
files,
|
|
46
|
+
entry = "/src/main.jsx",
|
|
47
|
+
port = 4173,
|
|
48
|
+
height = 360,
|
|
49
|
+
title = "Live React preview",
|
|
50
|
+
autoStart = true,
|
|
51
|
+
sandbox = "allow-scripts allow-same-origin allow-forms allow-modals",
|
|
52
|
+
className,
|
|
53
|
+
onReady,
|
|
54
|
+
onError,
|
|
55
|
+
}: ReactDemoProps) {
|
|
56
|
+
const [run, setRun] = useState(autoStart ? 1 : 0)
|
|
57
|
+
const [state, setState] = useState<PreviewState>(autoStart ? "starting" : "idle")
|
|
58
|
+
const [url, setUrl] = useState<string | null>(null)
|
|
59
|
+
const [error, setError] = useState<string | null>(null)
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!run) {
|
|
63
|
+
setState("idle")
|
|
64
|
+
setUrl(null)
|
|
65
|
+
setError(null)
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let cancelled = false
|
|
70
|
+
let workspace: AlmostNodeWorkspace | null = null
|
|
71
|
+
let readyUrl: string | null = null
|
|
72
|
+
setState("starting")
|
|
73
|
+
setUrl(null)
|
|
74
|
+
setError(null)
|
|
75
|
+
|
|
76
|
+
const start = async () => {
|
|
77
|
+
try {
|
|
78
|
+
const projectFiles = createReactDemoFiles(files, { entry })
|
|
79
|
+
workspace = await createAlmostNodeWorkspace(projectFiles, {
|
|
80
|
+
env: DETACHED_SERVER_ENV,
|
|
81
|
+
onServerReady: (_port, serverUrl) => {
|
|
82
|
+
readyUrl = serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const result = await workspace.container.run(`vite --port ${port}`, {
|
|
87
|
+
env: DETACHED_SERVER_ENV,
|
|
88
|
+
})
|
|
89
|
+
if (result.exitCode !== 0) {
|
|
90
|
+
throw new Error(result.stderr || result.stdout || "almost-node could not start Vite")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const serverUrl = readyUrl ?? `${workspace.container.serverBridge.getServerUrl(port)}/`
|
|
94
|
+
if (cancelled) {
|
|
95
|
+
workspace.dispose()
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
setUrl(serverUrl)
|
|
99
|
+
setState("ready")
|
|
100
|
+
onReady?.(serverUrl)
|
|
101
|
+
} catch (cause) {
|
|
102
|
+
const nextError = errorFromUnknown(cause)
|
|
103
|
+
if (!cancelled) {
|
|
104
|
+
setState("error")
|
|
105
|
+
setError(nextError.message)
|
|
106
|
+
onError?.(nextError)
|
|
107
|
+
}
|
|
108
|
+
workspace?.dispose()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
void start()
|
|
113
|
+
return () => {
|
|
114
|
+
cancelled = true
|
|
115
|
+
workspace?.dispose()
|
|
116
|
+
}
|
|
117
|
+
}, [entry, files, onError, onReady, port, run])
|
|
118
|
+
|
|
119
|
+
const wrapperClass = className ? `docs-react-demo ${className}` : "docs-react-demo"
|
|
120
|
+
const running = state === "starting"
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<section className={wrapperClass} data-docstream-react-demo="">
|
|
124
|
+
<header className="docs-react-demo-header">
|
|
125
|
+
<span>{title}</span>
|
|
126
|
+
<span className={`docs-react-demo-status docs-react-demo-status-${state}`}>
|
|
127
|
+
{state === "ready" ? "ready" : state === "starting" ? "starting" : state}
|
|
128
|
+
</span>
|
|
129
|
+
{!autoStart || state === "error" ? (
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
className="docs-react-demo-run"
|
|
133
|
+
disabled={running}
|
|
134
|
+
onClick={() => setRun((value) => value + 1)}
|
|
135
|
+
>
|
|
136
|
+
{state === "error" ? "Retry" : "Run demo"}
|
|
137
|
+
</button>
|
|
138
|
+
) : null}
|
|
139
|
+
</header>
|
|
140
|
+
{error ? (
|
|
141
|
+
<pre className="docs-react-demo-error">{error}</pre>
|
|
142
|
+
) : url ? (
|
|
143
|
+
<iframe
|
|
144
|
+
title={title}
|
|
145
|
+
className="docs-react-demo-frame"
|
|
146
|
+
src={url}
|
|
147
|
+
sandbox={sandbox}
|
|
148
|
+
style={frameHeight(height)}
|
|
149
|
+
/>
|
|
150
|
+
) : (
|
|
151
|
+
<div className="docs-react-demo-placeholder" style={frameHeight(height)}>
|
|
152
|
+
{running ? "Starting almost-node…" : "Run the demo to open its virtual filesystem."}
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
</section>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function ReactCodePreview(props: ReactCodePreviewProps) {
|
|
160
|
+
return <ReactDemo {...props} />
|
|
161
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { ContainerInstance, ContainerOptions, VirtualFS } from "@agent-wasm/core"
|
|
2
|
+
|
|
3
|
+
export type AlmostNodeFileContent = string | Uint8Array
|
|
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
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AlmostNodeWorkspaceOptions extends Omit<ContainerOptions, "cwd"> {
|
|
20
|
+
/** Project root used as the container cwd and Vite root. */
|
|
21
|
+
root?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AlmostNodeWorkspace {
|
|
25
|
+
container: ContainerInstance
|
|
26
|
+
root: string
|
|
27
|
+
writeFile(path: string, content: AlmostNodeFileContent): void
|
|
28
|
+
writeFiles(files: AlmostNodeFiles): void
|
|
29
|
+
listFiles(): string[]
|
|
30
|
+
dispose(): void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type AlmostNodeModule = typeof import("@agent-wasm/core")
|
|
34
|
+
|
|
35
|
+
async function loadAlmostNode(): Promise<AlmostNodeModule> {
|
|
36
|
+
try {
|
|
37
|
+
return await import("@agent-wasm/core")
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Docstream live previews require @agent-wasm/core (almost-node). Install it in the host app. ${message}`,
|
|
42
|
+
{ cause: error },
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
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
|
+
function parentDirectory(path: string): string {
|
|
91
|
+
const index = path.lastIndexOf("/")
|
|
92
|
+
return index <= 0 ? "/" : path.slice(0, index)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function writeFilesToVfs(vfs: VirtualFS, files: AlmostNodeFiles, root: string): void {
|
|
96
|
+
for (const { path, content } of normalizeAlmostNodeFiles(files)) {
|
|
97
|
+
const absolutePath = normalizeAlmostNodePath(path, root)
|
|
98
|
+
vfs.mkdirSync(parentDirectory(absolutePath), { recursive: true })
|
|
99
|
+
vfs.writeFileSync(absolutePath, content)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Create a complete almost-node filesystem from a map or list of files.
|
|
105
|
+
* The runtime is loaded lazily so ordinary markdown rendering does not pull
|
|
106
|
+
* the WASM/browser runtime into the bundle until a live demo is used.
|
|
107
|
+
*/
|
|
108
|
+
export async function createAlmostNodeFilesystem(
|
|
109
|
+
files: AlmostNodeFiles,
|
|
110
|
+
options: AlmostNodeFilesystemOptions = {},
|
|
111
|
+
): Promise<VirtualFS> {
|
|
112
|
+
const { VirtualFS } = await loadAlmostNode()
|
|
113
|
+
const root = normalizeRoot(options.root ?? "/")
|
|
114
|
+
const vfs = new VirtualFS()
|
|
115
|
+
writeFilesToVfs(vfs, files, root)
|
|
116
|
+
return vfs
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create a container and populate its entire virtual filesystem in one call.
|
|
121
|
+
* Use the returned container for `run`, `runFile`, npm installs, or Vite.
|
|
122
|
+
*/
|
|
123
|
+
export async function createAlmostNodeWorkspace(
|
|
124
|
+
files: AlmostNodeFiles,
|
|
125
|
+
options: AlmostNodeWorkspaceOptions = {},
|
|
126
|
+
): Promise<AlmostNodeWorkspace> {
|
|
127
|
+
const { root: requestedRoot, ...containerOptions } = options
|
|
128
|
+
const root = normalizeRoot(requestedRoot ?? "/")
|
|
129
|
+
const { createContainer } = await loadAlmostNode()
|
|
130
|
+
const container = createContainer({ ...containerOptions, cwd: root })
|
|
131
|
+
|
|
132
|
+
const writeFiles = (nextFiles: AlmostNodeFiles) => writeFilesToVfs(container.vfs, nextFiles, root)
|
|
133
|
+
writeFiles(files)
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
container,
|
|
137
|
+
root,
|
|
138
|
+
writeFile(path, content) {
|
|
139
|
+
writeFiles([{ path, content }])
|
|
140
|
+
},
|
|
141
|
+
writeFiles,
|
|
142
|
+
listFiles() {
|
|
143
|
+
return container.vfs
|
|
144
|
+
.toSnapshot()
|
|
145
|
+
.files.filter((entry) => entry.type === "file")
|
|
146
|
+
.map((entry) => entry.path)
|
|
147
|
+
.sort()
|
|
148
|
+
},
|
|
149
|
+
dispose() {
|
|
150
|
+
container.dispose()
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface ReactDemoFilesOptions {
|
|
156
|
+
entry?: string
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Add the tiny Vite/React shell needed by a file map that only contains source
|
|
161
|
+
* files. Existing package.json and index.html files are always respected.
|
|
162
|
+
*/
|
|
163
|
+
export function createReactDemoFiles(
|
|
164
|
+
files: AlmostNodeFiles,
|
|
165
|
+
options: ReactDemoFilesOptions = {},
|
|
166
|
+
): Record<string, AlmostNodeFileContent> {
|
|
167
|
+
const map = toAlmostNodeFileMap(files)
|
|
168
|
+
const entry = normalizeAlmostNodePath(options.entry ?? "/src/main.jsx")
|
|
169
|
+
const has = (candidate: string) => Object.keys(map).some((path) => normalizeAlmostNodePath(path) === candidate)
|
|
170
|
+
|
|
171
|
+
if (!has("/package.json")) {
|
|
172
|
+
map["/package.json"] = JSON.stringify(
|
|
173
|
+
{
|
|
174
|
+
name: "docstream-react-demo",
|
|
175
|
+
private: true,
|
|
176
|
+
type: "module",
|
|
177
|
+
dependencies: { react: "^19.0.0", "react-dom": "^19.0.0" },
|
|
178
|
+
},
|
|
179
|
+
null,
|
|
180
|
+
2,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!has("/index.html")) {
|
|
185
|
+
const entryUrl = `.${entry}`
|
|
186
|
+
map["/index.html"] = `<!doctype html>
|
|
187
|
+
<html lang="en">
|
|
188
|
+
<head>
|
|
189
|
+
<meta charset="UTF-8" />
|
|
190
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
191
|
+
<title>Docstream React demo</title>
|
|
192
|
+
</head>
|
|
193
|
+
<body>
|
|
194
|
+
<div id="root"></div>
|
|
195
|
+
<script type="module" src="${entryUrl}"></script>
|
|
196
|
+
</body>
|
|
197
|
+
</html>
|
|
198
|
+
`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return map
|
|
202
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { ReactCodePreview, ReactDemo } from "./ReactCodePreview"
|
|
2
|
+
export type { ReactCodePreviewProps, ReactDemoProps } from "./ReactCodePreview"
|
|
3
|
+
export {
|
|
4
|
+
createAlmostNodeFilesystem,
|
|
5
|
+
createAlmostNodeWorkspace,
|
|
6
|
+
createReactDemoFiles,
|
|
7
|
+
normalizeAlmostNodeFiles,
|
|
8
|
+
normalizeAlmostNodePath,
|
|
9
|
+
toAlmostNodeFileMap,
|
|
10
|
+
} from "./filesystem"
|
|
11
|
+
export type {
|
|
12
|
+
AlmostNodeFile,
|
|
13
|
+
AlmostNodeFileContent,
|
|
14
|
+
AlmostNodeFiles,
|
|
15
|
+
AlmostNodeFilesystemOptions,
|
|
16
|
+
AlmostNodeWorkspace,
|
|
17
|
+
AlmostNodeWorkspaceOptions,
|
|
18
|
+
ReactDemoFilesOptions,
|
|
19
|
+
} from "./filesystem"
|