Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@ bundle-analysis.html

# Agent
.claude/

# manual demo output
/demo-site
/out
53 changes: 48 additions & 5 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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`.
Expand All @@ -32,19 +50,21 @@ 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)
├── .storybook/ # Storybook config (main + preview)
├── .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)
Expand All @@ -55,14 +75,37 @@ 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`).

```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
Expand Down
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<hash>.js, out/assets/style-<hash>.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` | `<title>` 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() {}`.
Expand Down
13 changes: 13 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

151 changes: 151 additions & 0 deletions doc_site/docs/standalone-site.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions doc_site/docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions doc_site/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const sidebars: SidebarsConfig = {
items: ['react-canvas', 'edit-text-react-canvas'],
},
'scope',
'standalone-site',
'security',
'contributing',
],
Expand Down
Loading