@aquiles-ai/renderize 1.8.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,2 +1,132 @@
1
+ <div align="center">
2
+
1
3
  # Renderize
4
+
5
+ <img src="https://res.cloudinary.com/dmtomxyvm/image/upload/v1772919461/renderize_z4qlyx.png" alt="Renderize" width="800"/>
6
+
2
7
  Drop-in sandbox component that executes AI-generated React code with zero configuration.
8
+
9
+ ```tsx
10
+ <Renderize code={llmGeneratedCode} />
11
+ ```
12
+
13
+ </div>
14
+
15
+ ## How it works
16
+
17
+ Renderize injects LLM-generated code into a `srcdoc` iframe that comes pre-loaded with React 18, Tailwind CSS, Babel standalone, and a curated set of UI libraries. The iframe runs without `allow-same-origin`, so it can't access the parent page's cookies, storage, or DOM.
18
+
19
+ A built-in fetch proxy bridges the null-origin restriction: the iframe posts fetch requests to the parent window, which executes them and sends the response back. This means AI-generated code can call external APIs without any CORS configuration.
20
+
21
+ ```
22
+ Parent window
23
+
24
+ ├── <Renderize /> mounts the iframe, owns the message bus
25
+ │ ├── fetch proxy relays fetch calls from iframe → real network
26
+ │ └── error forwarding surfaces runtime errors via onError()
27
+
28
+ └── srcdoc iframe (sandboxed)
29
+ ├── React 18 + ReactDOM
30
+ ├── Tailwind CSS (CDN)
31
+ ├── Babel standalone transpiles JSX + modern JS at runtime
32
+ ├── importmap resolves lucide-react, @radix-ui/*, etc.
33
+ └── App() your LLM-generated component
34
+ ```
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ npm i @aquiles-ai/renderize
40
+ # or
41
+ pnpm add @aquiles-ai/renderize
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ ```tsx
47
+ import { Renderize } from "@aquiles-ai/renderize";
48
+
49
+ export default function Playground() {
50
+ const code = `
51
+ function App() {
52
+ const [count, setCount] = useState(0);
53
+ return (
54
+ <div className="flex flex-col items-center gap-4 p-8">
55
+ <h1 className="text-2xl font-bold">{count}</h1>
56
+ <button
57
+ onClick={() => setCount(c => c + 1)}
58
+ className="px-4 py-2 bg-blue-600 text-white rounded"
59
+ >
60
+ Increment
61
+ </button>
62
+ </div>
63
+ );
64
+ }
65
+ `;
66
+
67
+ return <Renderize code={code} height={400} />;
68
+ }
69
+ ```
70
+
71
+ ## Props
72
+
73
+ | Prop | Type | Default | Description |
74
+ |------|------|---------|-------------|
75
+ | `code` | `string` | | React code generated by the LLM. Must define a function component named `App`. |
76
+ | `height` | `string \| number` | `"100%"` | Height of the sandbox iframe. |
77
+ | `width` | `string \| number` | `"100%"` | Width of the sandbox iframe. |
78
+ | `className` | `string` | | Class name for the wrapper `<div>`. |
79
+ | `style` | `React.CSSProperties` | | Inline styles for the wrapper `<div>`. |
80
+ | `onError` | `(error: string) => void` | | Called when the sandbox encounters a runtime error. |
81
+
82
+ ## Available libraries
83
+
84
+ The sandbox importmap includes the following packages. Imports from any other module are stripped by `sanitizeCode`.
85
+
86
+ | Package | Notes |
87
+ |---------|-------|
88
+ | `react` | v18, hooks already in global scope |
89
+ | `react-dom` | v18 |
90
+ | `lucide-react` | Icon library |
91
+ | `clsx` | Class name utility |
92
+ | `tailwind-merge` | Tailwind class merging |
93
+ | `class-variance-authority` | CVA, variant-based styling |
94
+ | `@radix-ui/react-*` | Full Radix UI primitives suite |
95
+
96
+ React hooks (`useState`, `useEffect`, `useRef`, `useCallback`, `useMemo`, `useReducer`, `useContext`, `createContext`, `forwardRef`, `Fragment`) are already imported and available in global scope. The LLM does not need to import them.
97
+
98
+ ## Requirements for LLM-generated code
99
+
100
+ The component name must be `App`. The template calls `React.createElement(App)` directly, so:
101
+
102
+ ```tsx
103
+ // Correct
104
+ function App() { ... }
105
+
106
+ // Also correct (sanitizeCode will fix these automatically)
107
+ export default function App() { ... }
108
+ export default function Dashboard() { ... }
109
+ function MyComponent() { ... } // if it's the only PascalCase component
110
+ ```
111
+
112
+ ## Sandbox security
113
+
114
+ The iframe uses this `sandbox` attribute:
115
+
116
+ ```
117
+ allow-scripts allow-forms allow-modals allow-popups allow-downloads
118
+ ```
119
+
120
+ `allow-same-origin` is intentionally omitted. This means:
121
+
122
+ - No access to `localStorage` or `sessionStorage`
123
+ - No access to `document.cookie`
124
+ - No access to `indexedDB`
125
+ - No access to the parent page's DOM
126
+
127
+ The fetch proxy is the only bridge between the iframe and the outside world.
128
+
129
+
130
+ ## License
131
+
132
+ Apache 2.0
package/dist/index.cjs CHANGED
@@ -83,6 +83,28 @@ function buildTemplate(code) {
83
83
  <style>
84
84
  * { box-sizing: border-box; }
85
85
  body { margin: 0; padding: 0; }
86
+
87
+ /* Scrollbar personalizada: fina y semi-transparente */
88
+ ::-webkit-scrollbar {
89
+ width: 6px;
90
+ height: 6px;
91
+ }
92
+ ::-webkit-scrollbar-track {
93
+ background: transparent;
94
+ }
95
+ ::-webkit-scrollbar-thumb {
96
+ background: rgba(255, 255, 255, 0.35);
97
+ border-radius: 999px;
98
+ transition: background 0.2s ease;
99
+ }
100
+ ::-webkit-scrollbar-thumb:hover {
101
+ background: rgba(255, 255, 255, 0.55);
102
+ }
103
+ /* Firefox */
104
+ * {
105
+ scrollbar-width: thin;
106
+ scrollbar-color: rgba(255, 255, 255, 0.35) transparent;
107
+ }
86
108
  </style>
87
109
  </head>
88
110
  <body>
@@ -95,7 +117,7 @@ function buildTemplate(code) {
95
117
  // the parent has a real origin and can make fetch calls freely.
96
118
  window.fetch = function(url, options = {}) {
97
119
  return new Promise((resolve, reject) => {
98
- const id = crypto.randomUUID();
120
+ const id = Math.random().toString(36).slice(2) + Date.now().toString(36);
99
121
 
100
122
  // Serialize body \u2014 postMessage can't transfer Request objects
101
123
  const serializedOptions = {
@@ -166,6 +188,178 @@ function buildTemplate(code) {
166
188
  </html>`;
167
189
  }
168
190
 
191
+ // src/sanitize.ts
192
+ var TEMPLATE_PROVIDED_MODULES = /* @__PURE__ */ new Set([
193
+ "react",
194
+ "react-dom",
195
+ "react-dom/client",
196
+ "react/jsx-runtime"
197
+ ]);
198
+ var TEMPLATE_PROVIDED_NAMES = /* @__PURE__ */ new Set([
199
+ "React",
200
+ "useState",
201
+ "useEffect",
202
+ "useRef",
203
+ "useCallback",
204
+ "useMemo",
205
+ "useReducer",
206
+ "useContext",
207
+ "createContext",
208
+ "forwardRef",
209
+ "Fragment",
210
+ "createRoot"
211
+ ]);
212
+ var IMPORTMAP_MODULES = /* @__PURE__ */ new Set([
213
+ "react",
214
+ "react/jsx-runtime",
215
+ "react-dom",
216
+ "react-dom/client",
217
+ "lucide-react",
218
+ "clsx",
219
+ "class-variance-authority",
220
+ "tailwind-merge",
221
+ "@radix-ui/react-accordion",
222
+ "@radix-ui/react-alert-dialog",
223
+ "@radix-ui/react-avatar",
224
+ "@radix-ui/react-checkbox",
225
+ "@radix-ui/react-collapsible",
226
+ "@radix-ui/react-context-menu",
227
+ "@radix-ui/react-dialog",
228
+ "@radix-ui/react-dropdown-menu",
229
+ "@radix-ui/react-hover-card",
230
+ "@radix-ui/react-label",
231
+ "@radix-ui/react-menubar",
232
+ "@radix-ui/react-navigation-menu",
233
+ "@radix-ui/react-popover",
234
+ "@radix-ui/react-progress",
235
+ "@radix-ui/react-radio-group",
236
+ "@radix-ui/react-scroll-area",
237
+ "@radix-ui/react-select",
238
+ "@radix-ui/react-separator",
239
+ "@radix-ui/react-slider",
240
+ "@radix-ui/react-slot",
241
+ "@radix-ui/react-switch",
242
+ "@radix-ui/react-tabs",
243
+ "@radix-ui/react-toast",
244
+ "@radix-ui/react-toggle",
245
+ "@radix-ui/react-toggle-group",
246
+ "@radix-ui/react-toolbar",
247
+ "@radix-ui/react-tooltip"
248
+ ]);
249
+ function findMatchingBrace(code, openIndex) {
250
+ let depth = 0;
251
+ let inSingle = false;
252
+ let inDouble = false;
253
+ let inTemplate = 0;
254
+ for (let i = openIndex; i < code.length; i++) {
255
+ const ch = code[i];
256
+ const prev = i > 0 ? code[i - 1] : "";
257
+ if (prev === "\\") continue;
258
+ if (!inDouble && !inTemplate && ch === "'") {
259
+ inSingle = !inSingle;
260
+ continue;
261
+ }
262
+ if (!inSingle && !inTemplate && ch === '"') {
263
+ inDouble = !inDouble;
264
+ continue;
265
+ }
266
+ if (!inSingle && !inDouble && ch === "`") {
267
+ inTemplate = inTemplate ? inTemplate - 1 : inTemplate + 1;
268
+ continue;
269
+ }
270
+ if (inSingle || inDouble || inTemplate) continue;
271
+ if (ch === "{") depth++;
272
+ else if (ch === "}") {
273
+ depth--;
274
+ if (depth === 0) return i + 1;
275
+ }
276
+ }
277
+ return -1;
278
+ }
279
+ function stripTypeScriptDeclarations(code) {
280
+ const blockKeywords = /(?:export\s+)?(?:interface|enum)\s+\w[\w<,\s>]*\s*\{/g;
281
+ let match;
282
+ while ((match = blockKeywords.exec(code)) !== null) {
283
+ const openBrace = code.indexOf("{", match.index + match[0].length - 1);
284
+ if (openBrace === -1) continue;
285
+ const end = findMatchingBrace(code, openBrace);
286
+ if (end === -1) continue;
287
+ const tail = code[end] === ";" ? end + 1 : end;
288
+ code = code.slice(0, match.index) + code.slice(tail);
289
+ blockKeywords.lastIndex = match.index;
290
+ }
291
+ code = code.replace(
292
+ /^(?:export\s+)?type\s+\w[\w<,\s>]*\s*=\s*/gm,
293
+ (_header, offset, fullCode) => {
294
+ const rest = fullCode.slice(offset + _header.length);
295
+ const trimmed = rest.trimStart();
296
+ if (trimmed.startsWith("{")) {
297
+ const relOpen = rest.indexOf("{");
298
+ const end = findMatchingBrace(rest, relOpen);
299
+ if (end !== -1) {
300
+ code = fullCode.slice(0, offset) + fullCode.slice(offset + _header.length + end);
301
+ }
302
+ }
303
+ return "";
304
+ }
305
+ );
306
+ return code;
307
+ }
308
+ function stripAsAssertions(code) {
309
+ return code.replace(
310
+ /(?<![{,]\s*\w+)\s+as\s+[A-Z]\w*(?:<[^>]*>)?(?:\[\])?(?=\s*[),;}\n])/g,
311
+ ""
312
+ );
313
+ }
314
+ function detectMainComponentName(code) {
315
+ const fnExport = code.match(/export\s+default\s+function\s+([A-Z]\w*)/);
316
+ if (fnExport && fnExport[1] !== "App") return fnExport[1];
317
+ const constExport = code.match(/export\s+default\s+(?:const|let)\s+([A-Z]\w*)/);
318
+ if (constExport && constExport[1] !== "App") return constExport[1];
319
+ const allDefs = [
320
+ ...code.matchAll(
321
+ /^(?:function|const|let)\s+([A-Z]\w*)\s*(?:=\s*(?:\(|React\.memo\()|[\(<(])/gm
322
+ )
323
+ ].map((m) => m[1]).filter((n) => n !== "App");
324
+ if (allDefs.length === 1) return allDefs[0];
325
+ return null;
326
+ }
327
+ function sanitizeCode(raw) {
328
+ let code = raw;
329
+ code = code.replace(/^```[a-zA-Z]*\r?\n?/, "").replace(/\r?\n?```\s*$/, "");
330
+ code = code.replace(/\\n/g, "\n").replace(/\\t/g, " ").replace(/\\r/g, "\r");
331
+ code = code.replace(/^\s*['"]use (client|server)['"]\s*;?\s*\n?/gm, "");
332
+ code = stripTypeScriptDeclarations(code);
333
+ code = stripAsAssertions(code);
334
+ const originalName = detectMainComponentName(code);
335
+ if (originalName) {
336
+ code = code.replace(new RegExp(`\\b${originalName}\\b`, "g"), "App");
337
+ }
338
+ code = code.replace(/\bexport\s+default\s+(function\s+App\b)/, "$1");
339
+ code = code.replace(/\bexport\s+default\s+((?:const|let)\s+App\b)/, "$1");
340
+ code = code.replace(/\bexport\s+(function\s+App\b)/, "$1");
341
+ code = code.replace(/\bexport\s+((?:const|let)\s+App\b)/, "$1");
342
+ const importLineRegex = /^import\s+(?:type\s+)?(?:[^"'\n]+\s+from\s+)?["']([^"']+)["'];?\s*$/gm;
343
+ code = code.replace(importLineRegex, (line, modulePath) => {
344
+ if (TEMPLATE_PROVIDED_MODULES.has(modulePath)) return "";
345
+ if (!IMPORTMAP_MODULES.has(modulePath)) return "";
346
+ const namedMatch = line.match(/\{([^}]+)\}/);
347
+ if (namedMatch) {
348
+ const names = namedMatch[1].split(",").map((n) => n.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
349
+ if (names.length > 0 && names.every((n) => TEMPLATE_PROVIDED_NAMES.has(n))) {
350
+ return "";
351
+ }
352
+ }
353
+ return line;
354
+ });
355
+ code = code.replace(
356
+ /^(?:const|let|var)\s+[\w\s,{}]+\s*=\s*require\s*\(["'][^"']+["']\)\s*;?\s*$/gm,
357
+ ""
358
+ );
359
+ code = code.replace(/\n{3,}/g, "\n\n");
360
+ return code.trim();
361
+ }
362
+
169
363
  // src/Renderize.tsx
170
364
  var import_jsx_runtime = require("react/jsx-runtime");
171
365
  function Renderize({
@@ -180,7 +374,7 @@ function Renderize({
180
374
  const [srcDoc, setSrcDoc] = (0, import_react.useState)(null);
181
375
  (0, import_react.useEffect)(() => {
182
376
  if (!code?.trim()) return;
183
- setSrcDoc(buildTemplate(code));
377
+ setSrcDoc(buildTemplate(sanitizeCode(code)));
184
378
  }, [code]);
185
379
  (0, import_react.useEffect)(() => {
186
380
  const handler = async (event) => {
package/dist/index.js CHANGED
@@ -57,6 +57,28 @@ function buildTemplate(code) {
57
57
  <style>
58
58
  * { box-sizing: border-box; }
59
59
  body { margin: 0; padding: 0; }
60
+
61
+ /* Scrollbar personalizada: fina y semi-transparente */
62
+ ::-webkit-scrollbar {
63
+ width: 6px;
64
+ height: 6px;
65
+ }
66
+ ::-webkit-scrollbar-track {
67
+ background: transparent;
68
+ }
69
+ ::-webkit-scrollbar-thumb {
70
+ background: rgba(255, 255, 255, 0.35);
71
+ border-radius: 999px;
72
+ transition: background 0.2s ease;
73
+ }
74
+ ::-webkit-scrollbar-thumb:hover {
75
+ background: rgba(255, 255, 255, 0.55);
76
+ }
77
+ /* Firefox */
78
+ * {
79
+ scrollbar-width: thin;
80
+ scrollbar-color: rgba(255, 255, 255, 0.35) transparent;
81
+ }
60
82
  </style>
61
83
  </head>
62
84
  <body>
@@ -69,7 +91,7 @@ function buildTemplate(code) {
69
91
  // the parent has a real origin and can make fetch calls freely.
70
92
  window.fetch = function(url, options = {}) {
71
93
  return new Promise((resolve, reject) => {
72
- const id = crypto.randomUUID();
94
+ const id = Math.random().toString(36).slice(2) + Date.now().toString(36);
73
95
 
74
96
  // Serialize body \u2014 postMessage can't transfer Request objects
75
97
  const serializedOptions = {
@@ -140,6 +162,178 @@ function buildTemplate(code) {
140
162
  </html>`;
141
163
  }
142
164
 
165
+ // src/sanitize.ts
166
+ var TEMPLATE_PROVIDED_MODULES = /* @__PURE__ */ new Set([
167
+ "react",
168
+ "react-dom",
169
+ "react-dom/client",
170
+ "react/jsx-runtime"
171
+ ]);
172
+ var TEMPLATE_PROVIDED_NAMES = /* @__PURE__ */ new Set([
173
+ "React",
174
+ "useState",
175
+ "useEffect",
176
+ "useRef",
177
+ "useCallback",
178
+ "useMemo",
179
+ "useReducer",
180
+ "useContext",
181
+ "createContext",
182
+ "forwardRef",
183
+ "Fragment",
184
+ "createRoot"
185
+ ]);
186
+ var IMPORTMAP_MODULES = /* @__PURE__ */ new Set([
187
+ "react",
188
+ "react/jsx-runtime",
189
+ "react-dom",
190
+ "react-dom/client",
191
+ "lucide-react",
192
+ "clsx",
193
+ "class-variance-authority",
194
+ "tailwind-merge",
195
+ "@radix-ui/react-accordion",
196
+ "@radix-ui/react-alert-dialog",
197
+ "@radix-ui/react-avatar",
198
+ "@radix-ui/react-checkbox",
199
+ "@radix-ui/react-collapsible",
200
+ "@radix-ui/react-context-menu",
201
+ "@radix-ui/react-dialog",
202
+ "@radix-ui/react-dropdown-menu",
203
+ "@radix-ui/react-hover-card",
204
+ "@radix-ui/react-label",
205
+ "@radix-ui/react-menubar",
206
+ "@radix-ui/react-navigation-menu",
207
+ "@radix-ui/react-popover",
208
+ "@radix-ui/react-progress",
209
+ "@radix-ui/react-radio-group",
210
+ "@radix-ui/react-scroll-area",
211
+ "@radix-ui/react-select",
212
+ "@radix-ui/react-separator",
213
+ "@radix-ui/react-slider",
214
+ "@radix-ui/react-slot",
215
+ "@radix-ui/react-switch",
216
+ "@radix-ui/react-tabs",
217
+ "@radix-ui/react-toast",
218
+ "@radix-ui/react-toggle",
219
+ "@radix-ui/react-toggle-group",
220
+ "@radix-ui/react-toolbar",
221
+ "@radix-ui/react-tooltip"
222
+ ]);
223
+ function findMatchingBrace(code, openIndex) {
224
+ let depth = 0;
225
+ let inSingle = false;
226
+ let inDouble = false;
227
+ let inTemplate = 0;
228
+ for (let i = openIndex; i < code.length; i++) {
229
+ const ch = code[i];
230
+ const prev = i > 0 ? code[i - 1] : "";
231
+ if (prev === "\\") continue;
232
+ if (!inDouble && !inTemplate && ch === "'") {
233
+ inSingle = !inSingle;
234
+ continue;
235
+ }
236
+ if (!inSingle && !inTemplate && ch === '"') {
237
+ inDouble = !inDouble;
238
+ continue;
239
+ }
240
+ if (!inSingle && !inDouble && ch === "`") {
241
+ inTemplate = inTemplate ? inTemplate - 1 : inTemplate + 1;
242
+ continue;
243
+ }
244
+ if (inSingle || inDouble || inTemplate) continue;
245
+ if (ch === "{") depth++;
246
+ else if (ch === "}") {
247
+ depth--;
248
+ if (depth === 0) return i + 1;
249
+ }
250
+ }
251
+ return -1;
252
+ }
253
+ function stripTypeScriptDeclarations(code) {
254
+ const blockKeywords = /(?:export\s+)?(?:interface|enum)\s+\w[\w<,\s>]*\s*\{/g;
255
+ let match;
256
+ while ((match = blockKeywords.exec(code)) !== null) {
257
+ const openBrace = code.indexOf("{", match.index + match[0].length - 1);
258
+ if (openBrace === -1) continue;
259
+ const end = findMatchingBrace(code, openBrace);
260
+ if (end === -1) continue;
261
+ const tail = code[end] === ";" ? end + 1 : end;
262
+ code = code.slice(0, match.index) + code.slice(tail);
263
+ blockKeywords.lastIndex = match.index;
264
+ }
265
+ code = code.replace(
266
+ /^(?:export\s+)?type\s+\w[\w<,\s>]*\s*=\s*/gm,
267
+ (_header, offset, fullCode) => {
268
+ const rest = fullCode.slice(offset + _header.length);
269
+ const trimmed = rest.trimStart();
270
+ if (trimmed.startsWith("{")) {
271
+ const relOpen = rest.indexOf("{");
272
+ const end = findMatchingBrace(rest, relOpen);
273
+ if (end !== -1) {
274
+ code = fullCode.slice(0, offset) + fullCode.slice(offset + _header.length + end);
275
+ }
276
+ }
277
+ return "";
278
+ }
279
+ );
280
+ return code;
281
+ }
282
+ function stripAsAssertions(code) {
283
+ return code.replace(
284
+ /(?<![{,]\s*\w+)\s+as\s+[A-Z]\w*(?:<[^>]*>)?(?:\[\])?(?=\s*[),;}\n])/g,
285
+ ""
286
+ );
287
+ }
288
+ function detectMainComponentName(code) {
289
+ const fnExport = code.match(/export\s+default\s+function\s+([A-Z]\w*)/);
290
+ if (fnExport && fnExport[1] !== "App") return fnExport[1];
291
+ const constExport = code.match(/export\s+default\s+(?:const|let)\s+([A-Z]\w*)/);
292
+ if (constExport && constExport[1] !== "App") return constExport[1];
293
+ const allDefs = [
294
+ ...code.matchAll(
295
+ /^(?:function|const|let)\s+([A-Z]\w*)\s*(?:=\s*(?:\(|React\.memo\()|[\(<(])/gm
296
+ )
297
+ ].map((m) => m[1]).filter((n) => n !== "App");
298
+ if (allDefs.length === 1) return allDefs[0];
299
+ return null;
300
+ }
301
+ function sanitizeCode(raw) {
302
+ let code = raw;
303
+ code = code.replace(/^```[a-zA-Z]*\r?\n?/, "").replace(/\r?\n?```\s*$/, "");
304
+ code = code.replace(/\\n/g, "\n").replace(/\\t/g, " ").replace(/\\r/g, "\r");
305
+ code = code.replace(/^\s*['"]use (client|server)['"]\s*;?\s*\n?/gm, "");
306
+ code = stripTypeScriptDeclarations(code);
307
+ code = stripAsAssertions(code);
308
+ const originalName = detectMainComponentName(code);
309
+ if (originalName) {
310
+ code = code.replace(new RegExp(`\\b${originalName}\\b`, "g"), "App");
311
+ }
312
+ code = code.replace(/\bexport\s+default\s+(function\s+App\b)/, "$1");
313
+ code = code.replace(/\bexport\s+default\s+((?:const|let)\s+App\b)/, "$1");
314
+ code = code.replace(/\bexport\s+(function\s+App\b)/, "$1");
315
+ code = code.replace(/\bexport\s+((?:const|let)\s+App\b)/, "$1");
316
+ const importLineRegex = /^import\s+(?:type\s+)?(?:[^"'\n]+\s+from\s+)?["']([^"']+)["'];?\s*$/gm;
317
+ code = code.replace(importLineRegex, (line, modulePath) => {
318
+ if (TEMPLATE_PROVIDED_MODULES.has(modulePath)) return "";
319
+ if (!IMPORTMAP_MODULES.has(modulePath)) return "";
320
+ const namedMatch = line.match(/\{([^}]+)\}/);
321
+ if (namedMatch) {
322
+ const names = namedMatch[1].split(",").map((n) => n.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
323
+ if (names.length > 0 && names.every((n) => TEMPLATE_PROVIDED_NAMES.has(n))) {
324
+ return "";
325
+ }
326
+ }
327
+ return line;
328
+ });
329
+ code = code.replace(
330
+ /^(?:const|let|var)\s+[\w\s,{}]+\s*=\s*require\s*\(["'][^"']+["']\)\s*;?\s*$/gm,
331
+ ""
332
+ );
333
+ code = code.replace(/\n{3,}/g, "\n\n");
334
+ return code.trim();
335
+ }
336
+
143
337
  // src/Renderize.tsx
144
338
  import { jsx } from "react/jsx-runtime";
145
339
  function Renderize({
@@ -154,7 +348,7 @@ function Renderize({
154
348
  const [srcDoc, setSrcDoc] = useState(null);
155
349
  useEffect(() => {
156
350
  if (!code?.trim()) return;
157
- setSrcDoc(buildTemplate(code));
351
+ setSrcDoc(buildTemplate(sanitizeCode(code)));
158
352
  }, [code]);
159
353
  useEffect(() => {
160
354
  const handler = async (event) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aquiles-ai/renderize",
3
- "version": "1.8.0",
3
+ "version": "2.0.0",
4
4
  "description": "Drop-in sandbox component that executes AI-generated React code with zero configuration.",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
package/src/Renderize.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  /// <reference lib="dom" />
2
2
  import React, { useEffect, useRef, useState } from "react";
3
3
  import { buildTemplate } from "./template.js";
4
+ import { sanitizeCode } from "./sanitize.js";
4
5
 
5
6
  export interface RenderizeProps {
6
7
  /** React code generated by the LLM. Must define a function component named App. */
@@ -30,7 +31,7 @@ export function Renderize({
30
31
 
31
32
  useEffect(() => {
32
33
  if (!code?.trim()) return;
33
- setSrcDoc(buildTemplate(code));
34
+ setSrcDoc(buildTemplate(sanitizeCode(code)));
34
35
  }, [code]);
35
36
 
36
37
  // Central message handler — handles both fetch proxying and error forwarding
@@ -0,0 +1,297 @@
1
+ // ── Constants ────────────────────────────────────────────────────────────────
2
+
3
+ /**
4
+ * Modules already injected by the sandbox template (react, react-dom, etc.).
5
+ * Imports from these are always stripped.
6
+ */
7
+ const TEMPLATE_PROVIDED_MODULES = new Set([
8
+ "react",
9
+ "react-dom",
10
+ "react-dom/client",
11
+ "react/jsx-runtime",
12
+ ]);
13
+
14
+ /**
15
+ * Named bindings already in scope thanks to the template's explicit imports.
16
+ * If an import only pulls names from this set, the whole line is stripped.
17
+ */
18
+ const TEMPLATE_PROVIDED_NAMES = new Set([
19
+ "React",
20
+ "useState",
21
+ "useEffect",
22
+ "useRef",
23
+ "useCallback",
24
+ "useMemo",
25
+ "useReducer",
26
+ "useContext",
27
+ "createContext",
28
+ "forwardRef",
29
+ "Fragment",
30
+ "createRoot",
31
+ ]);
32
+
33
+ /**
34
+ * Every module present in the template's importmap.
35
+ * Imports from modules NOT in this set are stripped (they'd cause a network
36
+ * error or a module-resolution failure inside the srcdoc iframe).
37
+ */
38
+ const IMPORTMAP_MODULES = new Set([
39
+ "react",
40
+ "react/jsx-runtime",
41
+ "react-dom",
42
+ "react-dom/client",
43
+ "lucide-react",
44
+ "clsx",
45
+ "class-variance-authority",
46
+ "tailwind-merge",
47
+ "@radix-ui/react-accordion",
48
+ "@radix-ui/react-alert-dialog",
49
+ "@radix-ui/react-avatar",
50
+ "@radix-ui/react-checkbox",
51
+ "@radix-ui/react-collapsible",
52
+ "@radix-ui/react-context-menu",
53
+ "@radix-ui/react-dialog",
54
+ "@radix-ui/react-dropdown-menu",
55
+ "@radix-ui/react-hover-card",
56
+ "@radix-ui/react-label",
57
+ "@radix-ui/react-menubar",
58
+ "@radix-ui/react-navigation-menu",
59
+ "@radix-ui/react-popover",
60
+ "@radix-ui/react-progress",
61
+ "@radix-ui/react-radio-group",
62
+ "@radix-ui/react-scroll-area",
63
+ "@radix-ui/react-select",
64
+ "@radix-ui/react-separator",
65
+ "@radix-ui/react-slider",
66
+ "@radix-ui/react-slot",
67
+ "@radix-ui/react-switch",
68
+ "@radix-ui/react-tabs",
69
+ "@radix-ui/react-toast",
70
+ "@radix-ui/react-toggle",
71
+ "@radix-ui/react-toggle-group",
72
+ "@radix-ui/react-toolbar",
73
+ "@radix-ui/react-tooltip",
74
+ ]);
75
+
76
+ // ── Helpers ───────────────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Given a string and the index of an opening brace, returns the index
80
+ * immediately AFTER the matching closing brace (or -1 if not found).
81
+ * Handles nesting and ignores braces inside string literals.
82
+ */
83
+ function findMatchingBrace(code: string, openIndex: number): number {
84
+ let depth = 0;
85
+ let inSingle = false;
86
+ let inDouble = false;
87
+ let inTemplate = 0;
88
+
89
+ for (let i = openIndex; i < code.length; i++) {
90
+ const ch = code[i];
91
+ const prev = i > 0 ? code[i - 1] : "";
92
+
93
+ if (prev === "\\") continue; // escaped — skip
94
+
95
+ if (!inDouble && !inTemplate && ch === "'") { inSingle = !inSingle; continue; }
96
+ if (!inSingle && !inTemplate && ch === '"') { inDouble = !inDouble; continue; }
97
+ if (!inSingle && !inDouble && ch === "`") {
98
+ inTemplate = inTemplate ? inTemplate - 1 : inTemplate + 1;
99
+ continue;
100
+ }
101
+ if (inSingle || inDouble || inTemplate) continue;
102
+
103
+ if (ch === "{") depth++;
104
+ else if (ch === "}") {
105
+ depth--;
106
+ if (depth === 0) return i + 1;
107
+ }
108
+ }
109
+ return -1;
110
+ }
111
+
112
+ /**
113
+ * Strips top-level TypeScript block declarations (interface / enum)
114
+ * that Babel standalone cannot parse without @babel/preset-typescript.
115
+ * Also strips `type X = ...` aliases (block or inline).
116
+ */
117
+ function stripTypeScriptDeclarations(code: string): string {
118
+ // ── interface Foo { ... } and enum Foo { ... } ─────────────────────────
119
+ const blockKeywords = /(?:export\s+)?(?:interface|enum)\s+\w[\w<,\s>]*\s*\{/g;
120
+ let match: RegExpExecArray | null;
121
+
122
+ while ((match = blockKeywords.exec(code)) !== null) {
123
+ const openBrace = code.indexOf("{", match.index + match[0].length - 1);
124
+ if (openBrace === -1) continue;
125
+ const end = findMatchingBrace(code, openBrace);
126
+ if (end === -1) continue;
127
+ const tail = code[end] === ";" ? end + 1 : end;
128
+ code = code.slice(0, match.index) + code.slice(tail);
129
+ blockKeywords.lastIndex = match.index;
130
+ }
131
+
132
+ // ── type Foo = { ... } or type Foo = string | number; ──────────────────
133
+ // Strip the header and then either the brace block or the rest of the line.
134
+ code = code.replace(
135
+ /^(?:export\s+)?type\s+\w[\w<,\s>]*\s*=\s*/gm,
136
+ (_header, offset, fullCode) => {
137
+ const rest = fullCode.slice(offset + _header.length);
138
+ const trimmed = rest.trimStart();
139
+ if (trimmed.startsWith("{")) {
140
+ const relOpen = rest.indexOf("{");
141
+ const end = findMatchingBrace(rest, relOpen);
142
+ if (end !== -1) {
143
+ code = fullCode.slice(0, offset) + fullCode.slice(offset + _header.length + end);
144
+ }
145
+ }
146
+ // For inline types, removing the header is enough; the value
147
+ // (e.g. "string | number;\n") becomes a no-op expression statement
148
+ // which Babel tolerates, so we don't need to strip it.
149
+ return "";
150
+ }
151
+ );
152
+
153
+ return code;
154
+ }
155
+
156
+ /**
157
+ * Strips TypeScript `as` type assertions from expressions.
158
+ * Carefully avoids import/export aliases like `import { x as y }`.
159
+ *
160
+ * (value as string) → (value)
161
+ * setState(count as number) → setState(count)
162
+ * const x = foo as Bar; → const x = foo;
163
+ *
164
+ * NOT touched:
165
+ * import { foo as bar } from "..."
166
+ * export { baz as default }
167
+ */
168
+ function stripAsAssertions(code: string): string {
169
+ return code.replace(
170
+ /(?<![{,]\s*\w+)\s+as\s+[A-Z]\w*(?:<[^>]*>)?(?:\[\])?(?=\s*[),;}\n])/g,
171
+ ""
172
+ );
173
+ }
174
+
175
+ /**
176
+ * Detects the name of the main React component the LLM defined, if it is not
177
+ * already "App". Returns null when renaming is not necessary.
178
+ *
179
+ * Priority order:
180
+ * 1. export default function X → X
181
+ * 2. export default const/let X = ... → X
182
+ * 3. Only PascalCase function/const visible at the top level → X
183
+ */
184
+ function detectMainComponentName(code: string): string | null {
185
+ const fnExport = code.match(/export\s+default\s+function\s+([A-Z]\w*)/);
186
+ // @ts-ignore
187
+ if (fnExport && fnExport[1] !== "App") return fnExport[1];
188
+
189
+ const constExport = code.match(/export\s+default\s+(?:const|let)\s+([A-Z]\w*)/);
190
+ // @ts-ignore
191
+ if (constExport && constExport[1] !== "App") return constExport[1];
192
+
193
+ // Fallback: single PascalCase function/const that is not App
194
+ const allDefs = [
195
+ ...code.matchAll(
196
+ /^(?:function|const|let)\s+([A-Z]\w*)\s*(?:=\s*(?:\(|React\.memo\()|[\(<(])/gm
197
+ ),
198
+ ].map((m) => m[1]).filter((n) => n !== "App");
199
+
200
+ // @ts-ignore
201
+ if (allDefs.length === 1) return allDefs[0];
202
+
203
+ return null;
204
+ }
205
+
206
+ // ── Main export ───────────────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Sanitizes LLM-generated React code before passing it to buildTemplate().
210
+ *
211
+ * Steps (in order):
212
+ * 1. Strip markdown code fences
213
+ * 2. Fix literal escape sequences (\\n, \\t, \\r)
214
+ * 3. Strip Next.js / RSC directives ('use client', 'use server')
215
+ * 4. Strip TypeScript-only syntax (interface, type, enum, `as` assertions)
216
+ * 5. Ensure the main component is named App and has no export keyword
217
+ * 6. Strip imports from modules outside the importmap
218
+ * 7. Strip CommonJS require() calls
219
+ * 8. Collapse excessive blank lines
220
+ */
221
+ export function sanitizeCode(raw: string): string {
222
+ let code = raw;
223
+
224
+ // ── 1. Strip markdown code fences ───────────────────────────────────────
225
+ code = code.replace(/^```[a-zA-Z]*\r?\n?/, "").replace(/\r?\n?```\s*$/, "");
226
+
227
+ // ── 2. Fix literal escape sequences ─────────────────────────────────────
228
+ code = code
229
+ .replace(/\\n/g, "\n")
230
+ .replace(/\\t/g, "\t")
231
+ .replace(/\\r/g, "\r");
232
+
233
+ // ── 3. Strip Next.js / RSC directives ───────────────────────────────────
234
+ code = code.replace(/^\s*['"]use (client|server)['"]\s*;?\s*\n?/gm, "");
235
+
236
+ // ── 4. Strip TypeScript-only syntax ─────────────────────────────────────
237
+ code = stripTypeScriptDeclarations(code);
238
+ code = stripAsAssertions(code);
239
+
240
+ // ── 5. Fix component name and remove export keywords ────────────────────
241
+ // The template calls React.createElement(App), so App must be a plain
242
+ // function in global scope — no export keyword.
243
+
244
+ const originalName = detectMainComponentName(code);
245
+ if (originalName) {
246
+ // Rename all occurrences of the original name to "App"
247
+ code = code.replace(new RegExp(`\\b${originalName}\\b`, "g"), "App");
248
+ }
249
+
250
+ // Strip "export default" prefix from the App declaration
251
+ code = code.replace(/\bexport\s+default\s+(function\s+App\b)/, "$1");
252
+ code = code.replace(/\bexport\s+default\s+((?:const|let)\s+App\b)/, "$1");
253
+
254
+ // Strip "export" (non-default) prefix from the App declaration
255
+ code = code.replace(/\bexport\s+(function\s+App\b)/, "$1");
256
+ code = code.replace(/\bexport\s+((?:const|let)\s+App\b)/, "$1");
257
+
258
+ // ── 6. Strip imports from modules outside the importmap ─────────────────
259
+ const importLineRegex =
260
+ /^import\s+(?:type\s+)?(?:[^"'\n]+\s+from\s+)?["']([^"']+)["'];?\s*$/gm;
261
+
262
+ code = code.replace(importLineRegex, (line, modulePath) => {
263
+ // Always strip template-provided modules (already in global scope)
264
+ if (TEMPLATE_PROVIDED_MODULES.has(modulePath)) return "";
265
+
266
+ // Strip anything outside the importmap (would cause a fetch/resolve error)
267
+ if (!IMPORTMAP_MODULES.has(modulePath)) return "";
268
+
269
+ // Strip if all named imports are already in scope from the template
270
+ const namedMatch = line.match(/\{([^}]+)\}/);
271
+ if (namedMatch) {
272
+ // @ts-ignore
273
+ const names = namedMatch[1]
274
+ .split(",")
275
+ // @ts-ignore
276
+ .map((n) => n.trim().split(/\s+as\s+/)[0].trim())
277
+ .filter(Boolean);
278
+ if (names.length > 0 && names.every((n) => TEMPLATE_PROVIDED_NAMES.has(n))) {
279
+ return "";
280
+ }
281
+ }
282
+
283
+ return line;
284
+ });
285
+
286
+ // ── 7. Strip CommonJS require() calls ───────────────────────────────────
287
+ // The sandbox runs as ESM; require() is undefined.
288
+ code = code.replace(
289
+ /^(?:const|let|var)\s+[\w\s,{}]+\s*=\s*require\s*\(["'][^"']+["']\)\s*;?\s*$/gm,
290
+ ""
291
+ );
292
+
293
+ // ── 8. Collapse excessive blank lines ────────────────────────────────────
294
+ code = code.replace(/\n{3,}/g, "\n\n");
295
+
296
+ return code.trim();
297
+ }
package/src/template.ts CHANGED
@@ -53,6 +53,28 @@ export function buildTemplate(code: string): string {
53
53
  <style>
54
54
  * { box-sizing: border-box; }
55
55
  body { margin: 0; padding: 0; }
56
+
57
+ /* Scrollbar personalizada: fina y semi-transparente */
58
+ ::-webkit-scrollbar {
59
+ width: 6px;
60
+ height: 6px;
61
+ }
62
+ ::-webkit-scrollbar-track {
63
+ background: transparent;
64
+ }
65
+ ::-webkit-scrollbar-thumb {
66
+ background: rgba(255, 255, 255, 0.35);
67
+ border-radius: 999px;
68
+ transition: background 0.2s ease;
69
+ }
70
+ ::-webkit-scrollbar-thumb:hover {
71
+ background: rgba(255, 255, 255, 0.55);
72
+ }
73
+ /* Firefox */
74
+ * {
75
+ scrollbar-width: thin;
76
+ scrollbar-color: rgba(255, 255, 255, 0.35) transparent;
77
+ }
56
78
  <\/style>
57
79
  <\/head>
58
80
  <body>
@@ -65,7 +87,7 @@ export function buildTemplate(code: string): string {
65
87
  // the parent has a real origin and can make fetch calls freely.
66
88
  window.fetch = function(url, options = {}) {
67
89
  return new Promise((resolve, reject) => {
68
- const id = crypto.randomUUID();
90
+ const id = Math.random().toString(36).slice(2) + Date.now().toString(36);
69
91
 
70
92
  // Serialize body — postMessage can't transfer Request objects
71
93
  const serializedOptions = {