diff --git a/.gitignore b/.gitignore index f2b2319..00478e0 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ bundle-analysis.html # Agent .claude/ + +# manual demo output +/demo-site +/out diff --git a/AGENT.md b/AGENT.md index 58a7872..9856be4 100644 --- a/AGENT.md +++ b/AGENT.md @@ -4,7 +4,7 @@ Agent instructions for working in this repository. Read this before making chang ## What this project is -**`react-code-canvas`** is a published React/TypeScript **library** (npm package, currently `4.2.0-beta.18`). It provides a browser-based canvas that **renders React components from code strings at runtime** and supports live text editing of the rendered output. +**`react-code-canvas`** is a published React/TypeScript **library** (npm package, currently `5.0.0`). It provides a browser-based canvas that **renders React components from code strings at runtime** and supports live text editing of the rendered output. Primary use cases: previewing JSX from strings, exposing a scoped set of components/libraries to evaluated code, and validating generated React code before saving it. @@ -22,6 +22,24 @@ The package entry is `src/index.ts`. It exports two components, their prop types Prop types `ReactCanvasProps` and `EditReactCanvasProps` are exported alongside, plus the analyzer's `AnalysisResult`, `AnalyzeOptions`, `CodeIssue` and `IssueType`. +### Second entry: `react-code-canvas/builder` (Node only) + +| Export | Source | Purpose | +| --- | --- | --- | +| `buildStandaloneSite` | `src/builder/standalone.ts` | Build a code string into a deployable static site; returns a `path -> contents` map. | +| `writeStandaloneSite` | `src/builder/standalone.ts` | The same, written to a directory. | + +Emits `index.html` plus hashed `assets/`, with React, every referenced library and +compiled Tailwind bundled in — no network at runtime. **Never import this from `src/index.ts`**: it +pulls in `esbuild`, `node:*` and `react-dom/server`, none of which may reach a consumer's browser +bundle. `esbuild` and `tailwindcss` are *optional* peer dependencies, loaded via dynamic import at +point of use. + +The pure string half lives in `src/builder/entry.ts`, separate only because `standalone.ts` uses +`import.meta.url` (for esbuild's `resolveDir`), which is a syntax error under jest's CJS runtime — +so anything a test imports has to live outside it. Jest covers `entry.ts`; the end-to-end build runs +via `bun run test:manual:site` against `dist/builder.mjs`. + **Contract for consumer-supplied `code`:** must `export default` a component (or call `render(...)`), must **not** contain `import` statements — dependencies are injected through the `scope` prop instead. **Code persistence:** `ReactCanvas` and `EditTextReactCanvas` accept `persistKey?: string` (saves/restores code in `localStorage`) and `onCodeChange?: (code: string) => void` (notifies on change so the host can persist however it likes). For `EditTextReactCanvas` these operate on the **final saved JSX**, not the intermediate EditableText form. Shared, SSR-safe storage helpers live in `src/coreComponents/core/storage.ts`. @@ -32,11 +50,13 @@ Top-level layout (build artifacts and `node_modules` omitted): ``` . -├── src/ # library source — the published code (see Architecture) +├── src/ # library source — the published code (see Scope resolution) ├── dist/ # build output: CJS + ESM bundles + .d.ts (gitignored) ├── doc_site/ # Docusaurus documentation site (own package + bun.lock) ├── scripts/ -│ └── manual/ # manual, run-by-hand scripts (e.g. transformer walkthrough) +│ ├── generate-scope-maps.mjs # emits src/scopes/generated/ from node_modules +│ └── manual/ # manual, run-by-hand scripts (transformer, analyzer, site builder) +├── skills/ # react-code-canvas agent skill, shipped in the package ├── public/ # static assets (e.g. rrc.png used by the README) ├── storybook-static/ # built static Storybook (gitignored) ├── coverage/ # jest coverage output (gitignored) @@ -44,7 +64,7 @@ Top-level layout (build artifacts and `node_modules` omitted): ├── .github/ # workflows (test, deploy-docs), issue/PR templates ├── rollup.config.mjs # library bundler config ├── babel.config.js # babel presets used by jest (babel-jest) -├── jest.config.js # currently empty → jest defaults +├── jest.config.js # transformIgnorePatterns: lucide-react is ESM-only, must go through babel ├── tsconfig.json # TS config; emits declarations to dist/ ├── package.json # name, version, exports/types, scripts, deps ├── bun.lock # the committed lockfile (never commit package-lock.json) @@ -55,6 +75,26 @@ Top-level layout (build artifacts and `node_modules` omitted): └── bundle-analysis.html # rollup-plugin-visualizer output (gitignored via *.html) ``` +## Scope resolution + +`src/scopes/lazyScope.ts` is the **only** scope path. It regex-scans the code for +identifiers and loads just what is referenced: lucide icons one file at a time, +recharts / motion / `react-icons/fa` whole (they are not per-component +splittable). `resolveScope` is async; `useResolvedScope` returns `readyCode` so +code is never evaluated against a scope that has not finished loading. + +The name → loader tables in `src/scopes/generated/` are produced from the +installed dependencies by `bun run generate:scopes`, which runs automatically as +part of `bun run build`. **Never hand-edit `src/scopes/generated/`.** Import +specifiers must be string literals or Vite/Rollup cannot split them — that is +why the tables are generated rather than computed. + +The old eager scope (`src/scopes/Scope.ts` and the per-package +`lucidreactScope` / `rechartScope` / `motionScope` modules) was removed in +5.0.0. It spread every export of every package into one object at module load, +forcing ~300 KB (brotli) into the first chunk and handing ~5800 parameters to +`new Function` on every evaluation. Do not reintroduce it. + ## Commands Bun is the package manager; the committed lockfile is `bun.lock` (never commit `package-lock.json`). @@ -62,7 +102,10 @@ Bun is the package manager; the committed lockfile is `bun.lock` (never commit ` ```bash bun install --frozen-lockfile bun run test # jest -bun run build # rollup -c -> dist/ +bun run build # rollup -c -> dist/ (browser entry + dist/builder.mjs) +bun run test:manual:site # end-to-end site build; needs `bun run build` first +bun run test:manual:site ./Page.jsx [outDir] # build one canvas-format file instead +bun run demo:site # the same, on scripts/manual/fixtures/demo-page.jsx bun run dev # storybook dev on :6006 bun run build-storybook # static Storybook -> storybook-static/ bun run size # size-limit check diff --git a/README.md b/README.md index 3cc40c4..4554bad 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,55 @@ import { EditTextReactCanvas } from "react-code-canvas"; `EditTextReactCanvas` adds text editing behavior to rendered React code (beta). +### Build a standalone website + +Turn a code string into a deployable static site — `index.html` plus hashed `assets/`, with React, +every library the code references and compiled Tailwind bundled in. No CDN, no network at runtime. + +```js +// Node only. Ships as a separate entry so nothing here reaches your browser bundle. +import { writeStandaloneSite } from "react-code-canvas/builder"; + +await writeStandaloneSite(CODE, "./out"); +// -> out/index.html, out/assets/app-.js, out/assets/style-.css +``` + +Then deploy `out/` anywhere that serves files. Use `buildStandaloneSite` instead to get a +`path -> contents` map and handle delivery yourself. + +Requires the optional peers it actually uses: `npm i -D esbuild`, plus `tailwindcss` if your code +uses `className`. Neither is loaded otherwise. + +#### Options + +| Option | Type | Description | +| --- | --- | --- | +| `title` | `string` | `` for code that emits none of its own. A `<Helmet><title>` in the code wins. | +| `lang` | `string` | `<html lang>`. Default `'en'`. | +| `tailwind` | `boolean` | Compile Tailwind for the classes found in the code. Default `true`; only runs when the code contains `className`. | +| `css` | `string` | Extra CSS, appended after the compiled Tailwind. | +| `head` | `string` | Raw HTML appended to `<head>` — fonts, favicon, OG tags, analytics. | +| `prerender` | `boolean` | Bake the initial markup into the HTML for SEO, then hydrate. Default `true`. | +| `minify` | `boolean` | Default `true`. | +| `target` | `string` | esbuild target. Default `'es2020'`. | + +Notes: + +- **Styling all works**: inline `style` objects, in-component `<style>` tags, and Tailwind classes + are compiled into a real stylesheet. Unlike the canvas, the built site does not need the host to + provide Tailwind. +- **Prerendering is best-effort.** Code that touches `document` or `window` *during render* throws + in Node; the build warns and emits a client-only shell instead of failing. Guard with + `typeof document !== "undefined"` to keep the prerender. `useEffect` never runs during prerender, + so it is always safe. +- `<Helmet>` finally does something here — React 19 hoists its `<title>`/`<meta>` and the builder + lifts them into `<head>`, where crawlers read them. +- Machine-generated `<EditableText>` tags from an `EditTextReactCanvas` session are reversed back to + plain JSX; a deployed page does not keep click-to-edit fields. +- **recharts charts do not prerender** — measured: they render nothing server-side, fixed size or + not, and appear on hydration. The rest of the page still prerenders normally, so only the chart + area is blank in the initial HTML. + ## Notes - Code should export a default component, for example `export default function ComponentName() {}`. diff --git a/bun.lock b/bun.lock index 0118f64..b554225 100644 --- a/bun.lock +++ b/bun.lock @@ -41,8 +41,10 @@ "@testing-library/react": "^16.3.2", "@types/jest": "^30.0.0", "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.4", "@types/react-helmet": "^6.1.11", "babel-jest": "^30.4.1", + "esbuild": "^0.28.1", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", "package-size": "^2.3.0", @@ -55,11 +57,18 @@ "rollup-plugin-visualizer": "^7.0.1", "size-limit": "^13.0.2", "storybook": "^10.5.5", + "tailwindcss": "^4.3.3", }, "peerDependencies": { + "esbuild": ">=0.20", "react": "^19.0.0", "react-dom": "^19.0.0", + "tailwindcss": ">=4.0", }, + "optionalPeers": [ + "esbuild", + "tailwindcss", + ], }, }, "trustedDependencies": [ @@ -815,6 +824,8 @@ "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + "@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="], + "@types/react-helmet": ["@types/react-helmet@6.1.11", "", { "dependencies": { "@types/react": "*" } }, "sha512-0QcdGLddTERotCXo3VFlUSWO3ztraw8nZ6e3zJSgG7apwV5xt+pJUS8ewPBqT4NYB1optGLprNQzFleIY84u/g=="], "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], @@ -2403,6 +2414,8 @@ "synckit": ["synckit@0.11.13", "", { "dependencies": { "@pkgr/core": "^0.3.6" } }, "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg=="], + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + "tapable": ["tapable@1.1.3", "", {}, "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA=="], "tar-fs": ["tar-fs@3.1.3", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ=="], diff --git a/doc_site/docs/standalone-site.md b/doc_site/docs/standalone-site.md new file mode 100644 index 0000000..c218a71 --- /dev/null +++ b/doc_site/docs/standalone-site.md @@ -0,0 +1,151 @@ +--- +sidebar_position: 7 +title: Standalone site +--- + +# Build a standalone website + +The canvas renders code inside a host page. `buildStandaloneSite` takes the same code string and +turns it into a **deployable static site** — `index.html` plus hashed `assets/` — with React, every +library the code references, and compiled Tailwind bundled in. + +No CDN, no import map, no network at runtime. Copy the directory to Netlify, Vercel, S3, GitHub +Pages, or any host that serves files. + +:::info Node only +This ships as a separate entry, `react-code-canvas/builder`. It imports `esbuild`, `node:*` builtins +and `react-dom/server`, so it must never reach a browser bundle. Importing +`react-code-canvas` itself never pulls any of it in. +::: + +## Install + +```bash +npm i -D esbuild +npm i -D tailwindcss # only if your code uses className +``` + +Both are **optional peer dependencies**, loaded on demand. Code styled entirely with inline `style` +never needs `tailwindcss` at all. + +## Usage + +```js +import { writeStandaloneSite } from "react-code-canvas/builder"; + +const CODE = ` +export default function Page() { + const [n, setN] = useState(0); + return ( + <main className="flex flex-col gap-4 p-8 bg-slate-900 text-white"> + <h1 className="text-3xl font-bold">Count: {n}</h1> + <button className="rounded bg-blue-600 px-4 py-2" onClick={() => setN(n + 1)}> + increment + </button> + </main> + ); +} +`; + +await writeStandaloneSite(CODE, "./out"); +``` + +``` +out/ +├── index.html +└── assets/ + ├── app-8bf13721.js react + everything the code imports + └── style-067d3221.css compiled Tailwind +``` + +```bash +npx serve out +``` + +For full control over delivery, use `buildStandaloneSite`, which returns a `path -> contents` map and +touches no filesystem: + +```js +import { buildStandaloneSite } from "react-code-canvas/builder"; + +const files = await buildStandaloneSite(CODE); +// { 'index.html': '<!doctype html>…', 'assets/app-<hash>.js': '…', … } +``` + +## Options + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `title` | `string` | — | `<title>` for code that emits none. A `<Helmet><title>` in the code wins. | +| `lang` | `string` | `'en'` | `<html lang>`. | +| `tailwind` | `boolean` | `true` | Compile Tailwind for the classes found in the code. | +| `css` | `string` | — | Extra CSS, appended after the compiled Tailwind. | +| `head` | `string` | — | Raw HTML appended to `<head>` — fonts, favicon, OG tags, analytics. | +| `prerender` | `boolean` | `true` | Bake initial markup into the HTML, then hydrate. | +| `minify` | `boolean` | `true` | Minify both the JS and the CSS. | +| `target` | `string` | `'es2020'` | esbuild target. | + +## Styling + +All three styling approaches work, and none of them need anything from a host page: + +- **Inline `style={{…}}`** — carried into the bundle as ordinary props. +- **In-component `<style>` tags** — the CSS text is part of the bundle. This is how you express + pseudo-classes, media queries and keyframes. +- **Tailwind `className`** — compiled into a real stylesheet at build time. + +This is the one place the [Scope](./scope.md) guidance about avoiding Tailwind does not apply. That +advice is about the canvas, where classes are inert unless the host loads Tailwind. Here there is a +compile step, so they simply work. + +Class names are collected from every string and template literal in the source, which covers +conditional and template forms: + +```jsx +<p className={active ? "bg-green-500" : "bg-red-500"} /> +<p className={`p-4 ${spacing}`} /> +``` + +Only the static parts of a template literal can be seen, so a fully computed class name +(`` `bg-${color}-500` ``) will not be compiled. Write the alternatives out in full. + +## Prerendering + +By default the initial markup is rendered in Node and baked into `index.html`, then hydrated on the +client — so crawlers and a slow first paint both see real content. + +Prerendering **executes your code in Node**, where there is no DOM. It is best-effort: + +- `useEffect` never runs during prerender. Always safe. +- Touching `document` or `window` *during render* throws. The build warns and emits a client-only + shell rather than failing. The page still works; it just starts empty. + +```jsx +// prerenders +const isDark = typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches; + +// falls back to a client-only shell +const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; +``` + +Pass `prerender: false` to skip it entirely. + +`<Helmet>` is genuinely useful here: React 19 hoists its `<title>` and `<meta>` tags, and the builder +lifts them into `<head>` where crawlers read them. In the canvas, `<Helmet>` has no effect unless the +host renders a provider. + +**recharts charts do not prerender.** Measured: they render nothing server-side, whether sized +explicitly or wrapped in `ResponsiveContainer`, and appear once the client hydrates. Everything else +on the page still prerenders, so only the chart area is blank in the initial HTML — fine for a +dashboard, worth knowing if a chart is your above-the-fold content. + +## Notes + +- The code contract is the same as the canvas: `export default` a component (or call `render(...)`), + and **no `import` statements** — dependencies come from the scope. The builder validates with + [`analyzeReactCode`](./react-canvas.md) first and throws with the specific issues if the code + cannot render. +- Machine-generated `<EditableText>` tags from an `EditTextReactCanvas` session are reversed back to + plain JSX. A deployed page does not keep click-to-edit fields. +- Only what the code references is bundled. Two lucide icons cost about 2 KB, not the whole pack. +- Asset filenames are content-hashed, so a redeploy busts caches on its own. diff --git a/doc_site/docusaurus.config.ts b/doc_site/docusaurus.config.ts index 08d9bfa..df496d2 100644 --- a/doc_site/docusaurus.config.ts +++ b/doc_site/docusaurus.config.ts @@ -10,6 +10,10 @@ import type { // `react-code-canvas/package.json`. The package's `exports` map only exposes // ".", so a subpath import of package.json fails with ERR_PACKAGE_PATH_NOT_EXPORTED // on versions that ship that field. fs bypasses exports resolution entirely. +// +// The exports map now carries "./package.json", but this stays until the docs +// depend on a published version that has it -- the pin above is 5.0.0, which +// does not, and switching early would silently degrade to 'unknown'. function readCanvasVersion(): string { try { const pkgPath = path.join( diff --git a/doc_site/sidebars.ts b/doc_site/sidebars.ts index 53745f6..db06004 100644 --- a/doc_site/sidebars.ts +++ b/doc_site/sidebars.ts @@ -11,6 +11,7 @@ const sidebars: SidebarsConfig = { items: ['react-canvas', 'edit-text-react-canvas'], }, 'scope', + 'standalone-site', 'security', 'contributing', ], diff --git a/package.json b/package.json index 74604b2..ce2283d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-code-canvas", - "version": "5.0.0", + "version": "5.1.0", "packageManager": "bun@1.3.14", "sideEffects": false, "description": "A browser-based canvas for rendering React components at runtime", @@ -12,7 +12,12 @@ "types": "./dist/index.d.ts", "import": "./dist/index.esm.js", "require": "./dist/index.js" - } + }, + "./builder": { + "types": "./dist/builder/standalone.d.ts", + "import": "./dist/builder.mjs" + }, + "./package.json": "./package.json" }, "files": [ "dist", @@ -25,6 +30,8 @@ "test:coverage": "jest --coverage", "test:manual:transformer": "bun run scripts/manual/test-custom-transformer.js", "test:manual:analyzer": "bun run scripts/manual/test-analyze-react-code.js", + "test:manual:site": "bun run scripts/manual/test-standalone-site.mjs", + "demo:site": "bun run test:manual:site scripts/manual/fixtures/demo-page.jsx demo-site", "size": "bun run build && size-limit", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", @@ -52,7 +59,17 @@ }, "peerDependencies": { "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "esbuild": ">=0.20", + "tailwindcss": ">=4.0" + }, + "peerDependenciesMeta": { + "esbuild": { + "optional": true + }, + "tailwindcss": { + "optional": true + } }, "devDependencies": { "@babel/preset-env": "^7.29.7", @@ -65,7 +82,9 @@ "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-terser": "^1.0.0", "@size-limit/preset-big-lib": "^13.0.2", + "@storybook/addon-docs": "^10.5.5", "@storybook/addon-links": "^10.5.5", + "@storybook/addon-mcp": "^0.7.0", "@storybook/addon-onboarding": "^10.5.5", "@storybook/addon-webpack5-compiler-babel": "^4.0.1", "@storybook/addon-webpack5-compiler-swc": "^4.0.3", @@ -76,8 +95,10 @@ "@testing-library/react": "^16.3.2", "@types/jest": "^30.0.0", "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.4", "@types/react-helmet": "^6.1.11", "babel-jest": "^30.4.1", + "esbuild": "^0.28.1", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", "package-size": "^2.3.0", @@ -90,8 +111,7 @@ "rollup-plugin-visualizer": "^7.0.1", "size-limit": "^13.0.2", "storybook": "^10.5.5", - "@storybook/addon-mcp": "^0.7.0", - "@storybook/addon-docs": "^10.5.5" + "tailwindcss": "^4.3.3" }, "overrides": { "typescript": "^5.8.2" diff --git a/rollup.config.mjs b/rollup.config.mjs index b6ddfe1..83fd112 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -40,7 +40,7 @@ const external = [ "react-helmet-async", ]; -const config = { +const browserConfig = { input: "src/index.ts", // `dir` rather than `file`: the editor is dynamically imported (see // LazyLiveEditor), and a local dynamic import can only become its own chunk @@ -103,4 +103,55 @@ const config = { ], }; -export default config; +// The standalone site builder. Node-only, and kept out of the browser entry +// entirely: it pulls in esbuild, node:* builtins and react-dom/server, none of +// which may reach a consumer's client bundle. Published as `./builder`. +// +// ESM only, emitted as .mjs: the package has no "type" field, so a .js file +// here would be parsed as CJS and fail. The extension also makes the +// `import.meta.url` that esbuild's resolveDir depends on legal. +const builderConfig = { + input: "src/builder/standalone.ts", + output: { + dir: "dist", + format: "esm", + entryFileNames: "builder.mjs", + chunkFileNames: "chunks/builder/[name]-[hash].js", + sourcemap: false, + }, + external: (id) => + id.startsWith("node:") || + id === "esbuild" || + id === "tailwindcss" || + external.some((pkg) => id === pkg || id.startsWith(`${pkg}/`)), + plugins: [ + json(), + typescript({ + tsconfig: "./tsconfig.json", + // clean:false so this run does not wipe the cache the browser config + // above just populated -- with clean:true the two configs fight and the + // second build recompiles everything from scratch. + clean: false, + exclude: storybookExclude, + useTsconfigDeclarationDir: true, + }), + // preferBuiltins is the opposite of the browser config: node:fs and friends + // must stay external rather than being shimmed. + resolve({ + preferBuiltins: true, + extensions: [".js", ".jsx", ".ts", ".tsx", ".json"], + }), + commonjs(), + babel({ + babelHelpers: "bundled", + exclude: ["node_modules/**", ...storybookExclude], + presets: ["@babel/preset-react"], + extensions: [".js", ".jsx", ".ts", ".tsx"], + }), + terser(), + // No visualizer here: it has `open: true` and would launch a second browser + // tab on every build. + ], +}; + +export default [browserConfig, builderConfig]; diff --git a/scripts/manual/fixtures/demo-page.jsx b/scripts/manual/fixtures/demo-page.jsx new file mode 100644 index 0000000..88acc8d --- /dev/null +++ b/scripts/manual/fixtures/demo-page.jsx @@ -0,0 +1,94 @@ +// A canvas-format page for eyeballing a built site in a real browser. +// +// bun run build && bun run demo:site && npx serve demo-site +// +// Deliberately combines the things contract-level assertions cannot judge: +// hydration (the counter is in the prerendered HTML but dead until JS runs), +// a <motion.div> member expression, a recharts chart whose name also exists in +// lucide, Helmet tags that must reach <head>, an in-component <style> block, and +// Tailwind classes that only work if the compile step ran. The two bugs this +// caught -- `motion is not defined` and <LineChart> rendering as a 24x24 icon -- +// now have regression tests, but the combination is still the fastest way to see +// whether a change broke something a browser would notice. + +export default function Dashboard() { + const [count, setCount] = useState(0); + + const data = [ + { name: "Mon", v: 12 }, { name: "Tue", v: 19 }, { name: "Wed", v: 8 }, + { name: "Thu", v: 24 }, { name: "Fri", v: 17 }, { name: "Sat", v: 29 }, + ]; + + return ( + <main className="min-h-screen bg-slate-950 text-slate-100 p-8"> + <Helmet> + <title>Standalone Demo + + + + + +
+ +

Standalone site demo

+
+ +

+ Everything on this page was bundled from a single code string. No CDN, no network. + Turn off your wifi and reload — it still works. +

+ +
+
+
+ +

Interactivity

+
+

Proves hydration: this button only works after the JS loads.

+
+ + +
+
+ +
+
+ +

Animation

+
+

motion/react, bundled and tree-shaken.

+ +
+
+ +
+
+ +

recharts

+
+ + + + + + + +
+ + ); +} diff --git a/scripts/manual/test-standalone-site.mjs b/scripts/manual/test-standalone-site.mjs new file mode 100644 index 0000000..556ae7b --- /dev/null +++ b/scripts/manual/test-standalone-site.mjs @@ -0,0 +1,200 @@ +/** + * End-to-end check for the standalone site builder. + * + * Lives here rather than in the jest suite because it needs the built + * dist/builder.mjs: the builder is ESM-only and uses `import.meta.url`, which + * is a syntax error under jest's CJS runtime. The pure half is covered by + * src/builder/__tests__/entry.test.js, which does run in CI. + * + * bun run build && bun run test:manual:site + * + * Pass a file to build that instead of the built-in suite. The file holds the + * same canvas-format string ReactCanvas takes -- a default export (or a + * render(...) call) and no import statements, with dependencies coming from the + * scope: + * + * bun run test:manual:site ./MyPage.jsx + * bun run test:manual:site ./MyPage.jsx ./out + * + * The output directory is the second positional, or --out=DIR in suite mode + * where there is no file to put first: + * + * bun run test:manual:site --out=./out + */ + +import { readFile, rm } from "node:fs/promises"; +import { buildStandaloneSite, writeStandaloneSite } from "../../dist/builder.mjs"; + +const args = process.argv.slice(2); +const outFlag = args.find((a) => a.startsWith("--out="))?.slice("--out=".length); +const [fileArg, outArg] = args.filter((a) => !a.startsWith("-")); +const OUT = outFlag ?? outArg ?? "/tmp/react-code-canvas-site"; + +let failures = 0; +const check = (label, ok, detail = "") => { + console.log(`${ok ? " ok " : " FAIL"} ${label}${detail ? ` -- ${detail}` : ""}`); + if (!ok) failures++; +}; + +const rootOf = (html) => html.match(/
([\s\S]*?)<\/div>\n + + +
${body}
+ + +`; + + files["index.html"] = html; + return files; +} + +/** buildStandaloneSite, written to disk. Returns the paths written. */ +export async function writeStandaloneSite( + code: string, + outDir: string, + options: StandaloneOptions = {} +): Promise { + const files = await buildStandaloneSite(code, options); + const written: string[] = []; + + for (const [name, contents] of Object.entries(files)) { + const target = join(outDir, name); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, contents, "utf8"); + written.push(target); + } + return written; +} diff --git a/src/coreComponents/core/custom-transformer.js b/src/coreComponents/core/custom-transformer.js index 7523aa8..b78dfaf 100644 --- a/src/coreComponents/core/custom-transformer.js +++ b/src/coreComponents/core/custom-transformer.js @@ -336,7 +336,7 @@ export function transformEditableTextToJSX(code) { return { transformedCode: transformedCode, error: null }; } catch (error) { - return { transformedCode: null, error: "error" }; + return { transformedCode: null, error }; } } diff --git a/src/scopes/Scope.ts b/src/scopes/Scope.ts deleted file mode 100644 index 3f5eada..0000000 --- a/src/scopes/Scope.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { rechartsScope } from "./rechartScope"; -import { lucideScope } from "./lucidreactScope"; -import { editComponentScope } from "./editComponentScope"; -import motion, { motionHooksComponents } from "./motionScope"; -import helmetScope from "./helmetScope" - -import React, { - useState, - useEffect, - useContext, - useReducer, - useRef, - useMemo, - useCallback, -} from "react"; - -// react scope -const reactScope = { - React, - useState, - useEffect, - useContext, - useReducer, - useRef, - useMemo, - useCallback, -}; - -// Export all the components and scope needed. -// -// react-icons/fa was removed: it added ~424KB gzipped (roughly half the -// bundle) for 1611 icons that duplicate what lucide-react already covers with -// 5673. Code that referenced `Fa*` names must switch to lucide equivalents. -export const scope: any = { - ...lucideScope, - ...rechartsScope, - ...reactScope, - ...editComponentScope, - motion, // has to be named motion - - ...motionHooksComponents, // motion hooks, components - ...helmetScope, -}; diff --git a/src/scopes/__test__/lazyScope.test.js b/src/scopes/__test__/lazyScope.test.js index 6872013..d007806 100644 --- a/src/scopes/__test__/lazyScope.test.js +++ b/src/scopes/__test__/lazyScope.test.js @@ -33,6 +33,26 @@ describe('collectIdentifiers', () => { }) }) +describe('resolveScope name collisions', () => { + // Eleven names exist in both lucide and recharts. The winner used to be + // whichever dynamic import resolved last, so a warm recharts cache let the + // icon overwrite it and rendered a 24x24 icon instead of a chart. + it.each([ + 'AreaChart', 'BarChart', 'Brush', 'Cross', 'Dot', + 'Funnel', 'LineChart', 'PieChart', 'Radar', 'ScatterChart', 'Text', + ])('gives recharts %s, not the lucide icon of the same name', async (name) => { + const recharts = await import('recharts') + const scope = await resolveScope(`export default () => <${name}/>`) + expect(scope[name]).toBe(recharts[name]) + }) + + it('still resolves a non-colliding icon from lucide', async () => { + const scope = await resolveScope('export default () => ') + const camera = await import('lucide-react/dist/esm/icons/camera.js') + expect(scope.Camera).toBe(camera.default) + }) +}) + describe('iconLoaderFor', () => { it('resolves a plain icon name', () => { expect(iconLoaderFor('ChevronRight')).toBeInstanceOf(Function) diff --git a/src/scopes/__test__/scope.test.js b/src/scopes/__test__/scope.test.js deleted file mode 100644 index 8334f1d..0000000 --- a/src/scopes/__test__/scope.test.js +++ /dev/null @@ -1,58 +0,0 @@ -import { scope } from "../Scope"; - -// Test Scope -describe("scope object", () => { - test("contains React and hooks", () => { - expect(scope.React).toBeDefined(); - expect(scope.useState).toBeDefined(); - expect(scope.useEffect).toBeDefined(); - expect(scope.useContext).toBeDefined(); - expect(scope.useReducer).toBeDefined(); - expect(scope.useRef).toBeDefined(); - expect(scope.useMemo).toBeDefined(); - expect(scope.useCallback).toBeDefined(); - }); - - test("contains motion and motion hooks/components", () => { - expect(scope.motion).toBeDefined(); - expect(scope.useAnimate).toBeDefined(); - expect(scope.useScroll).toBeDefined(); - expect(scope.LazyMotion).toBeDefined(); - expect(scope.useReducedMotion).toBeDefined(); - }); - - test("contains recharts components", () => { - expect(scope.AreaChart).toBeDefined(); - expect(scope.ResponsiveContainer).toBeDefined(); - expect(scope.CartesianGrid).toBeDefined(); - expect(scope.LineChart).toBeDefined(); - expect(scope.XAxis).toBeDefined(); - }); - - test("contains lucid react components", () => { - expect(scope.Camera).toBeDefined(); - expect(scope.StarHalf).toBeDefined(); - expect(scope.Smile).toBeDefined(); - expect(scope.Landmark).toBeDefined(); - expect(scope.House).toBeDefined(); - }); - - // Fa icons are available, but only through the lazy scope -- referencing one - // fetches the pack (~424KB gzipped) on demand. This eager scope must stay - // free of them, or every consumer pays that cost whether they use it or not. - test("does NOT contain react-icons/fa components", () => { - expect(scope.FaBeer).toBeUndefined(); - expect(scope.FaHeart).toBeUndefined(); - expect(Object.keys(scope).filter((k) => /^Fa[A-Z0-9]/.test(k))).toHaveLength(0); - }); - - test("contains EditableText custom components", () => { - expect(scope.EditableText).toBeDefined(); - }); - - test("contains Helmet-async", () => { - expect(scope.Helmet).toBeDefined(); - expect(scope.HelmetProvider).toBeDefined(); - }); - -}); diff --git a/src/scopes/lazyScope.ts b/src/scopes/lazyScope.ts index ce37b6a..5b086d2 100644 --- a/src/scopes/lazyScope.ts +++ b/src/scopes/lazyScope.ts @@ -185,10 +185,24 @@ export async function resolveScope( ); } + // The namespaces are captured here and merged after the await, NOT written + // straight into `scope` from their .then. Eleven names exist in both lucide + // and recharts (AreaChart BarChart Brush Cross Dot Funnel LineChart PieChart + // Radar ScatterChart Text), and writing from inside the callbacks made the + // winner whichever import happened to resolve last: on a cold cache the ~0.7KB + // icon file beat recharts and recharts won, but once recharts was warm it + // resolved first and the icon overwrote it -- so silently rendered + // a 24x24 icon instead of a chart. Merging in a fixed order after the await + // makes the precedence the documented one: icons first, charts/motion over + // them, matching SKILL.md's "recharts owns Text/Label/Legend/Tooltip". + let rechartsMod: Record | undefined; + let motionMod: Record | undefined; + let faMod: Record | undefined; + if (needsRecharts) { pending.push( import("recharts").then((mod) => { - assignNamespace(scope, mod); + rechartsMod = mod as Record; }), ); } @@ -197,7 +211,7 @@ export async function resolveScope( pending.push( // `motion` has to keep its name -- resolves against it. import("motion/react").then((mod) => { - assignNamespace(scope, mod); + motionMod = mod as Record; }), ); } @@ -210,13 +224,18 @@ export async function resolveScope( // modules from the pack ourselves, or move the code to lucide equivalents. pending.push( import("react-icons/fa").then((mod: Record) => { - // Assign only what the code referenced, not all 1611 exports: these - // become `new Function` parameters on every evaluation. - for (const name of faNames) scope[name] = mod[name]; + faMod = mod; }), ); } await Promise.all(pending); + + if (rechartsMod) assignNamespace(scope, rechartsMod); + if (motionMod) assignNamespace(scope, motionMod); + // Assign only what the code referenced, not all 1611 exports: these become + // `new Function` parameters on every evaluation. + if (faMod) for (const name of faNames) scope[name] = faMod[name]; + return { ...scope, ...extra }; } diff --git a/src/scopes/lucidreactScope.ts b/src/scopes/lucidreactScope.ts deleted file mode 100644 index b7f39c2..0000000 --- a/src/scopes/lucidreactScope.ts +++ /dev/null @@ -1,286 +0,0 @@ -// import { -// // Icons -// Activity, -// AlertCircle, -// AlertTriangle, -// Archive, -// ArrowDown, -// ArrowLeft, -// ArrowRight, -// ArrowUp, -// BarChart, -// Bell, -// Book, -// Bookmark, -// Calendar, -// Camera, -// Check, -// CheckCircle, -// ChevronDown, -// ChevronLeft, -// ChevronRight, -// ChevronUp, -// Clock, -// Cloud, -// Code, -// Coffee, -// Command, -// Copy, -// CreditCard, -// Database, -// Download, -// Edit, -// Eye, -// EyeOff, -// File, -// FileText, -// Filter, -// Folder, -// Github, -// Globe, -// Grid, -// HardDrive, -// Hash, -// Heart, -// Home, -// Image, -// Inbox, -// Info, -// Key, -// Layers, -// Layout, -// Link, -// List, -// Lock, -// LogIn, -// LogOut, -// Mail, -// Map, -// Menu, -// MessageCircle, -// MessageSquare, -// Mic, -// Moon, -// MoreHorizontal, -// MoreVertical, -// Move, -// Music, -// Paperclip, -// Pause, -// Phone, -// Play, -// Plus, -// PlusCircle, -// Power, -// Printer, -// Radio, -// RefreshCw, -// Save, -// Search, -// Send, -// Settings, -// Share, -// Shield, -// ShoppingBag, -// ShoppingCart, -// Shuffle, -// Slack, -// Slash, -// Sliders, -// Smartphone, -// Smile, -// Speaker, -// Square, -// Star, -// Sun, -// Table, -// Tablet, -// Tag, -// Target, -// Terminal, -// Thermometer, -// ThumbsDown, -// ThumbsUp, -// Trash, -// Trash2, -// Trophy, -// Twitter, -// Upload, -// User, -// UserPlus, -// Users, -// Video, -// Voicemail, -// Volume, -// Volume1, -// Volume2, -// VolumeX, -// Watch, -// Wifi, -// X, -// XCircle, -// ZapOff, -// Zap, -// // YouTube, - -// // Core attributes and properties -// // createIcon, -// // icons, - -// // Icon variants -// IconNode, -// LucideIcon, -// LucideProps -// } from "lucide-react"; - -// // lucide-react scope -// export const lucideScope = { -// // Icons -// Activity, -// AlertCircle, -// AlertTriangle, -// Archive, -// ArrowDown, -// ArrowLeft, -// ArrowRight, -// ArrowUp, -// BarChart, -// Bell, -// Book, -// Bookmark, -// Calendar, -// Camera, -// Check, -// CheckCircle, -// ChevronDown, -// ChevronLeft, -// ChevronRight, -// ChevronUp, -// Clock, -// Cloud, -// Code, -// Coffee, -// Command, -// Copy, -// CreditCard, -// Database, -// Download, -// Edit, -// Eye, -// EyeOff, -// File, -// FileText, -// Filter, -// Folder, -// Github, -// Globe, -// Grid, -// HardDrive, -// Hash, -// Heart, -// Home, -// Image, -// Inbox, -// Info, -// Key, -// Layers, -// Layout, -// Link, -// List, -// Lock, -// LogIn, -// LogOut, -// Mail, -// Map, -// Menu, -// MessageCircle, -// MessageSquare, -// Mic, -// Moon, -// MoreHorizontal, -// MoreVertical, -// Move, -// Music, -// Paperclip, -// Pause, -// Phone, -// Play, -// Plus, -// PlusCircle, -// Power, -// Printer, -// Radio, -// RefreshCw, -// Save, -// Search, -// Send, -// Settings, -// Share, -// Shield, -// ShoppingBag, -// ShoppingCart, -// Shuffle, -// Slack, -// Slash, -// Sliders, -// Smartphone, -// Smile, -// Speaker, -// Square, -// Star, -// Sun, -// Table, -// Tablet, -// Tag, -// Target, -// Terminal, -// Thermometer, -// ThumbsDown, -// ThumbsUp, -// Trash, -// Trash2, -// Trophy, -// Twitter, -// Upload, -// User, -// UserPlus, -// Users, -// Video, -// Voicemail, -// Volume, -// Volume1, -// Volume2, -// VolumeX, -// Watch, -// Wifi, -// X, -// XCircle, -// ZapOff, -// Zap, -// // YouTube, - -// // Core attributes and properties -// // createIcon, -// // icons, - -// // Icon variants -// // IconNode, -// // LucideIcon, -// // LucideProps -// }; - -// https://www.npmjs.com/package/lucide-react -// https://lucide.dev/guide/packages/lucide-react - -import * as LucideIcons from "lucide-react"; - -// Create scope with only the actual components using type assertion -const componentNames = Object.keys(LucideIcons).filter(key => - typeof (LucideIcons as any)[key] === 'function' || typeof (LucideIcons as any)[key] === 'object' -); - -// Create scope with type assertion -export const lucideScope = componentNames.reduce((acc, name) => { - acc[name] = (LucideIcons as any)[name]; - return acc; -}, {} as Record); \ No newline at end of file diff --git a/src/scopes/motionScope.ts b/src/scopes/motionScope.ts deleted file mode 100644 index d150518..0000000 --- a/src/scopes/motionScope.ts +++ /dev/null @@ -1,9 +0,0 @@ -// https://motion.dev/docs/react - - -import * as Motion from "motion/react"; - -const { motion, ...motionHooksComponents }: any = Motion; - -export default motion; -export { motionHooksComponents }; diff --git a/src/scopes/rechartScope.ts b/src/scopes/rechartScope.ts deleted file mode 100644 index b254c2d..0000000 --- a/src/scopes/rechartScope.ts +++ /dev/null @@ -1,112 +0,0 @@ -// import { -// AreaChart, -// BarChart, -// LineChart, -// ComposedChart, -// PieChart, -// RadarChart, -// RadialBarChart, -// ScatterChart, -// FunnelChart, -// Treemap, -// ResponsiveContainer, -// Legend, -// Tooltip, -// Cell, -// Text, -// Label, -// LabelList, -// Customized, -// Area, -// Bar, -// Line, -// Scatter, -// XAxis, -// YAxis, -// ZAxis, -// Brush, -// CartesianAxis, -// CartesianGrid, -// ReferenceLine, -// ReferenceDot, -// ReferenceArea, -// ErrorBar, -// Funnel, -// Cross, -// Curve, -// Dot, -// Polygon, -// Rectangle, -// Sector, -// Pie, -// Radar, -// RadialBar, -// PolarAngleAxis, -// PolarGrid, -// PolarRadiusAxis, -// } from "recharts"; - - -// // recharts scope -// export const rechartsScope = { -// AreaChart, -// BarChart, -// LineChart, -// ComposedChart, -// PieChart, -// RadarChart, -// RadialBarChart, -// ScatterChart, -// FunnelChart, -// Treemap, -// ResponsiveContainer, -// Legend, -// Tooltip, -// Cell, -// Text, -// Label, -// LabelList, -// Customized, -// Area, -// Bar, -// Line, -// Scatter, -// XAxis, -// YAxis, -// ZAxis, -// Brush, -// CartesianAxis, -// CartesianGrid, -// ReferenceLine, -// ReferenceDot, -// ReferenceArea, -// ErrorBar, -// Funnel, -// Cross, -// Curve, -// Dot, -// Polygon, -// Rectangle, -// Sector, -// Pie, -// Radar, -// RadialBar, -// PolarAngleAxis, -// PolarGrid, -// PolarRadiusAxis, -// }; - -// https://www.npmjs.com/package/recharts - -import * as ReChartComponents from 'recharts'; - -// Filter only actual components (functions or objects) -const reComponentNames = Object.keys(ReChartComponents).filter( - key => typeof (ReChartComponents as any)[key] === 'function' || typeof (ReChartComponents as any)[key] === 'object' -); - -// Create the scope -export const rechartsScope = reComponentNames.reduce((acc, name) => { - acc[name] = (ReChartComponents as any)[name]; - return acc; -}, {} as Record); \ No newline at end of file