diff --git a/dev/package.json b/dev/package.json index 0d3979f..babf379 100644 --- a/dev/package.json +++ b/dev/package.json @@ -21,6 +21,8 @@ "devDependencies": { "@iconify-json/ri": "^1.2.5", "cross-env": "^7.0.3", + "csstype": "^3.2.3", + "typescript": "^5.9.3", "vite": "^8.0.0" } } diff --git a/dev/src/routes/repl.mdx b/dev/src/routes/repl.mdx new file mode 100644 index 0000000..408fb7f --- /dev/null +++ b/dev/src/routes/repl.mdx @@ -0,0 +1,70 @@ +--- +title: REPL +--- + +# REPL + +Route for reviewing `:::repl` blocks in the dev app. + +## Single File + +:::repl +```tsx +import { render } from "solid-js/web"; +import { createSignal } from "solid-js"; + +function Counter() { + const [count, setCount] = createSignal(1); + const increment = () => setCount((count) => count + 1); + + return ( + + ); +} + +render(() => , document.getElementById("app")!); +``` +::: + +## Multiple Files + +Blocks after the first need a `title`, which becomes the file name. + +:::repl +```tsx +import { render } from "solid-js/web"; +import { Greeting } from "./Greeting"; + +render(() => , document.getElementById("app")!); +``` + +```tsx title="Greeting.tsx" +export function Greeting(props: { name: string }) { + return

Hello {props.name}!

; +} +``` +::: + +## Options + +`:::repl{layout=horizontal height="20rem" devtools}` places the editor next to the preview, sets the height and shows the devtools tab. + +:::repl{layout=horizontal height="20rem" devtools} +```tsx +import { render } from "solid-js/web"; + +render(() =>

Horizontal layout

, document.getElementById("app")!); +``` +::: + +## Code Block Flag + +Add `repl` to a code block's meta to get a single-file playground without a directive. + +```tsx repl +import { render } from "solid-js/web"; + +render(() =>

Rendered from a flagged code block.

, document.getElementById("app")!); +``` diff --git a/package.json b/package.json index 4669791..f14eba2 100644 --- a/package.json +++ b/package.json @@ -148,6 +148,11 @@ "solid-js": "^1.9.1", "vite": "^8.0.0" }, + "peerDependenciesMeta": { + "solid-repl": { + "optional": true + } + }, "packageManager": "pnpm@11.20.0", "dependencies": { "@alloc/quick-lru": "^5.2.0", diff --git a/src/config/document-markdown.ts b/src/config/document-markdown.ts index 25fd7b4..de4a06c 100644 --- a/src/config/document-markdown.ts +++ b/src/config/document-markdown.ts @@ -15,6 +15,7 @@ import { } from "./remark-plugins/inline-frontmatter.js"; import { remarkAddClass } from "./remark-plugins/kbd.js"; import { remarkPreview } from "./remark-plugins/preview.js"; +import { remarkRepl } from "./remark-plugins/repl.js"; type DocumentMarkdownOptions = { config?: RemarkPipelineConfig; @@ -34,6 +35,7 @@ type MdxNode = { const DOCUMENT_ONLY_SKIPPED_PLUGINS = new Set([ remarkCodeTabs, remarkPreview, + remarkRepl, remarkAddClass, ]); diff --git a/src/config/mdx.ts b/src/config/mdx.ts index 911c0a4..9d27f7b 100644 --- a/src/config/mdx.ts +++ b/src/config/mdx.ts @@ -47,6 +47,7 @@ import type { PackageManagerConfig } from "./remark-plugins/package-manager-tabs import { remarkPackageManagerTabs } from "./remark-plugins/package-manager-tabs.js"; import { remarkPreview } from "./remark-plugins/preview.js"; import { remarkRelativeImports } from "./remark-plugins/relative-imports.js"; +import { remarkRepl } from "./remark-plugins/repl.js"; import { remarkSteps } from "./remark-plugins/steps.js"; import { remarkTabGroup } from "./remark-plugins/tab-group.js"; import type { TOCOptions } from "./remark-plugins/toc.js"; @@ -211,7 +212,12 @@ export function getRemarkPlugins(sbConfig: RemarkPipelineConfig) { if (sbConfig.markdown?.toc !== false) remarkPlugins.push([remarkTOC, sbConfig.markdown?.toc]); - remarkPlugins.push(remarkPreview, remarkDirectiveContainers, remarkAddClass); + remarkPlugins.push( + remarkRepl, + remarkPreview, + remarkDirectiveContainers, + remarkAddClass, + ); if (sbConfig.issueAutolink !== false) remarkPlugins.push([remarkIssueAutolink, sbConfig.issueAutolink]); diff --git a/src/config/remark-plugins/repl.ts b/src/config/remark-plugins/repl.ts new file mode 100644 index 0000000..f7cabd6 --- /dev/null +++ b/src/config/remark-plugins/repl.ts @@ -0,0 +1,137 @@ +import { MetaOptions } from "@expressive-code/core"; +import { valueToEstree } from "estree-util-value-to-estree"; +import type { Code, Root } from "mdast"; +import type { Transformer } from "unified"; +import { SKIP, visit } from "unist-util-visit"; +import type { VFile } from "vfile"; + +export interface ReplTab { + name: string; + source: string; +} + +export const REPL_ENTRY_FILE = "main.tsx"; + +function fail(file: VFile | undefined, node: unknown, reason: string): never { + if (file) { + file.fail(reason, node as any); + } + + throw new Error(reason); +} + +function getTabName(code: Code, index: number, file?: VFile) { + const title = new MetaOptions(code.meta ?? "").getString("title"); + if (title) return title; + if (index === 0) return REPL_ENTRY_FILE; + + fail( + file, + code, + "code blocks after the first in a repl directive must have a title", + ); +} + +function createTabs(codes: Code[], file?: VFile) { + const tabs: ReplTab[] = []; + + codes.forEach((code, index) => { + const name = getTabName(code, index, file); + + if (tabs.some((tab) => tab.name === name)) { + fail(file, code, `repl directives contain duplicate file "${name}"`); + } + + tabs.push({ name, source: code.value }); + }); + + return tabs; +} + +function createReplElement( + codes: Code[], + attributes: Record, + file?: VFile, +) { + const tabs = createTabs(codes, file); + + return { + type: "mdxJsxFlowElement", + name: "Repl", + children: [], + attributes: [ + ...Object.entries(attributes) + .filter(([, value]) => value !== undefined) + .map(([name, value]) => ({ + type: "mdxJsxAttribute", + name, + // bare directive attributes (`{devtools}`) become boolean props + value: value === null || value === "" ? null : String(value), + })), + { + type: "mdxJsxAttribute", + name: "tabs", + value: { + type: "mdxJsxAttributeValueExpression", + value: JSON.stringify(tabs), + data: { + estree: { + type: "Program", + sourceType: "module", + body: [ + { + type: "ExpressionStatement", + expression: valueToEstree(tabs), + }, + ], + }, + }, + }, + }, + ], + }; +} + +export function remarkRepl(): Transformer { + return (tree, file) => { + visit(tree, (node: any, index, parent: any) => { + if (index === undefined || parent === undefined) return; + + if (node.type === "containerDirective" && node.name === "repl") { + const maybeLabel = node.children[0]; + if (maybeLabel?.data?.directiveLabel) { + fail(file, maybeLabel, "repl directives do not support titles"); + } + + if (node.children.length === 0) { + fail( + file, + node, + "repl directives must contain at least one code block", + ); + } + + for (const child of node.children) { + if (child.type !== "code") { + fail(file, child, "repl directives may only contain code blocks"); + } + } + + parent.children[index] = createReplElement( + node.children, + node.attributes ?? {}, + file, + ); + return SKIP; + } + + if (node.type === "code") { + const meta = new MetaOptions(node.meta ?? ""); + if (!meta.getBoolean("repl")) return; + + parent.children[index] = createReplElement([node], {}, file); + return SKIP; + } + }); + }; +} diff --git a/src/default-theme/components/Repl.module.css b/src/default-theme/components/Repl.module.css new file mode 100644 index 0000000..7ee91de --- /dev/null +++ b/src/default-theme/components/Repl.module.css @@ -0,0 +1,47 @@ +.repl { + margin: 1.5rem 0; + height: 32rem; + display: flex; + flex-direction: column; + border-radius: 0.5rem; + overflow: hidden; + border: 1px solid + color-mix(in hsl, var(--sb-decoration-color) 14%, transparent); +} + +.repl > * { + flex: 1; + min-height: 0; +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + font-size: 0.875rem; + opacity: 0.7; +} + +/* solid-repl expects panda's preflight, which repl.ts strips out. Re-apply what it relies on, scoped to the REPL and the zag-js elements it portals to the body. */ +@layer panda-reset { + :is(.repl, [data-scope]) *, + :is(.repl, [data-scope]) ::before, + :is(.repl, [data-scope]) ::after { + margin: 0; + padding: 0; + border-width: 0; + border-style: solid; + border-color: currentcolor; + box-sizing: border-box; + } + + :is(.repl, [data-scope]) :is(button, input, select, textarea) { + font: inherit; + background: transparent; + color: inherit; + } + + :is(.repl, [data-scope]) :is(ol, ul, menu) { + list-style: none; + } +} diff --git a/src/default-theme/components/Repl.tsx b/src/default-theme/components/Repl.tsx new file mode 100644 index 0000000..7421df6 --- /dev/null +++ b/src/default-theme/components/Repl.tsx @@ -0,0 +1,42 @@ +import { clientOnly } from "@solidjs/start"; +import { ErrorBoundary } from "solid-js"; +import type { ReplTab } from "../../config/remark-plugins/repl.js"; +import { SOLID_REPL_ROOT_CLASS } from "../repl-constants.js"; +import { useThemeText } from "../utils.js"; +import styles from "./Repl.module.css"; + +export interface ReplProps { + tabs: ReplTab[]; + height?: string; + layout?: "vertical" | "horizontal"; + devtools?: boolean | string; +} + +// solid-repl depends on Web Workers, dockview and CodeMirror, none of which run on the server. +// The virtual module is a stub when solid-repl isn't installed (see ../repl.ts). +const ReplClient = clientOnly( + () => import("virtual:solidbase/default-theme/repl-client"), +); + +export function Repl(props: ReplProps) { + const text = useThemeText(); + + return ( +
+ { + console.error(error); + return
{text.replError}
; + }} + > + {text.replLoading}
} + /> + + + ); +} diff --git a/src/default-theme/components/ReplClient.tsx b/src/default-theme/components/ReplClient.tsx new file mode 100644 index 0000000..7a3af84 --- /dev/null +++ b/src/default-theme/components/ReplClient.tsx @@ -0,0 +1,68 @@ +import type { Tab } from "solid-repl"; +import CompilerWorker from "solid-repl/dist/compiler?worker"; +import FormatterWorker from "solid-repl/dist/formatter?worker"; +import LinterWorker from "solid-repl/dist/linter?worker"; +import SolidRepl from "solid-repl/dist/repl"; + +import "solid-repl/dist/bundle.css"; + +import { createMemo, createSignal, createUniqueId, onCleanup } from "solid-js"; + +import { getTheme } from "../../client/theme.js"; +import type { ReplProps } from "./Repl.jsx"; + +interface ReplWorkers { + compiler: Worker; + formatter: Worker; + linter: Worker; +} + +let workers: ReplWorkers | undefined; +let users = 0; + +function useReplWorkers() { + workers ??= { + compiler: new CompilerWorker(), + formatter: new FormatterWorker(), + linter: new LinterWorker(), + }; + users++; + + onCleanup(() => { + if (--users > 0 || !workers) return; + workers.compiler.terminate(); + workers.formatter.terminate(); + workers.linter.terminate(); + workers = undefined; + }); + + return workers; +} + +export default function ReplClient(props: ReplProps) { + const { compiler, formatter, linter } = useReplWorkers(); + + const id = `sb-repl-${createUniqueId()}`; + const initialTabs = () => props.tabs.map((tab) => ({ ...tab })); + const [tabs, setTabs] = createSignal(initialTabs()); + + const dark = createMemo(() => getTheme() === "dark"); + const devtools = () => + props.devtools === true || + (typeof props.devtools === "string" && props.devtools !== "false"); + + return ( + setTabs(initialTabs())} + hideDevtools={!devtools()} + vertical={props.layout !== "horizontal"} + /> + ); +} diff --git a/src/default-theme/index.ts b/src/default-theme/index.ts index ac31462..f78ca31 100644 --- a/src/default-theme/index.ts +++ b/src/default-theme/index.ts @@ -4,6 +4,7 @@ import type { DocSearchProps } from "@docsearch/js"; import type { Component, JSX } from "solid-js"; import { defineTheme, type ThemeDefinition } from "../config/index.js"; import type { SidebarConfig } from "../config/sidebar.js"; +import { solidReplVitePlugin } from "./repl.js"; import type { DefaultThemeSidebarItem } from "./sidebar.js"; import type { DefaultThemeTextConfig } from "./text.js"; @@ -104,6 +105,7 @@ const defaultTheme: ThemeDefinition = defineTheme({ } return [ + solidReplVitePlugin(), { name: "solidbase-default-theme-fonts", resolveId(id) { diff --git a/src/default-theme/mdx-components.tsx b/src/default-theme/mdx-components.tsx index b839118..e33473c 100644 --- a/src/default-theme/mdx-components.tsx +++ b/src/default-theme/mdx-components.tsx @@ -18,6 +18,7 @@ import { import { usePreferredLanguage } from "../client/preferred-language.js"; import CopyPageLink from "../default-theme/components/CopyPageLink.jsx"; import { Preview, PreviewPanel, PreviewStage } from "./components/Preview.jsx"; +import { Repl } from "./components/Repl.jsx"; import { useDefaultThemeComponents } from "./context.jsx"; import { useDefaultThemeFrontmatter } from "./frontmatter.js"; import styles from "./mdx-components.module.css"; @@ -231,7 +232,7 @@ export function DirectiveContainer( ); } -export { Preview, PreviewPanel, PreviewStage }; +export { Preview, PreviewPanel, PreviewStage, Repl }; export function Steps(props: ParentProps) { return
{props.children}
; diff --git a/src/default-theme/repl-constants.ts b/src/default-theme/repl-constants.ts new file mode 100644 index 0000000..47f59f6 --- /dev/null +++ b/src/default-theme/repl-constants.ts @@ -0,0 +1,2 @@ +// shared with the vite plugin in repl.ts, so this file must stay free of node-only imports +export const SOLID_REPL_ROOT_CLASS = "sb-repl"; diff --git a/src/default-theme/repl.ts b/src/default-theme/repl.ts new file mode 100644 index 0000000..0538a3e --- /dev/null +++ b/src/default-theme/repl.ts @@ -0,0 +1,101 @@ +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import type { Plugin } from "vite"; + +import { SOLID_REPL_ROOT_CLASS } from "./repl-constants.js"; + +const RESET_LAYER = "@layer panda-reset {"; + +const REPL_CLIENT_ID = "virtual:solidbase/default-theme/repl-client"; +const RESOLVED_REPL_CLIENT_STUB_ID = `\0${REPL_CLIENT_ID}`; + +// solid-repl's stylesheet is written for a full-page app: a global preflight reset, +// dockview theming keyed on `#app` and dark mode keyed on a `.dark` ancestor. +// Drop the reset (Repl.module.css re-applies it scoped to the REPL) and rewrite +// the selectors so the rest only affects the embedded REPL. +export function scopeSolidReplCss(code: string) { + let css = code; + + const start = css.indexOf(RESET_LAYER); + if (start !== -1) { + let depth = 0; + let end = css.length - 1; + + for (let i = start + RESET_LAYER.length - 1; i < css.length; i++) { + const char = css[i]; + if (char === "{") depth++; + else if (char === "}") { + depth--; + if (depth === 0) { + end = i; + break; + } + } + } + + css = css.slice(0, start) + css.slice(end + 1); + } + + return css + .replace(/(^|[\s,])\.dark(?=\s)/gm, '$1[data-theme*="dark"]') + .replace(/(^|[\s,])#app(?=\s)/gm, `$1.${SOLID_REPL_ROOT_CLASS}`); +} + +export function isSolidReplStylesheet(id: string) { + const [path] = id.split("?"); + return path!.replaceAll("\\", "/").endsWith("/solid-repl/dist/bundle.css"); +} + +function resolveSolidReplRoot() { + try { + const require = createRequire(import.meta.url); + return dirname(require.resolve("solid-repl/package.json")); + } catch { + return undefined; + } +} + +export function solidReplVitePlugin(): Plugin { + const solidReplRoot = resolveSolidReplRoot(); + + return { + name: "solidbase-default-theme-repl", + enforce: "pre", + config(_config, env) { + if (!solidReplRoot) return; + + return { + // solid-repl's workers bundle Babel, which reads `process.env` at load time. + // Defining one key makes Vite create a `process` global inside workers. + define: { + "process.env.NODE_DEBUG": "false", + ...(env.command === "serve" ? { global: "globalThis" } : {}), + }, + resolve: { + // solid-repl's compiled output imports panda's generated runtime, + // which the package ships in dist/styled-system. + alias: { + "styled-system": join(solidReplRoot, "dist/styled-system"), + }, + dedupe: ["solid-js", "solid-js/web", "solid-js/store"], + }, + }; + }, + resolveId(id, importer) { + if (id !== REPL_CLIENT_ID) return; + if (!solidReplRoot) return RESOLVED_REPL_CLIENT_STUB_ID; + return this.resolve("./ReplClient.jsx", importer, { skipSelf: true }); + }, + load(id) { + if (id !== RESOLVED_REPL_CLIENT_STUB_ID) return; + + return `export default function ReplClient() { + throw new Error("Install \`solid-repl\` to use the REPL component."); +}`; + }, + transform(code, id) { + if (!isSolidReplStylesheet(id)) return; + return { code: scopeSolidReplCss(code), map: null }; + }, + }; +} diff --git a/src/default-theme/text.ts b/src/default-theme/text.ts index dcc8cac..ceac48e 100644 --- a/src/default-theme/text.ts +++ b/src/default-theme/text.ts @@ -3,6 +3,8 @@ export type DefaultThemeTextConfig = { copyPage: string; copiedPage: string; copyFailedPage: string; + replLoading: string; + replError: string; }; export const defaultThemeTextConfig: DefaultThemeTextConfig = { @@ -10,4 +12,7 @@ export const defaultThemeTextConfig: DefaultThemeTextConfig = { copyPage: "Copy page", copiedPage: "Copied!", copyFailedPage: "Copy failed", + replLoading: "Loading playground…", + replError: + "The playground failed to load. Your browser may not support Web Workers.", }; diff --git a/src/virtual.d.ts b/src/virtual.d.ts index c02fc5b..0844003 100644 --- a/src/virtual.d.ts +++ b/src/virtual.d.ts @@ -18,3 +18,8 @@ declare module "virtual:solidbase/default-theme/fonts" { declare module "virtual:solidbase/mdx" { export const MDXProvider: typeof import("./mdx.ts").MDXProvider; } + +declare module "virtual:solidbase/default-theme/repl-client" { + const ReplClient: typeof import("./default-theme/components/ReplClient.tsx").default; + export default ReplClient; +} diff --git a/tsconfig.json b/tsconfig.json index 93694fd..edf45df 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,12 @@ "declaration": true, "sourceMap": true, "lib": ["dom", "esnext"], - "types": ["solid-js", "vite/client", "unplugin-icons/types/solid"], + "types": [ + "solid-js", + "vite/client", + "unplugin-icons/types/solid", + "solid-repl/dist/types" + ], "rootDir": "./src", "outDir": "./dist" },