Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions dev/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
70 changes: 70 additions & 0 deletions dev/src/routes/repl.mdx
Original file line number Diff line number Diff line change
@@ -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 (
<button type="button" onClick={increment}>
{count()}
</button>
);
}

render(() => <Counter />, 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(() => <Greeting name="SolidBase" />, document.getElementById("app")!);
```

```tsx title="Greeting.tsx"
export function Greeting(props: { name: string }) {
return <h1>Hello {props.name}!</h1>;
}
```
:::

## 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(() => <p>Horizontal layout</p>, 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(() => <p>Rendered from a flagged code block.</p>, document.getElementById("app")!);
```
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/config/document-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,6 +35,7 @@ type MdxNode = {
const DOCUMENT_ONLY_SKIPPED_PLUGINS = new Set([
remarkCodeTabs,
remarkPreview,
remarkRepl,
remarkAddClass,
]);

Expand Down
8 changes: 7 additions & 1 deletion src/config/mdx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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]);
Expand Down
137 changes: 137 additions & 0 deletions src/config/remark-plugins/repl.ts
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the first block has a title (e.g. title="App.tsx"), no main.tsx is produced, and the preview only updates when ./main exists (solid-repl/src/components/preview.tsx:278), so it stays blank with no error. Either always name the first block main.tsx or fail when no tab is main.tsx.

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<string, unknown>,
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<Root, Root> {
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;
}
});
};
}
47 changes: 47 additions & 0 deletions src/default-theme/components/Repl.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
42 changes: 42 additions & 0 deletions src/default-theme/components/Repl.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
class={`${styles.repl} ${SOLID_REPL_ROOT_CLASS}`}
style={{ height: props.height }}
>
<ErrorBoundary
fallback={(error) => {
console.error(error);
return <div class={styles.loading}>{text.replError}</div>;
}}
>
<ReplClient
{...props}
fallback={<div class={styles.loading}>{text.replLoading}</div>}
/>
</ErrorBoundary>
</div>
);
}
Loading