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"
},