diff --git a/.npmrc b/.npmrc index e69de29..01a9404 100644 --- a/.npmrc +++ b/.npmrc @@ -0,0 +1,3 @@ + +# Allow esbuild to run its install script (downloads the native binary) +build-scripts-allowed=esbuild diff --git a/package.json b/package.json index c240398..7660e67 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "test": "vitest run --coverage", "build": "pnpm --filter @originmain/app run build", "dev": "pnpm --filter @originmain/app run dev", + "cli:build": "pnpm --filter @originmain/cli run build", + "cli:dev": "node packages/cli/dist/cli.js dev", "migration:dry-run": "supabase db diff --schema public" }, "devDependencies": { diff --git a/packages/app/src/components/canvas/LiveArtboard.tsx b/packages/app/src/components/canvas/LiveArtboard.tsx index 205bfd7..fb56438 100644 --- a/packages/app/src/components/canvas/LiveArtboard.tsx +++ b/packages/app/src/components/canvas/LiveArtboard.tsx @@ -43,10 +43,17 @@ export function LiveArtboard({ onComponentSelected, style, }: LiveArtboardProps) { - const iframeRef = useRef(null); + const iframeRef = useRef(null); // Track whether the iframe has sent READY so we don't send messages too early. const isReadyRef = useRef(false); + // Reset ready state whenever src changes. Without this, isReadyRef stays + // true from the previous page, causing design-token / selection effects to + // fire against a half-loaded iframe between navigation and the new READY. + useEffect(() => { + isReadyRef.current = false; + }, [src]); + // ── Send a typed message to the iframe ─────────────────────────────────── const sendMessage = useCallback( (type: Parameters[1]['type'], payload?: Record) => { @@ -85,6 +92,10 @@ export function LiveArtboard({ case 'COMPONENT_SELECTED': onComponentSelected?.(msg.nodeId); break; + case 'COMPONENT_DESELECTED': + // Renderer clicked empty space — clear the host-side selection. + onComponentSelected?.(''); + break; } } diff --git a/packages/app/src/components/walkthrough/tourSteps.tsx b/packages/app/src/components/walkthrough/tourSteps.tsx index 662c6cf..833282e 100644 --- a/packages/app/src/components/walkthrough/tourSteps.tsx +++ b/packages/app/src/components/walkthrough/tourSteps.tsx @@ -107,7 +107,9 @@ export const TOUR_STEPS: TourStep[] = [ ), codeBlock: `# Terminal 2 — keep your dev server running in Terminal 1 -npx @originmain/cli dev --target http://localhost:3000 +# From the monorepo root: +pnpm cli:build # one-time build (skip if already built) +pnpm cli:dev --target http://localhost:3000 # ✓ Proxy listening on http://localhost:4170 # Paste http://localhost:4170 into the artboard URL input`, diff --git a/packages/cli/build.mjs b/packages/cli/build.mjs new file mode 100644 index 0000000..c8360e9 --- /dev/null +++ b/packages/cli/build.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +// ── CLI bundle script ───────────────────────────────────────────────────────── +// Produces two self-contained ESM bundles under dist/: +// +// dist/cli.js — the `originmain` binary (has shebang, chmod +x) +// dist/index.js — programmatic API: startProxy(), injectFiberHook() +// +// Run: node build.mjs (or via "pnpm build") + +import { build } from 'esbuild'; +import { readFileSync, writeFileSync, chmodSync } from 'node:fs'; + +// Node.js built-in module names (without and with the node: prefix). +// Both forms must be listed so esbuild leaves them as-is whether the +// source imports 'http' or 'node:http'. +const NODE_BUILTINS = [ + 'node:*', + 'assert', 'buffer', 'child_process', 'cluster', 'console', 'constants', + 'crypto', 'dgram', 'dns', 'domain', 'events', 'fs', 'http', 'http2', + 'https', 'module', 'net', 'os', 'path', 'perf_hooks', 'process', + 'punycode', 'querystring', 'readline', 'repl', 'stream', 'string_decoder', + 'sys', 'timers', 'tls', 'trace_events', 'tty', 'url', 'util', 'v8', + 'vm', 'worker_threads', 'zlib', +]; + +const SHARED_OPTIONS = { + bundle: true, + platform: 'node', + format: 'esm', + target: 'node22', + external: NODE_BUILTINS, + logLevel: 'info', +}; + +// ── Build both entry points in parallel ────────────────────────────────────── + +await Promise.all([ + build({ ...SHARED_OPTIONS, entryPoints: ['src/cli.ts'], outfile: 'dist/cli.js' }), + build({ ...SHARED_OPTIONS, entryPoints: ['src/index.ts'], outfile: 'dist/index.js' }), +]); + +// ── Add shebang + executable bit to the CLI binary ─────────────────────────── + +const SHEBANG = '#!/usr/bin/env node\n'; +const cliContent = readFileSync('dist/cli.js', 'utf-8'); + +if (!cliContent.startsWith('#!')) { + writeFileSync('dist/cli.js', SHEBANG + cliContent, 'utf-8'); +} +chmodSync('dist/cli.js', 0o755); + +console.log(' ✓ dist/cli.js (binary)'); +console.log(' ✓ dist/index.js (programmatic API)'); diff --git a/packages/cli/package.json b/packages/cli/package.json index 163f2bf..0364ad1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -8,17 +8,25 @@ "originmain": "./dist/cli.js" }, "exports": { - ".": "./src/index.ts" + ".": { + "import": "./dist/index.js", + "default": "./dist/index.js" + } }, + "files": [ + "dist/cli.js", + "dist/index.js", + "README.md" + ], "scripts": { "typecheck": "tsc --noEmit", - "build": "tsc -p tsconfig.build.json" - }, - "dependencies": { - "@originmain/renderer": "workspace:*" + "build": "node build.mjs" }, + "dependencies": {}, "devDependencies": { + "@originmain/renderer": "workspace:*", "@types/node": "^22.0.0", + "esbuild": "^0.28.0", "typescript": "^5.5.0" }, "engines": { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index df4b169..803cf93 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -47,7 +47,8 @@ function main(): void { if (values.help || !command) { printUsage(); - process.exit(command ? 0 : 1); + // --help is a success; missing command is a usage error. + process.exit(values.help ? 0 : 1); } if (command !== 'dev') { diff --git a/packages/cli/src/inject.ts b/packages/cli/src/inject.ts index 593bc5c..f0b7056 100644 --- a/packages/cli/src/inject.ts +++ b/packages/cli/src/inject.ts @@ -15,31 +15,51 @@ function getScriptTag(): string { return cachedScriptTag; } +/** + * Strip inline Content-Security-Policy meta tags from HTML. + * + * The proxy already removes the CSP response header, but some frameworks + * (e.g. Next.js with a custom _document) also embed CSP inside a meta tag. + * A meta CSP applies to the entire document — including scripts parsed before + * it — so our injected hook can be silently blocked even though it runs first. + * Removing these tags lets the hook execute freely inside the sandboxed iframe. + */ +function stripMetaCsp(html: string): string { + return html.replace( + /]+http-equiv\s*=\s*["']?\s*content-security-policy\s*["']?[^>]*\/?>/gi, + '', + ); +} + /** * Inject the fiber hook script into an HTML string. * - * Injection strategy (in order of preference): - * 1. After `` — standard position for early scripts - * 2. After `` — fallback if is missing - * 3. Prepend to document — final fallback + * Steps: + * 1. Strip any inline Content-Security-Policy meta tags that could block the + * injected script (the CSP response header is stripped by the proxy itself). + * 2. Insert the hook script immediately after the opening tag + * (preferred), after (fallback), or prepend to the document (final + * fallback). Placing it first ensures __REACT_DEVTOOLS_GLOBAL_HOOK__ is + * registered before React's module body runs. */ export function injectFiberHook(html: string): string { - const tag = getScriptTag(); + const tag = getScriptTag(); + const cleaned = stripMetaCsp(html); // Try after - const headMatch = /]*>/i.exec(html); + const headMatch = /]*>/i.exec(cleaned); if (headMatch) { const insertAt = headMatch.index + headMatch[0].length; - return html.slice(0, insertAt) + tag + html.slice(insertAt); + return cleaned.slice(0, insertAt) + tag + cleaned.slice(insertAt); } // Try after - const htmlMatch = /]*>/i.exec(html); + const htmlMatch = /]*>/i.exec(cleaned); if (htmlMatch) { const insertAt = htmlMatch.index + htmlMatch[0].length; - return html.slice(0, insertAt) + tag + html.slice(insertAt); + return cleaned.slice(0, insertAt) + tag + cleaned.slice(insertAt); } // Final fallback: prepend - return tag + html; + return tag + cleaned; } diff --git a/packages/cli/src/proxy.ts b/packages/cli/src/proxy.ts index d3ad2be..1819e53 100644 --- a/packages/cli/src/proxy.ts +++ b/packages/cli/src/proxy.ts @@ -1,17 +1,20 @@ // ── Reverse Proxy ──────────────────────────────────────────────────────────── -// HTTP reverse proxy that: -// 1. Forwards all requests to the target dev server +// HTTP(S) reverse proxy that: +// 1. Forwards all requests to the target dev server (http or https) // 2. Strips X-Frame-Options and CSP frame-ancestors from responses // 3. Injects the Originmain fiber hook into HTML responses // 4. Passes WebSocket upgrades through for HMR // // Uses only Node.js built-ins — no external dependencies. -import { createServer, request as httpRequest } from 'node:http'; -import { connect as netConnect } from 'node:net'; -import type { IncomingMessage, ServerResponse } from 'node:http'; -import type { Socket } from 'node:net'; -import { injectFiberHook } from './inject.js'; +import { createServer } from 'node:http'; +import { request as httpRequest } from 'node:http'; +import { request as httpsRequest } from 'node:https'; +import { connect as netConnect } from 'node:net'; +import type { IncomingMessage, ServerResponse, + RequestOptions, ClientRequest } from 'node:http'; +import type { Socket } from 'node:net'; +import { injectFiberHook } from './inject.js'; export interface ProxyOptions { /** Target dev server URL, e.g. "http://localhost:3000" */ @@ -27,88 +30,141 @@ const STRIP_RESPONSE_HEADERS = new Set([ 'content-security-policy-report-only', ]); -/** CORS headers added to every response for cross-origin API compatibility. */ +/** CORS headers appended to every response so the canvas can reach the app. */ const CORS_HEADERS: Record = { - 'access-control-allow-origin': '*', - 'access-control-allow-methods': '*', - 'access-control-allow-headers': '*', + 'access-control-allow-origin': '*', + 'access-control-allow-methods': '*', + 'access-control-allow-headers': '*', 'access-control-allow-credentials': 'true', }; +// ── Outgoing request helper ──────────────────────────────────────────────── +// Chooses http / https based on the target protocol. +// rejectUnauthorized is disabled for HTTPS because dev / staging servers +// frequently use self-signed certificates. + +function doRequest( + isHttps: boolean, + options: RequestOptions, + cb: (res: IncomingMessage) => void, +): ClientRequest { + if (isHttps) { + return httpsRequest({ ...options, rejectUnauthorized: false }, cb); + } + return httpRequest(options, cb); +} + /** * Start the reverse proxy server. - * Returns a cleanup function that shuts down the server. + * Returns an object with a `close()` method for graceful shutdown. */ export function startProxy(opts: ProxyOptions): { close: () => void } { - const targetUrl = new URL(opts.target); + const targetUrl = new URL(opts.target); + const isHttps = targetUrl.protocol === 'https:'; const targetHost = targetUrl.hostname; - const targetPort = parseInt(targetUrl.port || '80', 10); + // Default port: 443 for HTTPS, 80 for HTTP — matches browser behaviour. + const targetPort = parseInt(targetUrl.port || (isHttps ? '443' : '80'), 10); const server = createServer((clientReq: IncomingMessage, clientRes: ServerResponse) => { - // ── Build the outgoing request to the target ───────────────────────── + // ── Build the outgoing request headers ──────────────────────────────── - // Clone headers, stripping Accept-Encoding so the target sends - // uncompressed HTML (avoids needing to decompress before injection). const outHeaders: Record = {}; + for (const [key, val] of Object.entries(clientReq.headers)) { - if (key.toLowerCase() === 'accept-encoding') continue; - if (key.toLowerCase() === 'host') { + const lower = key.toLowerCase(); + + // Strip Accept-Encoding — we replace it with 'identity' below so the + // target always returns uncompressed HTML that we can decode and inject. + if (lower === 'accept-encoding') continue; + + // Rewrite Host to point at the actual target, not the proxy. + if (lower === 'host') { outHeaders[key] = `${targetHost}:${targetPort}`; continue; } - if (val !== undefined) { - outHeaders[key] = val; - } + + if (val !== undefined) outHeaders[key] = val as string | string[]; } - const proxyReq = httpRequest( + // Explicitly ask for uncompressed content. + outHeaders['accept-encoding'] = 'identity'; + + const method = clientReq.method ?? 'GET'; + const isHeadReq = method === 'HEAD'; + + const proxyReq = doRequest( + isHttps, { hostname: targetHost, - port: targetPort, - path: clientReq.url ?? '/', - method: clientReq.method, - headers: outHeaders, + port: targetPort, + path: clientReq.url ?? '/', + method, + headers: outHeaders, }, - (proxyRes) => { - // ── Process response headers ─────────────────────────────────── + (proxyRes: IncomingMessage) => { + // ── Build clean response headers ────────────────────────────────── const resHeaders: Record = {}; for (const [key, val] of Object.entries(proxyRes.headers)) { if (STRIP_RESPONSE_HEADERS.has(key.toLowerCase())) continue; - if (val !== undefined) { - resHeaders[key] = val; - } + if (val !== undefined) resHeaders[key] = val as string | string[]; } - // Add CORS headers + // Append CORS. for (const [key, val] of Object.entries(CORS_HEADERS)) { resHeaders[key] = val; } - // ── Determine if this is an HTML response ────────────────────── + // ── Detect HTML responses ───────────────────────────────────────── const contentType = (proxyRes.headers['content-type'] ?? '').toLowerCase(); - const isHtml = contentType.includes('text/html'); + const isHtml = contentType.includes('text/html'); - if (!isHtml) { - // Non-HTML: stream through unchanged (headers already stripped) + // HEAD and non-HTML responses: stream (or drain) through unchanged. + // HEAD: RFC 7231 §4.3.2 — response MUST NOT include a body. + // We must drain the response to free the socket, but send no body. + // Non-HTML: no injection needed, stream straight through. + if (isHeadReq || !isHtml) { clientRes.writeHead(proxyRes.statusCode ?? 200, resHeaders); - proxyRes.pipe(clientRes); + proxyRes.on('error', (err: Error) => { + console.error(`[originmain proxy] Response stream error: ${err.message}`); + clientRes.destroy(); + }); + if (isHeadReq) { + // Drain + end without sending body. + proxyRes.resume(); + proxyRes.on('end', () => clientRes.end()); + } else { + proxyRes.pipe(clientRes); + } return; } - // HTML: buffer the full body, inject the script, then send. - const chunks: Buffer[] = []; - proxyRes.on('data', (chunk: Buffer) => chunks.push(chunk)); - proxyRes.on('end', () => { - const rawHtml = Buffer.concat(chunks).toString('utf-8'); - const injectedHtml = injectFiberHook(rawHtml); - const body = Buffer.from(injectedHtml, 'utf-8'); + // HTML GET/POST: buffer, inject the fiber hook script, then send. + // Strip Content-Encoding — we decode to UTF-8, so the encoding header + // would be incorrect after injection. + delete resHeaders['content-encoding']; - // Update Content-Length to match the injected body + const chunks: Buffer[] = []; + + proxyRes.on('data', (chunk: Buffer) => chunks.push(chunk)); + + proxyRes.on('error', (err: Error) => { + console.error(`[originmain proxy] HTML response stream error: ${err.message}`); + if (!clientRes.headersSent) { + clientRes.writeHead(502, { 'content-type': 'text/plain' }); + } + clientRes.destroy(); + }); + + proxyRes.on('end', () => { + const rawHtml = Buffer.concat(chunks).toString('utf-8'); + const injectedHtml = injectFiberHook(rawHtml); + const body = Buffer.from(injectedHtml, 'utf-8'); + + // Correct Content-Length and drop Transfer-Encoding: chunked. resHeaders['content-length'] = String(body.byteLength); - // Remove Transfer-Encoding: chunked since we send the full body delete resHeaders['transfer-encoding']; clientRes.writeHead(proxyRes.statusCode ?? 200, resHeaders); @@ -117,51 +173,82 @@ export function startProxy(opts: ProxyOptions): { close: () => void } { }, ); - proxyReq.on('error', (err) => { + // ── Handle target-unreachable errors ────────────────────────────────── + + proxyReq.on('error', (err: Error) => { console.error(`[originmain proxy] Target request failed: ${err.message}`); if (!clientRes.headersSent) { clientRes.writeHead(502, { 'content-type': 'text/plain' }); + clientRes.end( + `Originmain proxy: could not reach target at ${opts.target}\n${err.message}`, + ); + } else { + // Headers already on the wire — tear down the connection cleanly. + clientRes.destroy(); } - clientRes.end(`Originmain proxy: could not reach target at ${opts.target}\n${err.message}`); }); - // Pipe the client request body to the target + // ── Handle client disconnects ───────────────────────────────────────── + + clientReq.on('error', (err: Error) => { + console.error(`[originmain proxy] Client request error: ${err.message}`); + proxyReq.destroy(); + }); + + // Pipe the request body (relevant for POST / PUT / PATCH). clientReq.pipe(proxyReq); }); - // ── WebSocket upgrade passthrough (for HMR) ───────────────────────────── + // ── Friendly error for port conflicts ───────────────────────────────────── + + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + console.error(`\n \x1b[31mError:\x1b[0m Port ${opts.port} is already in use.`); + console.error( + ` Try a different port: originmain dev --target ${opts.target} --port ${opts.port + 1}\n`, + ); + process.exit(1); + } + throw err; + }); + + // ── WebSocket upgrade passthrough (for HMR) ─────────────────────────────── server.on('upgrade', (req: IncomingMessage, clientSocket: Socket, head: Buffer) => { - // Open a TCP connection to the target and forward the HTTP upgrade const targetSocket = netConnect(targetPort, targetHost, () => { - // Reconstruct the raw HTTP upgrade request - const reqLine = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`; - const headers = Object.entries(req.headers) + // Reconstruct the raw HTTP upgrade request. + const reqLine = `${req.method ?? 'GET'} ${req.url ?? '/'} HTTP/${req.httpVersion}\r\n`; + const hostHdr = `host: ${targetHost}:${targetPort}`; + const otherHdr = Object.entries(req.headers) .filter(([key]) => key.toLowerCase() !== 'host') - .map(([key, val]) => `${key}: ${Array.isArray(val) ? val.join(', ') : val ?? ''}`) + .map(([key, val]) => `${key}: ${Array.isArray(val) ? val.join(', ') : (val ?? '')}`) .join('\r\n'); - const hostHeader = `host: ${targetHost}:${targetPort}`; - targetSocket.write(`${reqLine}${hostHeader}\r\n${headers}\r\n\r\n`); - if (head.length > 0) { - targetSocket.write(head); - } + targetSocket.write(`${reqLine}${hostHdr}\r\n${otherHdr}\r\n\r\n`); + if (head.length > 0) targetSocket.write(head); - // Pipe bidirectionally + // Bidirectional pipe. targetSocket.pipe(clientSocket); clientSocket.pipe(targetSocket); }); - targetSocket.on('error', (err) => { + // Ensure both sockets are torn down when either side closes. + targetSocket.on('close', () => clientSocket.destroy()); + clientSocket.on('close', () => targetSocket.destroy()); + + targetSocket.on('error', (err: Error) => { console.error(`[originmain proxy] WebSocket proxy error: ${err.message}`); clientSocket.destroy(); }); - clientSocket.on('error', () => { + clientSocket.on('error', (err: Error) => { + console.error(`[originmain proxy] WebSocket client error: ${err.message}`); targetSocket.destroy(); }); }); + // ── Start listening ─────────────────────────────────────────────────────── + server.listen(opts.port, () => { const proxyUrl = `http://localhost:${opts.port}`; console.log(''); @@ -176,6 +263,9 @@ export function startProxy(opts: ProxyOptions): { close: () => void } { console.log(' \x1b[2mFiber hook injection ........ active\x1b[0m'); console.log(' \x1b[2mX-Frame-Options stripping ... active\x1b[0m'); console.log(' \x1b[2mWebSocket passthrough ....... active\x1b[0m'); + if (isHttps) { + console.log(' \x1b[2mHTTPS → HTTP bridge ......... active\x1b[0m'); + } console.log(''); }); diff --git a/packages/renderer/src/fiber-hook.ts b/packages/renderer/src/fiber-hook.ts index 37cb417..6257eff 100644 --- a/packages/renderer/src/fiber-hook.ts +++ b/packages/renderer/src/fiber-hook.ts @@ -133,10 +133,10 @@ export function buildProxyFiberHookScript(): string { var HOST_SOURCE = ${JSON.stringify(HOST_SOURCE)}; // ── Runtime state ───────────────────────────────────────────────────────── - var currentTree = null; // latest serialized FiberNode tree - var nodeMap = {}; // nodeId → { domRect } (flat for O(1) lookup) - var selectedNodeId = null; // currently highlighted component - var highlightEl = null; // the blue-ring DOM overlay element + var nodeMap = {}; // nodeId → { domRect, fiber } + var fiberMap = new WeakMap(); // fiber → nodeId (reverse lookup for click) + var selectedNodeId = null; // currently highlighted component + var highlightEl = null; // the blue-ring DOM overlay element // ── postMessage helper ──────────────────────────────────────────────────── function post(msg) { @@ -165,11 +165,15 @@ export function buildProxyFiberHookScript(): string { catch (e) { /* don't break existing DevTools */ } } try { + // Reset maps before each walk so stale entries from the previous tree + // don't accumulate. fiberMap is a WeakMap so it self-cleans, but we + // rebuild nodeMap from scratch on every commit. + nodeMap = {}; + fiberMap = new WeakMap(); + var tree = serializeFiber(root.current, ''); - currentTree = tree; - rebuildNodeMap(tree); post({ type: 'FIBER_TREE_UPDATE', root: tree }); - // Re-sync the highlight ring after each React commit (position may shift). + // Re-sync the highlight ring after each React commit (layout may shift). if (selectedNodeId) updateHighlight(); } catch (err) { post({ type: 'ERROR', message: String(err) }); @@ -178,22 +182,24 @@ export function buildProxyFiberHookScript(): string { // ── Fiber serialization (stable path-based IDs) ─────────────────────────── // ID format: "ComponentName:siblingIndex/ChildName:siblingIndex/..." - // Stable across re-renders provided the tree structure doesn't change. + // + // IMPORTANT: unnamed fibers (Fragment, Context.Provider, React.memo wrappers) + // are transparent — their children are collected directly into their parent's + // children array. Returning only the first named child (the old approach) + // caused entire subtrees to vanish from the tree. function serializeFiber(fiber, parentId) { if (!fiber) return null; var name = getDisplayName(fiber); - if (!name) { - // Unnamed fiber (Fragment, Context, Provider) — skip this level, - // but keep walking children so named descendants are not lost. - var c = fiber.child; - while (c) { - var s = serializeFiber(c, parentId); - if (s) return s; - c = c.sibling; - } - return null; + // Unnamed root fiber (HostRoot) — skip to first named child. + // collectChildren handles the Fragment case recursively. + var children = []; + collectChildren(fiber, parentId, children); + if (children.length === 1) return children[0]; + if (children.length === 0) return null; + // Multiple named children at root — wrap in a synthetic root node. + return { id: '__root__', name: '__root__', props: {}, children: children }; } var nodeId = (parentId ? parentId + '/' : '') + name + ':' + String(fiber.index); @@ -201,13 +207,31 @@ export function buildProxyFiberHookScript(): string { var node = { id: nodeId, name: name, props: serializeProps(fiber.memoizedProps), children: [] }; if (rect) node.domRect = rect; + // Register in both maps for O(1) lookup. + nodeMap[nodeId] = { domRect: rect || null, fiber: fiber }; + fiberMap.set(fiber, nodeId); + + collectChildren(fiber, nodeId, node.children); + return node; + } + + // Collect all NAMED descendants of fiber.child into out[], transparently + // flattening unnamed intermediates (Fragments, Providers, wrappers). + function collectChildren(fiber, parentId, out) { var child = fiber.child; while (child) { - var serialized = serializeFiber(child, nodeId); - if (serialized) node.children.push(serialized); + var name = getDisplayName(child); + if (name) { + var serialized = serializeFiber(child, parentId); + if (serialized) out.push(serialized); + } else { + // Unnamed (Fragment / Context / Provider / forwardRef wrapper etc.): + // flatten its children directly into our level — they share parentId. + fiberMap.set(child, null); // mark as non-selectable + collectChildren(child, parentId, out); + } child = child.sibling; } - return node; } function getDisplayName(fiber) { @@ -242,23 +266,20 @@ export function buildProxyFiberHookScript(): string { return out; } - // ── Node map: flat O(1) lookup by stable ID ─────────────────────────────── - function rebuildNodeMap(node) { - nodeMap = {}; - fillMap(node); - } - function fillMap(node) { - if (!node) return; - nodeMap[node.id] = { domRect: node.domRect || null }; - var children = node.children; - for (var i = 0; i < children.length; i++) fillMap(children[i]); - } - // ── Highlight overlay (blue ring inside the iframe) ─────────────────────── + // updateHighlight re-measures from the live fiber stateNode so the ring + // stays accurate even after scroll (between React commits). function updateHighlight() { - var info = nodeMap[selectedNodeId]; - if (info && info.domRect) renderHighlight(info.domRect); - else removeHighlight(); + var info = selectedNodeId ? nodeMap[selectedNodeId] : null; + if (!info) { removeHighlight(); return; } + + // Re-measure from the live DOM element for scroll accuracy. + var rect = info.fiber ? getDomRect(info.fiber) : info.domRect; + if (rect && rect.width > 0 && rect.height > 0) { + renderHighlight(rect); + } else { + removeHighlight(); + } } function renderHighlight(rect) { @@ -266,8 +287,6 @@ export function buildProxyFiberHookScript(): string { highlightEl = document.createElement('div'); highlightEl.id = '__om_sel__'; highlightEl.setAttribute('aria-hidden', 'true'); - // CSS kept inline so no stylesheet dependency. Transition animates when - // the selected component moves (e.g. during a re-render or scroll). highlightEl.style.cssText = [ 'position:fixed', 'pointer-events:none', 'z-index:2147483647', 'box-shadow:0 0 0 2px #3385FF', @@ -290,42 +309,63 @@ export function buildProxyFiberHookScript(): string { } } - // ── Click-to-select (capturing phase) ──────────────────────────────────── - // Finds the deepest named fiber node at the click point and reports it back. - // In normal canvas usage the SelectionOverlay sits on top of the iframe and - // this listener fires when the overlay is bypassed (e.g. direct preview mode). - - document.addEventListener('click', function(event) { - var node = findDeepestAt(currentTree, event.clientX, event.clientY); - if (node && node.domRect) { - post({ type: 'COMPONENT_SELECTED', nodeId: node.id, rect: node.domRect }); - } + // Re-measure on scroll so the ring follows the element without needing + // a React commit (which only fires on state/prop changes). + window.addEventListener('scroll', function() { + if (selectedNodeId) updateHighlight(); }, true); - function findDeepestAt(node, x, y) { - if (!node) return null; - var r = node.domRect; - var hit = r && x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height; + // ── Click-to-select (capturing phase) ──────────────────────────────────── + // Uses document.elementFromPoint to get the live DOM element at the click + // position (accurate even after scroll), then walks the React fiber tree + // upward via __reactFiber$ keys to find the nearest tracked component. + // Falls back to a tree-based hit test if the fiber key is not present + // (e.g. in some SSR-hydrated contexts). - if (hit) { - // Matched — recurse children for a deeper (more specific) match. - var children = node.children; - for (var i = 0; i < children.length; i++) { - var deeper = findDeepestAt(children[i], x, y); - if (deeper) return deeper; - } - return node; - } - - // No hit on this node — still check children for overflow:visible cases. - var children = node.children; - for (var i = 0; i < children.length; i++) { - var found = findDeepestAt(children[i], x, y); - if (found) return found; + function getFiberKey(el) { + var keys = Object.keys(el); + for (var i = 0; i < keys.length; i++) { + if (keys[i].indexOf('__reactFiber$') === 0) return keys[i]; } return null; } + document.addEventListener('click', function(event) { + // Ignore clicks on our own highlight overlay. + if (event.target === highlightEl) return; + + var el = document.elementFromPoint(event.clientX, event.clientY); + + // Walk the DOM upward, trying to find a tracked React fiber at each level. + var current = el; + while (current && current !== document.documentElement) { + var fiberKey = current ? getFiberKey(current) : null; + if (fiberKey) { + // Walk the fiber's return (parent) chain to find the nearest node we track. + var fiber = current[fiberKey]; + while (fiber) { + var nodeId = fiberMap.get(fiber); + if (nodeId) { + // Re-measure from the live element for accurate post-scroll rect. + var liveEl = fiber.stateNode; + var rect = (liveEl && typeof liveEl.getBoundingClientRect === 'function') + ? (function(r) { return { x: r.x, y: r.y, width: r.width, height: r.height }; })(liveEl.getBoundingClientRect()) + : null; + if (rect) { + post({ type: 'COMPONENT_SELECTED', nodeId: nodeId, rect: rect }); + return; + } + } + fiber = fiber.return; + } + } + current = current.parentElement; + } + + // Nothing found — clear the selection. + post({ type: 'COMPONENT_DESELECTED' }); + }, true); + // ── Host → Renderer message handler ────────────────────────────────────── window.addEventListener('message', function(event) { var data = event.data; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b33340b..486a787 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -159,6 +159,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.17 + esbuild: + specifier: ^0.28.0 + version: 0.28.0 typescript: specifier: ^5.5.0 version: 5.9.3 @@ -395,138 +398,294 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1904,6 +2063,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2719,72 +2883,150 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true + '@esbuild/aix-ppc64@0.28.0': + optional: true + '@esbuild/android-arm64@0.21.5': optional: true + '@esbuild/android-arm64@0.28.0': + optional: true + '@esbuild/android-arm@0.21.5': optional: true + '@esbuild/android-arm@0.28.0': + optional: true + '@esbuild/android-x64@0.21.5': optional: true + '@esbuild/android-x64@0.28.0': + optional: true + '@esbuild/darwin-arm64@0.21.5': optional: true + '@esbuild/darwin-arm64@0.28.0': + optional: true + '@esbuild/darwin-x64@0.21.5': optional: true + '@esbuild/darwin-x64@0.28.0': + optional: true + '@esbuild/freebsd-arm64@0.21.5': optional: true + '@esbuild/freebsd-arm64@0.28.0': + optional: true + '@esbuild/freebsd-x64@0.21.5': optional: true + '@esbuild/freebsd-x64@0.28.0': + optional: true + '@esbuild/linux-arm64@0.21.5': optional: true + '@esbuild/linux-arm64@0.28.0': + optional: true + '@esbuild/linux-arm@0.21.5': optional: true + '@esbuild/linux-arm@0.28.0': + optional: true + '@esbuild/linux-ia32@0.21.5': optional: true + '@esbuild/linux-ia32@0.28.0': + optional: true + '@esbuild/linux-loong64@0.21.5': optional: true + '@esbuild/linux-loong64@0.28.0': + optional: true + '@esbuild/linux-mips64el@0.21.5': optional: true + '@esbuild/linux-mips64el@0.28.0': + optional: true + '@esbuild/linux-ppc64@0.21.5': optional: true + '@esbuild/linux-ppc64@0.28.0': + optional: true + '@esbuild/linux-riscv64@0.21.5': optional: true + '@esbuild/linux-riscv64@0.28.0': + optional: true + '@esbuild/linux-s390x@0.21.5': optional: true + '@esbuild/linux-s390x@0.28.0': + optional: true + '@esbuild/linux-x64@0.21.5': optional: true + '@esbuild/linux-x64@0.28.0': + optional: true + + '@esbuild/netbsd-arm64@0.28.0': + optional: true + '@esbuild/netbsd-x64@0.21.5': optional: true + '@esbuild/netbsd-x64@0.28.0': + optional: true + + '@esbuild/openbsd-arm64@0.28.0': + optional: true + '@esbuild/openbsd-x64@0.21.5': optional: true + '@esbuild/openbsd-x64@0.28.0': + optional: true + + '@esbuild/openharmony-arm64@0.28.0': + optional: true + '@esbuild/sunos-x64@0.21.5': optional: true + '@esbuild/sunos-x64@0.28.0': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true + '@esbuild/win32-arm64@0.28.0': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true + '@esbuild/win32-ia32@0.28.0': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true + '@esbuild/win32-x64@0.28.0': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': dependencies: eslint: 9.39.4 @@ -4730,6 +4972,35 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + escape-string-regexp@4.0.0: {} eslint-scope@8.4.0: