From 7569d474abf26cf781b0c3daaa2d89d0a590f7f2 Mon Sep 17 00:00:00 2001 From: hoce1n Date: Mon, 10 Aug 2026 14:04:18 +0000 Subject: [PATCH] feat(embed): add iframe-embeddable REPL + event loop view Add a standalone /embed route that reuses the existing Workbench (console or event-loop view) with no app shell chrome, preloads a snippet via ?example= (REPL examples or interview patterns), and is explicitly exempted from frame-blocking headers so it can be dropped into third-party iframes. Documents the embed in README. Co-authored-by: monkeycode-ai --- README.md | 17 ++++++ src/routeTree.gen.ts | 30 +++++++++- src/routes/embed.tsx | 140 +++++++++++++++++++++++++++++++++++++++++++ src/start.ts | 21 ++++++- 4 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 src/routes/embed.tsx diff --git a/README.md b/README.md index 725f37e..0e409b1 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,23 @@ Then open the local URL shown by Vite. (The lockfile is generated with [Bun](htt The app is built with TanStack Start and Vite. User code runs in a sandboxed iframe, and the event-loop trace is generated from Acorn-based AST instrumentation plus controlled queue tracking. +## Embed this + +The REPL and event-loop visualizer are also available as a standalone, iframe-friendly page — no app chrome, no search, just the tool — so you can drop a runnable example straight into a blog post or docs page. + +The embed lives at `/embed`. Add `?example=` to preload a specific snippet (either a REPL example id or an interview-pattern id from the main site); unknown ids fall back to the default example. `?view=loop` opens the event-loop view instead of the console. + +```html + +``` + +The small "via runtime.js" badge in the corner links back to the main site, which is the whole point of embedding it. + ## Analytics Cookie-less, privacy-friendly analytics are built in but **disabled by default** — no script is loaded until you configure a provider: diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index ae22be2..b5510b4 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' +import { Route as EmbedRouteImport } from './routes/embed' import { Route as EraEraRouteImport } from './routes/era.$era' import { Route as EraEraIndexRouteImport } from './routes/era.$era.index' import { Route as EraEraConceptRouteImport } from './routes/era.$era.$concept' @@ -19,6 +20,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const EmbedRoute = EmbedRouteImport.update({ + id: '/embed', + path: '/embed', + getParentRoute: () => rootRouteImport, +} as any) const EraEraRoute = EraEraRouteImport.update({ id: '/era/$era', path: '/era/$era', @@ -37,32 +43,42 @@ const EraEraConceptRoute = EraEraConceptRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/embed': typeof EmbedRoute '/era/$era': typeof EraEraRouteWithChildren '/era/$era/$concept': typeof EraEraConceptRoute '/era/$era/': typeof EraEraIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/embed': typeof EmbedRoute '/era/$era/$concept': typeof EraEraConceptRoute '/era/$era': typeof EraEraIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/embed': typeof EmbedRoute '/era/$era': typeof EraEraRouteWithChildren '/era/$era/$concept': typeof EraEraConceptRoute '/era/$era/': typeof EraEraIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/era/$era' | '/era/$era/$concept' | '/era/$era/' + fullPaths: '/' | '/embed' | '/era/$era' | '/era/$era/$concept' | '/era/$era/' fileRoutesByTo: FileRoutesByTo - to: '/' | '/era/$era/$concept' | '/era/$era' - id: '__root__' | '/' | '/era/$era' | '/era/$era/$concept' | '/era/$era/' + to: '/' | '/embed' | '/era/$era/$concept' | '/era/$era' + id: + | '__root__' + | '/' + | '/embed' + | '/era/$era' + | '/era/$era/$concept' + | '/era/$era/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + EmbedRoute: typeof EmbedRoute EraEraRoute: typeof EraEraRouteWithChildren } @@ -75,6 +91,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/embed': { + id: '/embed' + path: '/embed' + fullPath: '/embed' + preLoaderRoute: typeof EmbedRouteImport + parentRoute: typeof rootRouteImport + } '/era/$era': { id: '/era/$era' path: '/era/$era' @@ -114,6 +137,7 @@ const EraEraRouteWithChildren = const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + EmbedRoute: EmbedRoute, EraEraRoute: EraEraRouteWithChildren, } export const routeTree = rootRouteImport diff --git a/src/routes/embed.tsx b/src/routes/embed.tsx new file mode 100644 index 0000000..9cd1b26 --- /dev/null +++ b/src/routes/embed.tsx @@ -0,0 +1,140 @@ +import { lazy, Suspense, useEffect, useState } from "react"; +import { createFileRoute, ClientOnly } from "@tanstack/react-router"; +import { REPL_EXAMPLES } from "@/lib/js-eras"; +import { INTERVIEW_PATTERNS } from "@/lib/interview-patterns"; + +const Workbench = lazy(() => import("@/components/devtools/Workbench")); + +type EmbedView = "console" | "loop"; + +const DEFAULT_CODE = REPL_EXAMPLES[0]!.code; + +/** Resolve an example/pattern id to code, falling back to the first REPL example. */ +function codeForExample(example?: string): string { + if (!example) return DEFAULT_CODE; + const repl = REPL_EXAMPLES.find((e) => e.id === example); + if (repl) return repl.code; + const pattern = INTERVIEW_PATTERNS.find((p) => p.id === example); + if (pattern) return pattern.code; + return DEFAULT_CODE; +} + +function labelForExample(example?: string): string | undefined { + if (!example) return undefined; + return ( + REPL_EXAMPLES.find((e) => e.id === example)?.label ?? + INTERVIEW_PATTERNS.find((p) => p.id === example)?.title + ); +} + +export const Route = createFileRoute("/embed")({ + validateSearch: ( + search: Record, + ): { example?: string | undefined; view: EmbedView } => { + const example = typeof search["example"] === "string" ? search["example"] : undefined; + return { + ...(example !== undefined ? { example } : {}), + view: search["view"] === "loop" ? "loop" : "console", + }; + }, + head: () => ({ + meta: [ + { title: "Embeddable REPL — runtime.js" }, + { + name: "description", + content: + "A small embeddable sandboxed REPL and event loop visualizer. Drop it into any page with an iframe.", + }, + { name: "robots", content: "noindex, nofollow" }, + ], + }), + component: Embed, +}); + +function Embed() { + const { example, view: searchView } = Route.useSearch(); + const [code, setCode] = useState(() => codeForExample(example)); + const [view, setView] = useState(searchView); + + // React to ?example= / ?view= changes without a reload (e.g. SPA links). + useEffect(() => { + setCode(codeForExample(example)); + }, [example]); + + useEffect(() => { + setView(searchView); + }, [searchView]); + + const exampleLabel = labelForExample(example); + + return ( +
+
+ + + JS + + + runtime.js + + + + {exampleLabel && ( + + {exampleLabel} + + )} + +
+ {(["console", "loop"] as const).map((v) => ( + + ))} +
+
+ +
+ booting sandbox…}> + loading editor…}> + + + +
+ + +
+ ); +} + +function EmbedFallback({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/src/start.ts b/src/start.ts index 6935f74..aa1e1a4 100644 --- a/src/start.ts +++ b/src/start.ts @@ -17,6 +17,25 @@ const errorMiddleware = createMiddleware().server(async ({ next }) => { } }); +// The /embed route exists to be dropped into