feat(docs): add MDX sandbox previews
This commit is contained in:
@@ -12,6 +12,7 @@ dist
|
||||
dist-ssr
|
||||
.ssr
|
||||
.output
|
||||
docs/icons
|
||||
*.local
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
|
||||
@@ -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">) {
|
||||
|
||||
@@ -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"
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user