diff --git a/app/routes/_marketing.playground.tsx b/app/routes/_marketing.playground.tsx
new file mode 100644
index 00000000..b17f68cb
--- /dev/null
+++ b/app/routes/_marketing.playground.tsx
@@ -0,0 +1,322 @@
+import * as React from "react";
+import { type HeadersFunction } from "@remix-run/node";
+import { Await } from "@remix-run/react";
+import ManacoEditor from "@monaco-editor/react";
+import type * as wc from "@webcontainer/api";
+
+export default function Playground() {
+ const state = useWebContainer();
+ const [mounted, setMounted] = React.useState(false);
+ React.useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ const loadingState = (
+
+ {state?.status ? `${state.status}...` : "booting..."}
+
+ );
+
+ if (!mounted || (!state?.containerPromise && !state?.container)) {
+ return loadingState;
+ }
+
+ return (
+
+ {() => (
+
+ )}
+
+ );
+}
+
+export const headers: HeadersFunction = () => {
+ const headers = new Headers();
+ headers.set("Cross-Origin-Embedder-Policy", "require-corp");
+ headers.set("Cross-Origin-Opener-Policy", "same-origin");
+ return headers;
+};
+
+function Preview() {
+ const state = useWebContainer();
+ const [mounted, setMounted] = React.useState(false);
+ React.useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ const loadingState = (
+
+ {state?.status
+ ? state.status === "ready"
+ ? "waiting for server..."
+ : `${state.status}...`
+ : "booting..."}
+
+ );
+
+ if (!mounted || !state?.urlPromise) {
+ return loadingState;
+ }
+
+ return (
+
+
+ {(url) => (
+
+ )}
+
+
+ );
+}
+
+function Editor() {
+ const [localContainer, setLocalContainer] =
+ React.useState(null);
+ const state = useWebContainer();
+ const containerOrPromise =
+ state?.container ?? state?.containerPromise ?? null;
+
+ const editor = (
+ {
+ if (!localContainer) return;
+ localContainer.fs.writeFile(
+ "/app/routes/_index.tsx",
+ value || "",
+ "utf8",
+ );
+ }}
+ />
+ );
+ return (
+
+
+ {(container) => {
+ if (localContainer !== container) {
+ setTimeout(() => {
+ setLocalContainer(container);
+ }, 0);
+ }
+ return editor;
+ }}
+
+
+ );
+}
+
+interface WebContainerStore {
+ state: {
+ container?: wc.WebContainer;
+ containerPromise?: Promise;
+ urlPromise?: Promise;
+ status:
+ | "idle"
+ | "booting container"
+ | "initializing template"
+ | "installing dependencies"
+ | "ready"
+ | "error";
+ };
+ subscribe: (onStoreChange: () => void) => () => void;
+ getSnapshot: () => (typeof webContainerStore)["state"];
+ getServerSnapshot: () => (typeof webContainerStore)["state"];
+ update: (newState: Partial<(typeof webContainerStore)["state"]>) => void;
+}
+
+declare global {
+ interface Window {
+ webContainerStore?: WebContainerStore;
+ }
+}
+
+const onChangeHandlers = new Set<() => void>();
+let webContainerStore: WebContainerStore = {
+ state: {
+ status: "idle",
+ },
+ subscribe: (onChange) => {
+ onChangeHandlers.add(onChange);
+ return () => {
+ onChangeHandlers.delete(onChange);
+ };
+ },
+ getSnapshot: () => webContainerStore.state,
+ getServerSnapshot: () => webContainerStore.state,
+ update: (newState) => {
+ webContainerStore.state = Object.assign(
+ {},
+ webContainerStore.state,
+ newState,
+ );
+ onChangeHandlers.forEach((onChange) => onChange());
+ },
+};
+if (typeof document !== "undefined") {
+ if (window.webContainerStore) {
+ webContainerStore = window.webContainerStore;
+ } else {
+ window.webContainerStore = webContainerStore;
+ }
+}
+
+function useWebContainer() {
+ const store = React.useSyncExternalStore(
+ webContainerStore.subscribe,
+ webContainerStore.getSnapshot,
+ webContainerStore.getServerSnapshot,
+ );
+
+ if (typeof document === "undefined") {
+ return null;
+ }
+
+ if (!store.container && !store.containerPromise) {
+ const deferredURL = new Deferred();
+ webContainerStore.update({
+ status: "booting container",
+ urlPromise: deferredURL.promise,
+ containerPromise: import("@webcontainer/api")
+ .then(({ WebContainer }) => WebContainer.boot())
+ .then(async (container) => {
+ webContainerStore.update({ status: "initializing template" });
+
+ const process = await container.spawn("npx", [
+ "-y",
+ "create-remix@latest",
+ ".",
+ "-y",
+ "--no-color",
+ "--no-motion",
+ "--no-install",
+ "--no-git-init",
+ "--template",
+ "https://github.com/remix-run/remix/tree/main/templates/vite",
+ ]);
+ if ((await process.exit) !== 0) {
+ throw new Error("Failed to create remix app");
+ }
+
+ container.fs.writeFile(
+ "/app/routes/_index.tsx",
+ DEFAULT_ROUTE,
+ "utf8",
+ );
+
+ return container;
+ })
+ .then((container) => {
+ webContainerStore.update({ status: "installing dependencies" });
+ container
+ .spawn("npm", ["install"])
+ .then((process) => process.exit)
+ .then((exit) => {
+ if (exit !== 0) {
+ throw new Error("Failed to install dependencies");
+ }
+ })
+ .then(() => {
+ webContainerStore.update({
+ status: "ready",
+ container,
+ containerPromise: undefined,
+ });
+
+ container.on("server-ready", (port, url) => {
+ if (port === 5173) {
+ deferredURL.resolve(url);
+ }
+ });
+
+ return container
+ .spawn("npm", ["run", "dev"])
+ .then(async (process) => {
+ return process.exit;
+ });
+ })
+ .then(() => {
+ throw new Error("Dev server exited unexpectedly");
+ })
+ .catch((reason) => {
+ deferredURL.reject(reason);
+ });
+ return container;
+ })
+ .catch((reason) => {
+ deferredURL.reject(reason);
+ throw reason;
+ }),
+ });
+ }
+
+ return store;
+}
+
+class Deferred {
+ promise: Promise;
+ resolve!: (value: T) => void;
+ reject!: (reason?: any) => void;
+ constructor() {
+ this.promise = new Promise((resolve, reject) => {
+ this.resolve = resolve;
+ this.reject = reject;
+ });
+ }
+}
+
+const js = String.raw;
+const DEFAULT_ROUTE = js`
+import { Form, useActionData } from "@remix-run/react";
+
+export async function action({ request }) {
+ const formData = new URLSearchParams(await request.text());
+ return {
+ message: "Hello, " + (formData.get("name") || "World") + "!",
+ };
+}
+
+export default function Route() {
+ const actionData = useActionData();
+
+ return (
+
+ Hello, World!
+
+ {actionData && {actionData.message}
}
+
+ );
+}
+`.trim();
diff --git a/app/ui/header.tsx b/app/ui/header.tsx
index 664ddd31..a9f3e794 100644
--- a/app/ui/header.tsx
+++ b/app/ui/header.tsx
@@ -41,6 +41,7 @@ export function Header({
Blog
Showcase
Resources
+ Playground
diff --git a/package-lock.json b/package-lock.json
index 78540d08..cd4e8d47 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,11 +10,13 @@
"dependencies": {
"@docsearch/css": "^3.5.2",
"@docsearch/react": "^3.5.2",
+ "@monaco-editor/react": "^4.6.0",
"@remix-run/express": "2.6.0",
"@remix-run/node": "2.6.0",
"@remix-run/react": "2.6.0",
"@remix-run/v1-meta": "0.1.3",
"@tailwindcss/aspect-ratio": "^0.4.2",
+ "@webcontainer/api": "^1.1.9",
"cheerio": "^1.0.0-rc.12",
"clsx": "^2.1.0",
"compression": "^1.7.4",
@@ -2378,6 +2380,30 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/@monaco-editor/loader": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.4.0.tgz",
+ "integrity": "sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==",
+ "dependencies": {
+ "state-local": "^1.0.6"
+ },
+ "peerDependencies": {
+ "monaco-editor": ">= 0.21.0 < 1"
+ }
+ },
+ "node_modules/@monaco-editor/react": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.6.0.tgz",
+ "integrity": "sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==",
+ "dependencies": {
+ "@monaco-editor/loader": "^1.4.0"
+ },
+ "peerDependencies": {
+ "monaco-editor": ">= 0.25.0 < 1",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@@ -4808,6 +4834,11 @@
"resolved": "https://registry.npmjs.org/@web3-storage/multipart-parser/-/multipart-parser-1.0.0.tgz",
"integrity": "sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw=="
},
+ "node_modules/@webcontainer/api": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/@webcontainer/api/-/api-1.1.9.tgz",
+ "integrity": "sha512-Sp6PV0K9D/3f8fSbCubqhfmBFH8XbngZCBOCF+aExyGqnz2etmw+KYvbQ/JxYvYX5KPaSxM+asFQwoP2RHl5cg=="
+ },
"node_modules/@zxing/text-encoding": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
@@ -15517,6 +15548,12 @@
"integrity": "sha512-yoe+JbhTClckZ67b2itRtistFKf8yPYelHLc7e5xAwtNAXxM6wJTUx2C7QeVSJFDzKT7bCIFyBVybPMKvmB9AA==",
"dev": true
},
+ "node_modules/monaco-editor": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.44.0.tgz",
+ "integrity": "sha512-5SmjNStN6bSuSE5WPT2ZV+iYn1/yI9sd4Igtk23ChvqB7kDk9lZbB9F5frsuvpB+2njdIeGGFf2G4gbE6rCC9Q==",
+ "peer": true
+ },
"node_modules/morgan": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz",
@@ -19030,6 +19067,11 @@
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true
},
+ "node_modules/state-local": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
+ "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="
+ },
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
diff --git a/package.json b/package.json
index 57220969..300a0130 100644
--- a/package.json
+++ b/package.json
@@ -22,11 +22,13 @@
"dependencies": {
"@docsearch/css": "^3.5.2",
"@docsearch/react": "^3.5.2",
+ "@monaco-editor/react": "^4.6.0",
"@remix-run/express": "2.6.0",
"@remix-run/node": "2.6.0",
"@remix-run/react": "2.6.0",
"@remix-run/v1-meta": "0.1.3",
"@tailwindcss/aspect-ratio": "^0.4.2",
+ "@webcontainer/api": "^1.1.9",
"cheerio": "^1.0.0-rc.12",
"clsx": "^2.1.0",
"compression": "^1.7.4",