diff --git a/.gitignore b/.gitignore index 6064a5e..4ce785a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ dist dist-ssr .ssr .output +docs/icons *.local # env files (can opt-in for committing if needed) diff --git a/docs/src/components/mdx.tsx b/docs/src/components/mdx.tsx index e744406..30bb3e2 100644 --- a/docs/src/components/mdx.tsx +++ b/docs/src/components/mdx.tsx @@ -1,4 +1,5 @@ import type { MdxComponents } from "../content/types" +import { Sandbox, SingleFileSandbox } from "./sandbox" export const mdxComponents: MdxComponents = { a: MdxAnchor, @@ -7,6 +8,8 @@ export const mdxComponents: MdxComponents = { h2: MdxHeading2, h3: MdxHeading3, pre: MdxPre, + Sandbox, + SingleFileSandbox, } function MdxHeading2({ children, id, ...props }: React.ComponentProps<"h2">) { diff --git a/docs/src/components/sandbox/index.ts b/docs/src/components/sandbox/index.ts new file mode 100644 index 0000000..7eb76a3 --- /dev/null +++ b/docs/src/components/sandbox/index.ts @@ -0,0 +1,13 @@ +export { + Sandbox, + SingleFileSandbox, + type SandboxProps, + type SingleFileSandboxProps, +} from "./sandbox" +export { + defineSandbox, + type SandboxDefinition, + type SandboxPreviewModule, + type SandboxSource, + type SandboxSourceModule, +} from "./types" diff --git a/docs/src/components/sandbox/registry.ts b/docs/src/components/sandbox/registry.ts new file mode 100644 index 0000000..a6c5f83 --- /dev/null +++ b/docs/src/components/sandbox/registry.ts @@ -0,0 +1,24 @@ +import type { SandboxDefinition } from "./types" + +type SandboxManifestModule = { + default: SandboxDefinition +} + +const manifestModules = import.meta.glob( + "../../sandboxes/*/*/sandbox.ts", + { eager: true } +) + +const sandboxDefinitions = new Map() + +for (const module of Object.values(manifestModules)) { + const definition = module.default + if (sandboxDefinitions.has(definition.id)) { + throw new Error(`Duplicate sandbox id: ${definition.id}`) + } + sandboxDefinitions.set(definition.id, definition) +} + +export function getSandboxDefinition(id: string) { + return sandboxDefinitions.get(id) +} diff --git a/docs/src/components/sandbox/sandbox-error-boundary.tsx b/docs/src/components/sandbox/sandbox-error-boundary.tsx new file mode 100644 index 0000000..2a6493b --- /dev/null +++ b/docs/src/components/sandbox/sandbox-error-boundary.tsx @@ -0,0 +1,27 @@ +import React from "react" + +type SandboxErrorBoundaryProps = { + children: React.ReactNode + fallback: (error: Error) => React.ReactNode +} + +type SandboxErrorBoundaryState = { + error: Error | null +} + +export class SandboxErrorBoundary extends React.Component< + SandboxErrorBoundaryProps, + SandboxErrorBoundaryState +> { + state: SandboxErrorBoundaryState = { error: null } + + static getDerivedStateFromError(error: Error) { + return { error } + } + + render() { + return this.state.error + ? this.props.fallback(this.state.error) + : this.props.children + } +} diff --git a/docs/src/components/sandbox/sandbox-frame.tsx b/docs/src/components/sandbox/sandbox-frame.tsx new file mode 100644 index 0000000..9c1428b --- /dev/null +++ b/docs/src/components/sandbox/sandbox-frame.tsx @@ -0,0 +1,104 @@ +import React from "react" +import { createPortal } from "react-dom" + +const minimumFrameHeight = 288 + +export function SandboxFrame({ + children, + reloadKey, + title, +}: { + children: React.ReactNode + reloadKey: number + title: string +}) { + const frameRef = React.useRef(null) + const [mountNode, setMountNode] = React.useState(null) + const [height, setHeight] = React.useState(minimumFrameHeight) + + React.useLayoutEffect(() => { + const frame = frameRef.current + const frameDocument = frame?.contentDocument + if (!frame || !frameDocument) return + + frameDocument.open() + frameDocument.write( + '
' + ) + frameDocument.close() + + for (const node of document.head.querySelectorAll( + 'link[rel="stylesheet"], style' + )) { + frameDocument.head.append(node.cloneNode(true)) + } + + const frameOverrides = frameDocument.createElement("style") + frameOverrides.textContent = ` + html, body, #sandbox-root { + min-height: 100%; + min-width: 0 !important; + } + body { + box-sizing: border-box; + margin: 0; + padding: 1.5rem; + } + ` + frameDocument.head.append(frameOverrides) + + const syncDocumentTheme = () => { + frameDocument.documentElement.className = + document.documentElement.className + frameDocument.documentElement.style.colorScheme = + document.documentElement.style.colorScheme + } + syncDocumentTheme() + + const themeObserver = new MutationObserver(syncDocumentTheme) + themeObserver.observe(document.documentElement, { + attributeFilter: ["class", "style"], + attributes: true, + }) + + const nextMountNode = frameDocument.getElementById("sandbox-root") + setMountNode(nextMountNode) + + return () => { + themeObserver.disconnect() + setMountNode(null) + } + }, [reloadKey]) + + React.useEffect(() => { + if (!mountNode) return + + const frameDocument = mountNode.ownerDocument + const updateHeight = () => { + setHeight( + Math.max( + minimumFrameHeight, + Math.ceil(frameDocument.documentElement.scrollHeight) + ) + ) + } + const resizeObserver = new ResizeObserver(updateHeight) + resizeObserver.observe(frameDocument.body) + updateHeight() + + return () => resizeObserver.disconnect() + }, [mountNode]) + + return ( + <> +