feat(docs): add MDX sandbox previews

This commit is contained in:
Maofeng
2026-09-21 17:09:44 +08:00
parent b26c8886cf
commit a9d6856ab8
8 changed files with 532 additions and 0 deletions
+1
View File
@@ -12,6 +12,7 @@ dist
dist-ssr
.ssr
.output
docs/icons
*.local
# env files (can opt-in for committing if needed)
+3
View File
@@ -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">) {
+13
View File
@@ -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"
+24
View File
@@ -0,0 +1,24 @@
import type { SandboxDefinition } from "./types"
type SandboxManifestModule = {
default: SandboxDefinition
}
const manifestModules = import.meta.glob<SandboxManifestModule>(
"../../sandboxes/*/*/sandbox.ts",
{ eager: true }
)
const sandboxDefinitions = new Map<string, SandboxDefinition>()
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)
}
@@ -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
}
}
@@ -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<HTMLIFrameElement>(null)
const [mountNode, setMountNode] = React.useState<HTMLElement | null>(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(
'<!doctype html><html><head></head><body><div id="sandbox-root"></div></body></html>'
)
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 (
<>
<iframe
ref={frameRef}
className="block w-full border-0 bg-white dark:bg-slate-950"
style={{ height }}
sandbox="allow-same-origin"
title={title}
/>
{mountNode ? createPortal(children, mountNode) : null}
</>
)
}
+333
View File
@@ -0,0 +1,333 @@
import React from "react"
import { Icon } from "@icones/react"
import { useTranslate } from "../../lib/locale"
import { getSandboxDefinition } from "./registry"
import { SandboxErrorBoundary } from "./sandbox-error-boundary"
import { SandboxFrame } from "./sandbox-frame"
import type {
SandboxDefinition,
SandboxPreviewModule,
SandboxSource,
} from "./types"
type SandboxView = "code" | "preview"
export type SandboxProps = {
className?: string
example: string
title?: string
}
export function Sandbox({ className, example, title }: SandboxProps) {
const definition = getSandboxDefinition(example)
const t = useTranslate()
if (!definition) {
return (
<SandboxMessage className={className} tone="error">
{t({
zh: `找不到 Sandbox 示例:${example}`,
us: `Sandbox example not found: ${example}`,
})}
</SandboxMessage>
)
}
return (
<SandboxViewer
className={className}
definition={definition}
title={title}
/>
)
}
export type SingleFileSandboxProps = {
className?: string
component: React.ComponentType
filename?: string
source: string
title?: string
}
export function SingleFileSandbox({
className,
component,
filename = "App.tsx",
source,
title,
}: SingleFileSandboxProps) {
const definition = React.useMemo<SandboxDefinition>(
() => ({
defaultFile: filename,
entry: async () => ({ default: component }),
files: { [filename]: source },
id: `single-file:${filename}`,
title,
}),
[component, filename, source, title]
)
return <SandboxViewer className={className} definition={definition} />
}
function SandboxViewer({
className,
definition,
title,
}: {
className?: string
definition: SandboxDefinition
title?: string
}) {
const t = useTranslate()
const filenames = React.useMemo(
() => Object.keys(definition.files),
[definition.files]
)
const [view, setView] = React.useState<SandboxView>("preview")
const [selectedFile, setSelectedFile] = React.useState(
definition.defaultFile ?? filenames[0] ?? ""
)
const [previewModule, setPreviewModule] =
React.useState<SandboxPreviewModule | null>(null)
const [previewError, setPreviewError] = React.useState<Error | null>(null)
const [source, setSource] = React.useState<string | null>(null)
const [sourceError, setSourceError] = React.useState<Error | null>(null)
const [reloadKey, setReloadKey] = React.useState(0)
const [copied, setCopied] = React.useState(false)
const resolvedTitle = title ?? definition.title ?? definition.id
React.useEffect(() => {
let active = true
setPreviewModule(null)
setPreviewError(null)
definition.entry().then(
(module) => {
if (active) setPreviewModule(module)
},
(error: unknown) => {
if (active) setPreviewError(toError(error))
}
)
return () => {
active = false
}
}, [definition])
React.useEffect(() => {
if (view !== "code") return
const file = definition.files[selectedFile]
if (!file) {
setSource(null)
setSourceError(new Error(`Source file not found: ${selectedFile}`))
return
}
let active = true
setSource(null)
setSourceError(null)
resolveSource(file).then(
(value) => {
if (active) setSource(value)
},
(error: unknown) => {
if (active) setSourceError(toError(error))
}
)
return () => {
active = false
}
}, [definition.files, selectedFile, view])
React.useEffect(() => {
setSelectedFile(definition.defaultFile ?? filenames[0] ?? "")
}, [definition.defaultFile, filenames])
const copySource = async () => {
if (!source) return
await navigator.clipboard.writeText(source)
setCopied(true)
window.setTimeout(() => setCopied(false), 1500)
}
return (
<section
className={`not-prose my-6 overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950 ${className ?? ""}`}
aria-label={resolvedTitle}
>
<div className="flex min-h-11 flex-wrap items-center gap-2 border-b border-slate-200 bg-slate-50 px-2 py-1.5 dark:border-slate-800 dark:bg-slate-900/70">
<div className="inline-flex rounded-lg bg-slate-200/70 p-0.5 dark:bg-slate-800">
<SandboxTab
active={view === "preview"}
onClick={() => setView("preview")}
>
<Icon name="lucide:play" className="size-3.5" />
{t({ zh: "预览", us: "Preview" })}
</SandboxTab>
<SandboxTab active={view === "code"} onClick={() => setView("code")}>
<Icon name="lucide:code-2" className="size-3.5" />
{t({ zh: "代码", us: "Code" })}
</SandboxTab>
</div>
<span className="min-w-0 truncate px-1 text-xs text-slate-500 dark:text-slate-400">
{resolvedTitle}
</span>
<div className="ml-auto flex items-center gap-1">
{view === "code" ? (
<SandboxAction
disabled={!source}
label={t({ zh: "复制源码", us: "Copy source" })}
onClick={() => void copySource()}
>
<Icon
name={copied ? "lucide:check" : "lucide:copy"}
className="size-4"
/>
</SandboxAction>
) : (
<SandboxAction
label={t({ zh: "重置预览", us: "Reset preview" })}
onClick={() => setReloadKey((value) => value + 1)}
>
<Icon name="lucide:rotate-ccw" className="size-4" />
</SandboxAction>
)}
</div>
</div>
{view === "preview" ? (
<SandboxFrame reloadKey={reloadKey} title={resolvedTitle}>
<SandboxErrorBoundary key={reloadKey} fallback={renderSandboxError}>
{previewError ? (
<SandboxMessage tone="error">
{previewError.message}
</SandboxMessage>
) : previewModule ? (
React.createElement(previewModule.default, { key: reloadKey })
) : (
<SandboxMessage>
{t({ zh: "正在加载预览…", us: "Loading preview…" })}
</SandboxMessage>
)}
</SandboxErrorBoundary>
</SandboxFrame>
) : (
<div
className={`grid min-h-72 ${filenames.length > 1 ? "md:grid-cols-[12rem_minmax(0,1fr)]" : ""}`}
>
{filenames.length > 1 ? (
<nav
className="flex overflow-x-auto border-b border-slate-200 bg-slate-50 p-2 md:flex-col md:border-r md:border-b-0 dark:border-slate-800 dark:bg-slate-900/40"
aria-label={t({ zh: "示例文件", us: "Example files" })}
>
{filenames.map((filename) => (
<button
key={filename}
type="button"
className="rounded-md px-2 py-1.5 text-left font-mono text-xs whitespace-nowrap text-slate-500 hover:bg-slate-200/70 hover:text-slate-950 aria-pressed:bg-indigo-100 aria-pressed:text-indigo-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:aria-pressed:bg-indigo-950 dark:aria-pressed:text-indigo-300"
aria-pressed={selectedFile === filename}
onClick={() => setSelectedFile(filename)}
>
{filename}
</button>
))}
</nav>
) : null}
<pre className="m-0 min-w-0 overflow-auto bg-slate-950 p-4 text-sm leading-6 text-slate-100">
<code>
{sourceError
? sourceError.message
: (source ?? t({ zh: "正在加载源码…", us: "Loading source…" }))}
</code>
</pre>
</div>
)}
</section>
)
}
function SandboxTab({
active,
children,
onClick,
}: {
active: boolean
children: React.ReactNode
onClick: VoidFunction
}) {
return (
<button
type="button"
className="inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-slate-500 transition-colors hover:text-slate-950 aria-pressed:bg-white aria-pressed:text-slate-950 aria-pressed:shadow-sm dark:text-slate-400 dark:hover:text-slate-100 dark:aria-pressed:bg-slate-700 dark:aria-pressed:text-slate-100"
aria-pressed={active}
onClick={onClick}
>
{children}
</button>
)
}
function SandboxAction({
children,
disabled,
label,
onClick,
}: {
children: React.ReactNode
disabled?: boolean
label: string
onClick: VoidFunction
}) {
return (
<button
type="button"
className="inline-flex size-8 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-slate-200 hover:text-slate-950 disabled:opacity-40 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
aria-label={label}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
)
}
function SandboxMessage({
children,
className,
tone = "neutral",
}: {
children: React.ReactNode
className?: string
tone?: "error" | "neutral"
}) {
return (
<div
className={`flex min-h-72 items-center justify-center p-6 text-sm ${tone === "error" ? "text-red-600 dark:text-red-400" : "text-slate-500 dark:text-slate-400"} ${className ?? ""}`}
role={tone === "error" ? "alert" : undefined}
>
{children}
</div>
)
}
async function resolveSource(source: SandboxSource) {
if (typeof source === "string") return source
const loaded = await source()
return typeof loaded === "string" ? loaded : loaded.default
}
function toError(value: unknown) {
return value instanceof Error ? value : new Error(String(value))
}
function renderSandboxError(error: Error) {
return <SandboxMessage tone="error">{error.message}</SandboxMessage>
}
+27
View File
@@ -0,0 +1,27 @@
import type { ComponentType } from "react"
export type SandboxPreviewModule = {
default: ComponentType
}
export type SandboxSourceModule = {
default: string
}
export type SandboxSource =
| string
| (() => Promise<string | SandboxSourceModule>)
export type SandboxDefinition = {
defaultFile?: string
entry: () => Promise<SandboxPreviewModule>
files: Readonly<Record<string, SandboxSource>>
id: string
title?: string
}
export function defineSandbox<const Definition extends SandboxDefinition>(
definition: Definition
) {
return definition
}