diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..a815981 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,28 @@ +name: Publish + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + registry-url: 'https://registry.npmjs.org' + - run: npm ci + - run: npm run typecheck + - run: npm run lint + - run: npm test + - run: npm audit --omit=dev + - run: npm pack --dry-run + - run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..eef5c6c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Bjørn A. Johansen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 3d59550..09e1e4d 100644 --- a/README.md +++ b/README.md @@ -1 +1,80 @@ -# React Observatory +# Reactoscope + +Explore, stress-test, and get AI feedback on your React components — without writing a single test or storybook file. + +## Features + +- **Explore** — Renders any component with auto-generated prop controls based on its TypeScript types +- **Stress Test** — Measures render performance, detects non-determinism, and spots memory leaks via server-side rendering +- **AI Feedback** — Connects to a local Ollama instance to review component source code and provide suggestions + +## Quick Start + +### As a standalone CLI (no Vite project required) + +```bash +npx reactoscope path/to/MyComponent.tsx +``` + +This starts a dev server and opens the Observatory UI in your browser. + +### As a Vite plugin + +```bash +npm install reactoscope --save-dev +``` + +Add it to your Vite config: + +```ts +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { observatory } from 'reactoscope' + +export default defineConfig({ + plugins: [react(), ...observatory()], +}) +``` + +Then visit `/__observatory?component=path/to/MyComponent.tsx` in your browser while the dev server is running. + +## Options + +```ts +observatory({ + ollamaUrl: 'http://localhost:11434', // default +}) +``` + +| Option | Type | Default | Description | +| ----------- | -------- | ------------------------ | --------------------------------------------------------- | +| `ollamaUrl` | `string` | `http://localhost:11434` | Base URL for the Ollama API used by the AI feedback panel | + +## Requirements + +- Node.js >= 22.18.0 +- React 19 +- TypeScript >= 5.8 + +## How It Works + +Reactoscope is a set of Vite plugins that: + +1. **Schema plugin** — Parses your component's TypeScript props at dev time and serves them as JSON, powering the auto-generated prop controls +2. **Stress plugin** — Renders your component server-side in a loop, measuring timing, output determinism, and heap growth +3. **AI plugin** — Proxies requests to a local Ollama instance, injecting your component's source code as context +4. **UI plugin** — Serves the pre-built Reactoscope dashboard at `/__observatory` + +The CLI wraps all of this into a single command using Vite's `createServer` API, so it works even in projects that don't use Vite. + +## AI Feedback + +The AI panel requires [Ollama](https://ollama.ai) running locally. Install it, pull a model, and the panel will auto-detect available models: + +```bash +ollama pull llama3 +``` + +## License + +MIT diff --git a/bin/observe.js b/bin/observe.js index bcc4dfd..4b264de 100755 --- a/bin/observe.js +++ b/bin/observe.js @@ -1,42 +1,79 @@ #!/usr/bin/env node -import { spawn } from 'node:child_process' import { resolve, relative } from 'node:path' import { fileURLToPath } from 'node:url' +import { existsSync } from 'node:fs' +import { createServer } from 'vite' +import react from '@vitejs/plugin-react' const componentPath = process.argv[2] if (!componentPath) { - console.error('Usage: observe path/to/MyComponent.tsx') + console.error('Usage: reactoscope path/to/MyComponent.tsx') process.exit(1) } -const projectRoot = resolve(fileURLToPath(import.meta.url), '../..') -const abs = resolve(componentPath) +const cwd = process.cwd() +const abs = resolve(cwd, componentPath) -// Make it relative to project root so we don't leak absolute paths -const rel = relative(projectRoot, abs) +if (!existsSync(abs)) { + console.error(`Error: File not found: ${abs}`) + process.exit(1) +} + +const rel = relative(cwd, abs) if (rel.startsWith('..')) { - console.error('Error: Component must be inside the project directory.') + console.error( + 'Error: Component must be inside the current working directory.', + ) process.exit(1) } -const viteBin = resolve(projectRoot, 'node_modules/.bin/vite') - -// spawn avoids shell injection — no shell is involved -const existingNodeOptions = process.env.NODE_OPTIONS ?? '' -const child = spawn( - viteBin, - ['--open', `/?component=${encodeURIComponent(rel)}`], - { - cwd: projectRoot, - stdio: 'inherit', - env: { - ...process.env, - NODE_OPTIONS: `${existingNodeOptions} --expose-gc`.trim(), +const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..') + +// Dynamic import so this works whether the user installed the package +// or is running from the repo itself. +const { observatory } = await import(resolve(pkgRoot, 'dist/plugin.mjs')) + +/** Check whether the user's Vite config already includes a React plugin. */ +function hasReactPlugin(plugins) { + const reactPluginNames = new Set([ + 'vite:react-babel', + 'vite:react-swc', + 'vite:react-refresh', + ]) + return plugins.some((p) => { + if (Array.isArray(p)) return p.some((pp) => reactPluginNames.has(pp.name)) + return reactPluginNames.has(p.name) + }) +} + +// Build the plugin list — always include observatory, +// only add react() if the user's config doesn't already provide one. +const extraPlugins = [...observatory()] + +const server = await createServer({ + root: cwd, + plugins: [ + { + name: 'observatory:inject', + config(config) { + const existing = config.plugins?.flat() ?? [] + if (!hasReactPlugin(existing)) { + config.plugins = [react(), ...existing] + } + }, }, + ...extraPlugins, + ], + resolve: { + tsconfigPaths: true, + }, + server: { + open: `/__observatory?component=${encodeURIComponent(rel)}`, }, -) +}) -child.on('exit', (code) => process.exit(code ?? 0)) +await server.listen() +server.printUrls() diff --git a/index.html b/index.html index 897dc5d..a8baa1a 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - react-observatory + Reactoscope
diff --git a/package-lock.json b/package-lock.json index 10a9758..2fecde1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,19 @@ { - "name": "react-observatory", - "version": "0.0.0", + "name": "reactoscope", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "react-observatory", - "version": "0.0.0", + "name": "reactoscope", + "version": "0.1.0", + "license": "MIT", "dependencies": { - "html2canvas-pro": "^2.0.2", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-feather": "^2.0.10" + "@vitejs/plugin-react": "^6.0.0", + "vite": "^8.0.0" + }, + "bin": { + "reactoscope": "bin/observe.js" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -20,17 +22,26 @@ "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "html2canvas-pro": "^2.0.2", "jsdom": "^29.0.1", "prettier": "^3.8.1", + "react-feather": "^2.0.10", + "tsdown": "^0.21.7", "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", - "vite": "^8.0.1", "vitest": "^4.1.2" + }, + "engines": { + "node": ">=22.18.0" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": ">=5.8.0" } }, "node_modules/@adobe/css-tools": { @@ -508,7 +519,6 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -520,7 +530,6 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -531,7 +540,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -819,7 +827,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -836,12 +843,24 @@ "version": "0.122.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@quansync/fs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz", + "integrity": "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "quansync": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", @@ -849,7 +868,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -866,7 +884,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -883,7 +900,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -900,7 +916,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -917,7 +932,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -934,7 +948,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -951,7 +964,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -968,7 +980,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -985,7 +996,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1002,7 +1012,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1019,7 +1028,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1036,7 +1044,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1053,7 +1060,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1070,7 +1076,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1087,7 +1092,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1101,7 +1105,6 @@ "version": "1.0.0-rc.7", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", - "dev": true, "license": "MIT" }, "node_modules/@standard-schema/spec": { @@ -1191,7 +1194,6 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1231,6 +1233,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1242,7 +1251,7 @@ "version": "24.12.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -1567,7 +1576,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", - "dev": true, "license": "MIT", "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" @@ -1769,6 +1777,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1796,6 +1814,74 @@ "node": ">=12" } }, + "node_modules/ast-kit": { + "version": "3.0.0-beta.1", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-3.0.0-beta.1.tgz", + "integrity": "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0-beta.4", + "estree-walker": "^3.0.3", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-kit/node_modules/@babel/helper-string-parser": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz", + "integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/ast-kit/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz", + "integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/ast-kit/node_modules/@babel/parser": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz", + "integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0-rc.3" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/ast-kit/node_modules/@babel/types": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz", + "integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0-rc.3", + "@babel/helper-validator-identifier": "^8.0.0-rc.3" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1807,6 +1893,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6.0" @@ -1835,6 +1922,16 @@ "require-from-string": "^2.0.2" } }, + "node_modules/birpc": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-4.0.0.tgz", + "integrity": "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -1880,6 +1977,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz", + "integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1991,6 +2098,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "dev": true, "license": "MIT", "dependencies": { "utrie": "^1.0.2" @@ -2070,6 +2178,13 @@ "dev": true, "license": "MIT" }, + "node_modules/defu": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.6.tgz", + "integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==", + "dev": true, + "license": "MIT" + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2084,7 +2199,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -2098,6 +2212,27 @@ "license": "MIT", "peer": true }, + "node_modules/dts-resolver": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/dts-resolver/-/dts-resolver-2.1.3.tgz", + "integrity": "sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "oxc-resolver": ">=11.0.0" + }, + "peerDependenciesMeta": { + "oxc-resolver": { + "optional": true + } + } + }, "node_modules/electron-to-chromium": { "version": "1.5.328", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", @@ -2105,6 +2240,16 @@ "dev": true, "license": "ISC" }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -2377,7 +2522,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -2446,7 +2590,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2467,6 +2610,19 @@ "node": ">=6.9.0" } }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2520,6 +2676,13 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hookable": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.0.tgz", + "integrity": "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==", + "dev": true, + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -2537,6 +2700,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-2.0.2.tgz", "integrity": "sha512-9G/t0XgCZWonLwL0JwI7su6NdbOPUY7Ur4Ihpp8+XMaW9ibA2nDXF181Jr6tm94k8lX6sthpaXB3XqEnsMd5Cw==", + "dev": true, "license": "MIT", "dependencies": { "css-line-break": "^2.1.0", @@ -2573,6 +2737,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-without-cache": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/import-without-cache/-/import-without-cache-0.2.5.tgz", + "integrity": "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2634,6 +2811,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -2775,7 +2953,6 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -2808,7 +2985,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2829,7 +3005,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2850,7 +3025,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2871,7 +3045,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2892,7 +3065,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2913,7 +3085,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2934,7 +3105,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2955,7 +3125,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2976,7 +3145,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2997,7 +3165,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3018,7 +3185,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3059,6 +3225,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -3139,7 +3306,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -3172,6 +3338,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3295,14 +3462,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3315,7 +3480,6 @@ "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -3400,6 +3564,7 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -3411,6 +3576,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, "license": "MIT" }, "node_modules/punycode": { @@ -3423,11 +3589,29 @@ "node": ">=6" } }, + "node_modules/quansync": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz", + "integrity": "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3437,6 +3621,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3448,6 +3633,7 @@ "version": "2.0.10", "resolved": "https://registry.npmjs.org/react-feather/-/react-feather-2.0.10.tgz", "integrity": "sha512-BLhukwJ+Z92Nmdcs+EMw6dy1Z/VLiJTzEQACDUEnWMClhYnFykJCGWQx+NmwP/qQHGX/5CzQ+TGi8ofg2+HzVQ==", + "dev": true, "license": "MIT", "dependencies": { "prop-types": "^15.7.2" @@ -3498,11 +3684,20 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", - "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.122.0", @@ -3532,11 +3727,124 @@ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" } }, + "node_modules/rolldown-plugin-dts": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.23.2.tgz", + "integrity": "sha512-PbSqLawLgZBGcOGT3yqWBGn4cX+wh2nt5FuBGdcMHyOhoukmjbhYAl8NT9sE4U38Cm9tqLOIQeOrvzeayM0DLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/generator": "8.0.0-rc.3", + "@babel/helper-validator-identifier": "8.0.0-rc.3", + "@babel/parser": "8.0.0-rc.3", + "@babel/types": "8.0.0-rc.3", + "ast-kit": "^3.0.0-beta.1", + "birpc": "^4.0.0", + "dts-resolver": "^2.1.3", + "get-tsconfig": "^4.13.7", + "obug": "^2.1.1", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@ts-macro/tsc": "^0.3.6", + "@typescript/native-preview": ">=7.0.0-dev.20260325.1", + "rolldown": "^1.0.0-rc.12", + "typescript": "^5.0.0 || ^6.0.0", + "vue-tsc": "~3.2.0" + }, + "peerDependenciesMeta": { + "@ts-macro/tsc": { + "optional": true + }, + "@typescript/native-preview": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vue-tsc": { + "optional": true + } + } + }, + "node_modules/rolldown-plugin-dts/node_modules/@babel/generator": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.3.tgz", + "integrity": "sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0-rc.3", + "@babel/types": "^8.0.0-rc.3", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/rolldown-plugin-dts/node_modules/@babel/helper-string-parser": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz", + "integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/rolldown-plugin-dts/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz", + "integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/rolldown-plugin-dts/node_modules/@babel/parser": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz", + "integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0-rc.3" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/rolldown-plugin-dts/node_modules/@babel/types": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz", + "integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0-rc.3", + "@babel/helper-validator-identifier": "^8.0.0-rc.3" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", - "dev": true, "license": "MIT" }, "node_modules/saxes": { @@ -3556,7 +3864,8 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/semver": { "version": "6.3.1", @@ -3602,7 +3911,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -3672,6 +3980,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "dev": true, "license": "MIT", "dependencies": { "utrie": "^1.0.2" @@ -3698,7 +4007,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -3767,6 +4075,16 @@ "node": ">=20" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3780,11 +4098,89 @@ "typescript": ">=4.8.4" } }, + "node_modules/tsdown": { + "version": "0.21.7", + "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.21.7.tgz", + "integrity": "sha512-ukKIxKQzngkWvOYJAyptudclkm4VQqbjq+9HF5K5qDO8GJsYtMh8gIRwicbnZEnvFPr6mquFwYAVZ8JKt3rY2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansis": "^4.2.0", + "cac": "^7.0.0", + "defu": "^6.1.4", + "empathic": "^2.0.0", + "hookable": "^6.1.0", + "import-without-cache": "^0.2.5", + "obug": "^2.1.1", + "picomatch": "^4.0.4", + "rolldown": "1.0.0-rc.12", + "rolldown-plugin-dts": "^0.23.2", + "semver": "^7.7.4", + "tinyexec": "^1.0.4", + "tinyglobby": "^0.2.15", + "tree-kill": "^1.2.2", + "unconfig-core": "^7.5.0", + "unrun": "^0.2.34" + }, + "bin": { + "tsdown": "dist/run.mjs" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@arethetypeswrong/core": "^0.18.1", + "@tsdown/css": "0.21.7", + "@tsdown/exe": "0.21.7", + "@vitejs/devtools": "*", + "publint": "^0.3.0", + "typescript": "^5.0.0 || ^6.0.0", + "unplugin-unused": "^0.5.0" + }, + "peerDependenciesMeta": { + "@arethetypeswrong/core": { + "optional": true + }, + "@tsdown/css": { + "optional": true + }, + "@tsdown/exe": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "publint": { + "optional": true + }, + "typescript": { + "optional": true + }, + "unplugin-unused": { + "optional": true + } + } + }, + "node_modules/tsdown/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD", "optional": true }, @@ -3839,6 +4235,20 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/unconfig-core": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/unconfig-core/-/unconfig-core-7.5.0.tgz", + "integrity": "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@quansync/fs": "^1.0.0", + "quansync": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/undici": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", @@ -3853,9 +4263,36 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/unrun": { + "version": "0.2.34", + "resolved": "https://registry.npmjs.org/unrun/-/unrun-0.2.34.tgz", + "integrity": "sha512-LyaghRBR++r7svhDK6tnDz2XaYHWdneBOA0jbS8wnRsHerI9MFljX4fIiTgbbNbEVzZ0C9P1OjWLLe1OqoaaEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "rolldown": "1.0.0-rc.12" + }, + "bin": { + "unrun": "dist/cli.mjs" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/Gugustinette" + }, + "peerDependencies": { + "synckit": "^0.11.11" + }, + "peerDependenciesMeta": { + "synckit": { + "optional": true + } + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3901,6 +4338,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "dev": true, "license": "MIT", "dependencies": { "base64-arraybuffer": "^1.0.2" @@ -3910,7 +4348,6 @@ "version": "8.0.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", - "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", diff --git a/package.json b/package.json index b1b4b7b..70003e6 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,48 @@ { - "name": "react-observatory", - "private": true, - "version": "0.0.0", + "name": "reactoscope", + "version": "0.1.0", "type": "module", + "description": "Explore, stress-test, and get AI feedback on your React components", + "repository": { + "type": "git", + "url": "git+https://github.com/BAJ-/react-observatory.git" + }, + "license": "MIT", + "bin": { + "reactoscope": "./bin/observe.js" + }, + "exports": { + ".": { + "import": "./dist/plugin.mjs", + "types": "./dist/plugin.d.mts" + } + }, + "files": [ + "dist/", + "bin/" + ], + "keywords": [ + "react", + "component", + "devtools", + "vite-plugin", + "stress-test", + "reactoscope" + ], + "engines": { + "node": ">=22.18.0" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "npm run build:plugin && npm run build:ui && npm run build:render", + "build:plugin": "tsdown", + "build:ui": "vite build --config vite.config.ui.ts", + "build:render": "vite build --config vite.config.render.ts", + "prepack": "npm run build", "typecheck": "tsc -b --noEmit", "lint": "eslint .", "test": "vitest run", @@ -15,10 +52,13 @@ "observe": "node bin/observe.js" }, "dependencies": { - "html2canvas-pro": "^2.0.2", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-feather": "^2.0.10" + "@vitejs/plugin-react": "^6.0.0", + "vite": "^8.0.0" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": ">=5.8.0" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -27,16 +67,17 @@ "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "html2canvas-pro": "^2.0.2", "jsdom": "^29.0.1", "prettier": "^3.8.1", + "react-feather": "^2.0.10", + "tsdown": "^0.21.7", "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", - "vite": "^8.0.1", "vitest": "^4.1.2" } } diff --git a/src/main.tsx b/src/main.tsx index 0465cb7..1fd512e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,15 +7,14 @@ async function mount() { const root = createRoot(document.getElementById('root')!) if (isRenderMode) { - const { ComponentRenderer } = - await import('./observatory/ComponentRenderer.tsx') + const { ComponentRenderer } = await import('./ui/ComponentRenderer.tsx') root.render( , ) } else { - const { default: App } = await import('./observatory/App.tsx') + const { default: App } = await import('./ui/App.tsx') root.render( diff --git a/src/observatory/plugins/aiPlugin.ts b/src/plugin/aiPlugin.ts similarity index 82% rename from src/observatory/plugins/aiPlugin.ts rename to src/plugin/aiPlugin.ts index 1700ad5..0fc6d61 100644 --- a/src/observatory/plugins/aiPlugin.ts +++ b/src/plugin/aiPlugin.ts @@ -1,10 +1,10 @@ import { readFileSync } from 'node:fs' -import { resolve, relative } from 'node:path' +import { resolve, relative, isAbsolute } from 'node:path' import type { Plugin } from 'vite' import type { IncomingMessage, ServerResponse } from 'node:http' -import { API_AI_MODELS, API_AI_CHAT } from '../constants' +import { API_AI_MODELS, API_AI_CHAT } from '../shared/constants' +import type { RootRef } from './index' -const OLLAMA_BASE = 'http://localhost:11434' const MAX_BODY_BYTES = 1_048_576 // 1 MB function readBody(req: IncomingMessage): Promise { @@ -38,9 +38,10 @@ function jsonResponse( async function handleModels( _req: IncomingMessage, res: ServerResponse, + ollamaUrl: string, ): Promise { try { - const response = await fetch(`${OLLAMA_BASE}/api/tags`) + const response = await fetch(`${ollamaUrl}/api/tags`) if (!response.ok) { jsonResponse(res, 502, { error: 'Failed to reach Ollama' }) return @@ -55,7 +56,7 @@ async function handleModels( jsonResponse(res, 200, { models }) } catch { jsonResponse(res, 502, { - error: 'Ollama is not running at ' + OLLAMA_BASE, + error: 'Ollama is not running at ' + ollamaUrl, }) } } @@ -66,10 +67,13 @@ interface ChatRequest { component?: string } -function readComponentSource(componentPath: string): string | null { - const absPath = resolve(process.cwd(), componentPath) - const rel = relative(process.cwd(), absPath) - if (rel.startsWith('..')) return null +function readComponentSource( + componentPath: string, + root: string, +): string | null { + const absPath = resolve(root, componentPath) + const rel = relative(root, absPath) + if (rel.startsWith('..') || isAbsolute(rel)) return null try { return readFileSync(absPath, 'utf-8') } catch { @@ -80,6 +84,8 @@ function readComponentSource(componentPath: string): string | null { async function handleChat( req: IncomingMessage, res: ServerResponse, + ollamaUrl: string, + rootRef: RootRef, ): Promise { if (req.method !== 'POST') { jsonResponse(res, 405, { error: 'Method not allowed' }) @@ -109,7 +115,7 @@ async function handleChat( // If component path is provided, read source and inject as system context const messages = [...params.messages] if (params.component) { - const source = readComponentSource(params.component) + const source = readComponentSource(params.component, rootRef.root) if (source) { const systemMsg = messages.find((m) => m.role === 'system') const sourceBlock = `\n\nComponent source code:\n\`\`\`tsx\n${source}\n\`\`\`` @@ -122,7 +128,7 @@ async function handleChat( } try { - const response = await fetch(`${OLLAMA_BASE}/api/chat`, { + const response = await fetch(`${ollamaUrl}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -171,25 +177,25 @@ async function handleChat( } catch { if (!res.headersSent) { jsonResponse(res, 502, { - error: 'Ollama is not running at ' + OLLAMA_BASE, + error: 'Ollama is not running at ' + ollamaUrl, }) } } } -export function aiPlugin(): Plugin { +export function aiPlugin(ollamaUrl: string, rootRef: RootRef): Plugin { return { name: 'observatory-ai', configureServer(server) { server.middlewares.use(API_AI_MODELS, (req, res) => { - handleModels(req, res).catch((err) => { + handleModels(req, res, ollamaUrl).catch((err) => { if (!res.headersSent) { jsonResponse(res, 500, { error: String(err) }) } }) }) server.middlewares.use(API_AI_CHAT, (req, res) => { - handleChat(req, res).catch((err) => { + handleChat(req, res, ollamaUrl, rootRef).catch((err) => { if (!res.headersSent) { jsonResponse(res, 500, { error: String(err) }) } diff --git a/src/plugin/findTsconfig.ts b/src/plugin/findTsconfig.ts new file mode 100644 index 0000000..7a7711e --- /dev/null +++ b/src/plugin/findTsconfig.ts @@ -0,0 +1,16 @@ +import { resolve } from 'node:path' +import { existsSync } from 'node:fs' + +/** + * Find the best tsconfig to use for TypeScript analysis. + * Prefers `tsconfig.app.json` (Vite convention), falls back to `tsconfig.json`. + */ +export function findTsconfig(root: string): string { + const app = resolve(root, 'tsconfig.app.json') + if (existsSync(app)) return app + + const base = resolve(root, 'tsconfig.json') + if (existsSync(base)) return base + + return app // fall back to app path so the error message is clear +} diff --git a/src/observatory/hydrateProps.test.ts b/src/plugin/hydrateProps.test.ts similarity index 97% rename from src/observatory/hydrateProps.test.ts rename to src/plugin/hydrateProps.test.ts index 8aa99fc..cc25670 100644 --- a/src/observatory/hydrateProps.test.ts +++ b/src/plugin/hydrateProps.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' -import type { PropInfo } from './plugins/schemaPlugin' +import type { PropInfo } from '../shared/types' import { hydrateProps } from './hydrateProps' -import { UNSET } from './generateProps' +import { UNSET } from '../shared/constants' function makeProp(overrides: Partial & { name: string }): PropInfo { return { type: 'string', required: true, ...overrides } diff --git a/src/observatory/hydrateProps.ts b/src/plugin/hydrateProps.ts similarity index 88% rename from src/observatory/hydrateProps.ts rename to src/plugin/hydrateProps.ts index ab64725..4019c22 100644 --- a/src/observatory/hydrateProps.ts +++ b/src/plugin/hydrateProps.ts @@ -1,6 +1,6 @@ -import type { PropInfo } from './plugins/schemaPlugin' -import { UNSET } from './generateProps' -import { hydrateValue } from './hydrateDescriptor' +import type { PropInfo } from '../shared/types' +import { UNSET } from '../shared/constants' +import { hydrateValue } from '../shared/hydrateDescriptor' /** * Server-safe function prop hydration. diff --git a/src/plugin/index.ts b/src/plugin/index.ts new file mode 100644 index 0000000..171dfd8 --- /dev/null +++ b/src/plugin/index.ts @@ -0,0 +1,48 @@ +import type { Plugin, ResolvedConfig } from 'vite' +import { schemaPlugin } from './schemaPlugin' +import { stressPlugin } from './stressPlugin' +import { aiPlugin } from './aiPlugin' +import { uiPlugin } from './uiPlugin' + +export interface ObservatoryOptions { + /** Ollama API base URL (default: "http://localhost:11434") */ + ollamaUrl?: string +} + +/** Mutable ref so configResolved can set root after plugin creation. */ +export interface RootRef { + root: string +} + +/** + * Create the React Observatory Vite plugin array. + * + * Usage: + * ```ts + * import { observatory } from 'reactoscope' + * export default defineConfig({ + * plugins: [react(), ...observatory()] + * }) + * ``` + */ +export function observatory(options?: ObservatoryOptions): Plugin[] { + const rootRef: RootRef = { root: process.cwd() } + const ollamaUrl = options?.ollamaUrl ?? 'http://localhost:11434' + + const rootPlugin: Plugin = { + name: 'observatory:root', + configResolved(config: ResolvedConfig) { + rootRef.root = config.root + }, + } + + return [ + rootPlugin, + uiPlugin(), + schemaPlugin(rootRef), + stressPlugin(rootRef), + aiPlugin(ollamaUrl, rootRef), + ] +} + +export type { PropInfo } from '../shared/types' diff --git a/src/observatory/plugins/schemaPlugin.ts b/src/plugin/schemaPlugin.ts similarity index 91% rename from src/observatory/plugins/schemaPlugin.ts rename to src/plugin/schemaPlugin.ts index 90074f5..3ce78df 100644 --- a/src/observatory/plugins/schemaPlugin.ts +++ b/src/plugin/schemaPlugin.ts @@ -1,26 +1,12 @@ import ts from 'typescript' -import { resolve } from 'node:path' +import { resolve, relative, isAbsolute } from 'node:path' import type { Plugin } from 'vite' -import { API_SCHEMA, HMR_SCHEMA_UPDATE } from '../constants' - -export interface PropInfo { - name: string - type: - | 'string' - | 'number' - | 'boolean' - | 'function' - | 'enum' - | 'array' - | 'object' - | 'unknown' - required: boolean - enumValues?: string[] - /** Full TypeScript signature for function props, e.g. "(n: number) => string" */ - signature?: string - /** Serializable default return value for function props, derived from the return type */ - returnDefault?: unknown -} +import { API_SCHEMA, HMR_SCHEMA_UPDATE } from '../shared/constants' +import type { PropInfo } from '../shared/types' +import { findTsconfig } from './findTsconfig' +import type { RootRef } from './index' + +export type { PropInfo } export function extractProps( filePath: string, @@ -239,7 +225,7 @@ function symbolToPropInfo( return { name: symbol.name, type: 'unknown', required } } -export function schemaPlugin(): Plugin { +export function schemaPlugin(rootRef: RootRef): Plugin { return { name: 'observatory-schema', configureServer(server) { @@ -253,17 +239,19 @@ export function schemaPlugin(): Plugin { return } - const absPath = resolve(process.cwd(), componentPath) + const root = rootRef.root + const absPath = resolve(root, componentPath) // Verify the file is inside the project root - if (!absPath.startsWith(process.cwd())) { + const rel = relative(root, absPath) + if (rel.startsWith('..') || isAbsolute(rel)) { res.writeHead(403, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Path outside project root' })) return } try { - const tsconfigPath = resolve(process.cwd(), 'tsconfig.app.json') + const tsconfigPath = findTsconfig(root) const props = extractProps(absPath, tsconfigPath) res.writeHead(200, { 'Content-Type': 'application/json' }) diff --git a/src/observatory/plugins/stressPlugin.ts b/src/plugin/stressPlugin.ts similarity index 82% rename from src/observatory/plugins/stressPlugin.ts rename to src/plugin/stressPlugin.ts index 28a6741..38fbd8f 100644 --- a/src/observatory/plugins/stressPlugin.ts +++ b/src/plugin/stressPlugin.ts @@ -1,11 +1,15 @@ -import { resolve, relative } from 'node:path' +import { resolve, relative, isAbsolute, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { existsSync } from 'node:fs' import type { Plugin, ViteDevServer } from 'vite' import type { IncomingMessage, ServerResponse } from 'node:http' -import { API_STRESS } from '../constants' -import { computeStats } from '../stressStats' -import type { StressResult } from '../analyzeHealth' +import { API_STRESS } from '../shared/constants' +import { computeStats } from '../shared/stressStats' +import type { StressResult } from '../shared/analyzeHealth' import { extractProps } from './schemaPlugin' -import { hydrateProps } from '../hydrateProps' +import { hydrateProps } from './hydrateProps' +import { findTsconfig } from './findTsconfig' +import type { RootRef } from './index' interface StressRequest { component: string @@ -51,6 +55,7 @@ async function handleStress( req: IncomingMessage, res: ServerResponse, server: ViteDevServer, + rootRef: RootRef, ): Promise { if (req.method !== 'POST') { res.writeHead(405, { 'Content-Type': 'application/json' }) @@ -101,9 +106,9 @@ async function handleStress( return } - const absPath = resolve(process.cwd(), component) - const rel = relative(process.cwd(), absPath) - if (rel.startsWith('..')) { + const absPath = resolve(rootRef.root, component) + const rel = relative(rootRef.root, absPath) + if (rel.startsWith('..') || isAbsolute(rel)) { res.writeHead(403, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Path outside project root' })) return @@ -122,12 +127,19 @@ async function handleStress( // Load the rendering helper via SSR so React is resolved through // Vite's normal externalization (avoids CJS/ESM mismatch). - const { render } = (await server.ssrLoadModule( - '/src/observatory/stressRender.ts', - )) as { render: (comp: unknown, props: Record) => string } + const selfDir = dirname(fileURLToPath(import.meta.url)) + // In dev: selfDir is src/plugin/, stressRender.ts is a sibling. + // As npm package: selfDir is dist/, stressRender.mjs is a sibling. + const localPath = resolve(selfDir, 'stressRender.ts') + const stressRenderPath = existsSync(localPath) + ? localPath + : resolve(selfDir, 'stressRender.mjs') + const { render } = (await server.ssrLoadModule(stressRenderPath)) as { + render: (comp: unknown, props: Record) => string + } // Hydrate function props so the component receives callable stubs - const tsconfigPath = resolve(process.cwd(), 'tsconfig.app.json') + const tsconfigPath = findTsconfig(rootRef.root) const propInfos = extractProps(absPath, tsconfigPath) const hydratedProps = hydrateProps(props, propInfos) @@ -212,12 +224,12 @@ async function handleStress( } } -export function stressPlugin(): Plugin { +export function stressPlugin(rootRef: RootRef): Plugin { return { name: 'observatory-stress', configureServer(server) { server.middlewares.use(API_STRESS, (req, res) => { - handleStress(req, res, server).catch((err) => { + handleStress(req, res, server, rootRef).catch((err) => { if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: String(err) })) diff --git a/src/observatory/stressRender.ts b/src/plugin/stressRender.ts similarity index 100% rename from src/observatory/stressRender.ts rename to src/plugin/stressRender.ts diff --git a/src/plugin/uiPlugin.ts b/src/plugin/uiPlugin.ts new file mode 100644 index 0000000..b8f493b --- /dev/null +++ b/src/plugin/uiPlugin.ts @@ -0,0 +1,148 @@ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { resolve, relative, isAbsolute, dirname, extname } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Plugin } from 'vite' + +const ROUTE_BASE = '/__observatory' + +const CONTENT_TYPES: Record = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', +} + +/** Inject a tag so relative asset URLs resolve under /__observatory/. */ +function injectBase(html: string): string { + return html.replace('', ``) +} + +/** + * Vite plugin that serves the pre-built Observatory UI from dist/client/. + * + * Only activates when the pre-built client directory exists (i.e., when + * reactoscope is installed as an npm package). During RO development, + * the normal Vite dev server handles the UI directly from source. + */ +const VIRTUAL_RENDER_ID = 'virtual:observatory-render' +const RESOLVED_RENDER_ID = '\0' + VIRTUAL_RENDER_ID + +export function uiPlugin(): Plugin { + const selfDir = dirname(fileURLToPath(import.meta.url)) + // When built: dist/plugin.mjs → dist/client/ and dist/render/ + const clientDir = resolve(selfDir, 'client') + const renderEntry = resolve(selfDir, 'render', 'entry.js') + + return { + name: 'observatory-ui', + resolveId(id) { + if (id === VIRTUAL_RENDER_ID) return RESOLVED_RENDER_ID + }, + load(id) { + if (id === RESOLVED_RENDER_ID && existsSync(renderEntry)) { + // Return the pre-built render entry. Vite will transform the + // bare imports (react, react-dom) into optimized dep references. + return readFileSync(renderEntry, 'utf-8') + } + }, + configureServer(server) { + if (!existsSync(clientDir)) { + // No pre-built UI — we're in RO dev mode, let Vite handle everything + return + } + + // Serve a virtual HTML page for the component iframe. + // /?render=&component=... loads ComponentRenderer via Vite's pipeline. + server.middlewares.use((req, res, next) => { + const url = req.url ?? '' + if (!url.startsWith(ROUTE_BASE)) { + next() + return + } + const qs = url.indexOf('?') + if (qs < 0) { + next() + return + } + const params = new URLSearchParams(url.slice(qs)) + if (!params.has('render')) { + next() + return + } + const html = ` + +
+ +` + server + .transformIndexHtml(url, html) + .then((transformed) => { + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end(transformed) + }) + .catch(next) + return + }) + + server.middlewares.use((req, res, next) => { + const url = req.url ?? '' + + if (!url.startsWith(ROUTE_BASE)) { + next() + return + } + + // Strip the route base to get the asset path + let assetPath = url.slice(ROUTE_BASE.length) || '/index.html' + + // Strip query strings (e.g. ?component=...) + const queryIndex = assetPath.indexOf('?') + if (queryIndex >= 0) { + assetPath = assetPath.slice(0, queryIndex) + } + + // Serve index.html for the base route (with tag injected) + if (assetPath === '/' || assetPath === '/index.html') { + const indexPath = resolve(clientDir, 'index.html') + if (existsSync(indexPath)) { + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end(injectBase(readFileSync(indexPath, 'utf-8'))) + return + } + } + + const filePath = resolve(clientDir, assetPath.slice(1)) + + // Security: ensure resolved path is within clientDir + const relPath = relative(clientDir, filePath) + if (relPath.startsWith('..') || isAbsolute(relPath)) { + res.writeHead(403) + res.end() + return + } + + if (!existsSync(filePath) || !statSync(filePath).isFile()) { + // SPA fallback: serve index.html for non-asset paths + const indexPath = resolve(clientDir, 'index.html') + if (existsSync(indexPath)) { + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end(injectBase(readFileSync(indexPath, 'utf-8'))) + return + } + res.writeHead(404) + res.end() + return + } + + const ext = extname(filePath) + const contentType = CONTENT_TYPES[ext] || 'application/octet-stream' + + res.writeHead(200, { 'Content-Type': contentType }) + res.end(readFileSync(filePath)) + }) + }, + } +} diff --git a/src/renderEntry.tsx b/src/renderEntry.tsx new file mode 100644 index 0000000..b40c106 --- /dev/null +++ b/src/renderEntry.tsx @@ -0,0 +1,6 @@ +import { StrictMode, createElement } from 'react' +import { createRoot } from 'react-dom/client' +import { ComponentRenderer } from './ui/ComponentRenderer' + +const root = createRoot(document.getElementById('root')!) +root.render(createElement(StrictMode, null, createElement(ComponentRenderer))) diff --git a/src/sandbox/LeakyButton.tsx b/src/sandbox/LeakyButton.tsx deleted file mode 100644 index 831a432..0000000 --- a/src/sandbox/LeakyButton.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/** - * A deliberately leaky component for testing. - * The module-level array grows on every render, simulating - * a component that accumulates state outside React's lifecycle. - */ - -interface LeakyButtonProps { - label: string - onClick: () => void - disabled?: boolean -} - -// This is the leak: grows on every render, never cleaned up -const renderLog: string[] = [] - -const LeakyButton = ({ - label, - onClick, - disabled = false, -}: LeakyButtonProps) => { - // Every render adds to the array AND iterates the whole thing - renderLog.push(`rendered: ${label}`) - - return ( - - ) -} - -export default LeakyButton diff --git a/src/sandbox/TestButton.tsx b/src/sandbox/TestButton.tsx deleted file mode 100644 index 71cba0a..0000000 --- a/src/sandbox/TestButton.tsx +++ /dev/null @@ -1,33 +0,0 @@ -interface TestButtonProps { - label: string - onClick: () => void - disabled?: boolean - variant?: 'primary' | 'secondary' -} - -const TestButton = ({ - label, - onClick, - disabled = false, - variant = 'primary', -}: TestButtonProps) => { - return ( - - ) -} - -export default TestButton diff --git a/src/observatory/analyzeHealth.test.ts b/src/shared/analyzeHealth.test.ts similarity index 100% rename from src/observatory/analyzeHealth.test.ts rename to src/shared/analyzeHealth.test.ts diff --git a/src/observatory/analyzeHealth.ts b/src/shared/analyzeHealth.ts similarity index 100% rename from src/observatory/analyzeHealth.ts rename to src/shared/analyzeHealth.ts diff --git a/src/observatory/constants.ts b/src/shared/constants.ts similarity index 91% rename from src/observatory/constants.ts rename to src/shared/constants.ts index 26d268a..a5daeec 100644 --- a/src/observatory/constants.ts +++ b/src/shared/constants.ts @@ -21,3 +21,6 @@ export const API_AI_CHAT = '/api/ai/chat' /** ID of the wrapper element around the rendered component in the iframe. */ export const COMPONENT_ROOT_ID = 'observatory-component-root' + +/** Sentinel value for unset props. */ +export const UNSET = '__unset__' as const diff --git a/src/observatory/hydrateDescriptor.test.ts b/src/shared/hydrateDescriptor.test.ts similarity index 100% rename from src/observatory/hydrateDescriptor.test.ts rename to src/shared/hydrateDescriptor.test.ts diff --git a/src/observatory/hydrateDescriptor.ts b/src/shared/hydrateDescriptor.ts similarity index 100% rename from src/observatory/hydrateDescriptor.ts rename to src/shared/hydrateDescriptor.ts diff --git a/src/observatory/stressStats.test.ts b/src/shared/stressStats.test.ts similarity index 100% rename from src/observatory/stressStats.test.ts rename to src/shared/stressStats.test.ts diff --git a/src/observatory/stressStats.ts b/src/shared/stressStats.ts similarity index 100% rename from src/observatory/stressStats.ts rename to src/shared/stressStats.ts diff --git a/src/shared/types.ts b/src/shared/types.ts new file mode 100644 index 0000000..d6bebeb --- /dev/null +++ b/src/shared/types.ts @@ -0,0 +1,18 @@ +export interface PropInfo { + name: string + type: + | 'string' + | 'number' + | 'boolean' + | 'function' + | 'enum' + | 'array' + | 'object' + | 'unknown' + required: boolean + enumValues?: string[] + /** Full TypeScript signature for function props, e.g. "(n: number) => string" */ + signature?: string + /** Serializable default return value for function props, derived from the return type */ + returnDefault?: unknown +} diff --git a/src/observatory/AIPanel.tsx b/src/ui/AIPanel.tsx similarity index 100% rename from src/observatory/AIPanel.tsx rename to src/ui/AIPanel.tsx diff --git a/src/observatory/App.css b/src/ui/App.css similarity index 100% rename from src/observatory/App.css rename to src/ui/App.css diff --git a/src/observatory/App.tsx b/src/ui/App.tsx similarity index 98% rename from src/observatory/App.tsx rename to src/ui/App.tsx index 94ea9e2..919caa0 100644 --- a/src/observatory/App.tsx +++ b/src/ui/App.tsx @@ -1,9 +1,9 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react' -import type { PropInfo } from './plugins/schemaPlugin' +import type { PropInfo } from '../shared/types' import { generateProps } from './generateProps' import { type SerializableProps, readPropsFromUrl } from './resolveProps' import { getMarkedSequence } from './timelineTree' -import { analyzeHealth, worstSeverity } from './analyzeHealth' +import { analyzeHealth, worstSeverity } from '../shared/analyzeHealth' import { PropsPanel } from './PropsPanel' import { ViewportControls } from './ViewportControls' import { TimelinePanel } from './TimelinePanel' @@ -18,7 +18,7 @@ import { useStress } from './useStress' import { usePinnedVariants } from './usePinnedVariants' import { useAI } from './useAI' import { AIPanel } from './AIPanel' -import { MSG_PROPS, HMR_SCHEMA_UPDATE, API_SCHEMA } from './constants' +import { MSG_PROPS, HMR_SCHEMA_UPDATE, API_SCHEMA } from '../shared/constants' import { buildIframeSrc } from './buildIframeSrc' import './App.css' diff --git a/src/observatory/ComponentRenderer.tsx b/src/ui/ComponentRenderer.tsx similarity index 94% rename from src/observatory/ComponentRenderer.tsx rename to src/ui/ComponentRenderer.tsx index 5b5e344..70e932c 100644 --- a/src/observatory/ComponentRenderer.tsx +++ b/src/ui/ComponentRenderer.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import type { PropInfo } from './plugins/schemaPlugin' +import type { PropInfo } from '../shared/types' import { resolveProps, type SerializableProps, @@ -11,7 +11,7 @@ import { MSG_RENDERED, API_SCHEMA, COMPONENT_ROOT_ID, -} from './constants' +} from '../shared/constants' export function ComponentRenderer() { const params = new URLSearchParams(window.location.search) @@ -26,7 +26,7 @@ export function ComponentRenderer() { useEffect(() => { if (!componentPath) return - import(/* @vite-ignore */ `../${componentPath.replace(/^src\//, '')}`) + import(/* @vite-ignore */ `/${componentPath}`) .then((module) => { const Comp = module.default ?? diff --git a/src/observatory/ErrorBoundary.tsx b/src/ui/ErrorBoundary.tsx similarity index 100% rename from src/observatory/ErrorBoundary.tsx rename to src/ui/ErrorBoundary.tsx diff --git a/src/observatory/HealthPanel.tsx b/src/ui/HealthPanel.tsx similarity index 97% rename from src/observatory/HealthPanel.tsx rename to src/ui/HealthPanel.tsx index 3053c90..86186db 100644 --- a/src/observatory/HealthPanel.tsx +++ b/src/ui/HealthPanel.tsx @@ -1,6 +1,10 @@ import { useMemo } from 'react' import type { StressRun } from './useStress' -import { analyzeHealth, worstSeverity, type Finding } from './analyzeHealth' +import { + analyzeHealth, + worstSeverity, + type Finding, +} from '../shared/analyzeHealth' import { X, RefreshCw, diff --git a/src/observatory/PdiffModal.tsx b/src/ui/PdiffModal.tsx similarity index 100% rename from src/observatory/PdiffModal.tsx rename to src/ui/PdiffModal.tsx diff --git a/src/observatory/PropsPanel.tsx b/src/ui/PropsPanel.tsx similarity index 97% rename from src/observatory/PropsPanel.tsx rename to src/ui/PropsPanel.tsx index a110f21..76d3659 100644 --- a/src/observatory/PropsPanel.tsx +++ b/src/ui/PropsPanel.tsx @@ -1,5 +1,5 @@ -import type { PropInfo } from './plugins/schemaPlugin' -import { UNSET } from './generateProps' +import type { PropInfo } from '../shared/types' +import { UNSET } from '../shared/constants' import { functionBehaviorOptions, type SerializableProps } from './resolveProps' interface PropsPanelProps { diff --git a/src/observatory/ScenarioPanel.tsx b/src/ui/ScenarioPanel.tsx similarity index 100% rename from src/observatory/ScenarioPanel.tsx rename to src/ui/ScenarioPanel.tsx diff --git a/src/observatory/StressModal.tsx b/src/ui/StressModal.tsx similarity index 97% rename from src/observatory/StressModal.tsx rename to src/ui/StressModal.tsx index 3053c90..86186db 100644 --- a/src/observatory/StressModal.tsx +++ b/src/ui/StressModal.tsx @@ -1,6 +1,10 @@ import { useMemo } from 'react' import type { StressRun } from './useStress' -import { analyzeHealth, worstSeverity, type Finding } from './analyzeHealth' +import { + analyzeHealth, + worstSeverity, + type Finding, +} from '../shared/analyzeHealth' import { X, RefreshCw, diff --git a/src/observatory/TimelinePanel.tsx b/src/ui/TimelinePanel.tsx similarity index 100% rename from src/observatory/TimelinePanel.tsx rename to src/ui/TimelinePanel.tsx diff --git a/src/observatory/VariantCard.tsx b/src/ui/VariantCard.tsx similarity index 100% rename from src/observatory/VariantCard.tsx rename to src/ui/VariantCard.tsx diff --git a/src/observatory/ViewportControls.tsx b/src/ui/ViewportControls.tsx similarity index 98% rename from src/observatory/ViewportControls.tsx rename to src/ui/ViewportControls.tsx index 3e9d2df..0d1bf9e 100644 --- a/src/observatory/ViewportControls.tsx +++ b/src/ui/ViewportControls.tsx @@ -1,4 +1,4 @@ -import type { Severity } from './analyzeHealth' +import type { Severity } from '../shared/analyzeHealth' import { Activity, Copy, Cpu } from 'react-feather' interface Viewport { diff --git a/src/observatory/buildIframeSrc.ts b/src/ui/buildIframeSrc.ts similarity index 86% rename from src/observatory/buildIframeSrc.ts rename to src/ui/buildIframeSrc.ts index f7de758..b72cde7 100644 --- a/src/observatory/buildIframeSrc.ts +++ b/src/ui/buildIframeSrc.ts @@ -8,5 +8,5 @@ export function buildIframeSrc( params.set('render', '') params.set('component', componentPath) params.set('props', JSON.stringify(props)) - return `/?${params.toString()}` + return `/__observatory?${params.toString()}` } diff --git a/src/observatory/captureIframe.ts b/src/ui/captureIframe.ts similarity index 93% rename from src/observatory/captureIframe.ts rename to src/ui/captureIframe.ts index 274b087..66c3f70 100644 --- a/src/observatory/captureIframe.ts +++ b/src/ui/captureIframe.ts @@ -1,5 +1,5 @@ import html2canvas from 'html2canvas-pro' -import { COMPONENT_ROOT_ID } from './constants' +import { COMPONENT_ROOT_ID } from '../shared/constants' /** * Capture the rendered component inside a same-origin iframe as ImageData. diff --git a/src/observatory/generateProps.ts b/src/ui/generateProps.ts similarity index 86% rename from src/observatory/generateProps.ts rename to src/ui/generateProps.ts index 89e3477..8d8046d 100644 --- a/src/observatory/generateProps.ts +++ b/src/ui/generateProps.ts @@ -1,6 +1,5 @@ -import type { PropInfo } from './plugins/schemaPlugin' - -export const UNSET = '__unset__' as const +import type { PropInfo } from '../shared/types' +import { UNSET } from '../shared/constants' export function generateProps(props: PropInfo[]): Record { const result: Record = {} diff --git a/src/observatory/pdiff.test.ts b/src/ui/pdiff.test.ts similarity index 100% rename from src/observatory/pdiff.test.ts rename to src/ui/pdiff.test.ts diff --git a/src/observatory/pdiff.ts b/src/ui/pdiff.ts similarity index 100% rename from src/observatory/pdiff.ts rename to src/ui/pdiff.ts diff --git a/src/observatory/resolveProps.ts b/src/ui/resolveProps.ts similarity index 93% rename from src/observatory/resolveProps.ts rename to src/ui/resolveProps.ts index 35d9487..052b143 100644 --- a/src/observatory/resolveProps.ts +++ b/src/ui/resolveProps.ts @@ -1,6 +1,6 @@ -import type { PropInfo } from './plugins/schemaPlugin' -import { UNSET } from './generateProps' -import { hydrateValue } from './hydrateDescriptor' +import type { PropInfo } from '../shared/types' +import { UNSET } from '../shared/constants' +import { hydrateValue } from '../shared/hydrateDescriptor' type FunctionBehavior = 'noop' | 'log' diff --git a/src/observatory/timelineTree.test.ts b/src/ui/timelineTree.test.ts similarity index 100% rename from src/observatory/timelineTree.test.ts rename to src/ui/timelineTree.test.ts diff --git a/src/observatory/timelineTree.ts b/src/ui/timelineTree.ts similarity index 98% rename from src/observatory/timelineTree.ts rename to src/ui/timelineTree.ts index 8b3ffe5..3abff09 100644 --- a/src/observatory/timelineTree.ts +++ b/src/ui/timelineTree.ts @@ -1,5 +1,5 @@ import type { SerializableProps } from './resolveProps' -import { UNSET } from './generateProps' +import { UNSET } from '../shared/constants' export function getNodeLabel( node: TimelineNode, diff --git a/src/observatory/useAI.ts b/src/ui/useAI.ts similarity index 98% rename from src/observatory/useAI.ts rename to src/ui/useAI.ts index 4c0fc02..3cfbd71 100644 --- a/src/observatory/useAI.ts +++ b/src/ui/useAI.ts @@ -1,9 +1,8 @@ import { useState, useCallback, useRef, useEffect } from 'react' -import type { PropInfo } from './plugins/schemaPlugin' -import type { StressResult } from './analyzeHealth' +import type { PropInfo } from '../shared/types' +import type { StressResult } from '../shared/analyzeHealth' import type { SerializableProps } from './resolveProps' -import { UNSET } from './generateProps' -import { API_AI_MODELS, API_AI_CHAT } from './constants' +import { UNSET, API_AI_MODELS, API_AI_CHAT } from '../shared/constants' export interface AIModel { name: string diff --git a/src/observatory/usePdiff.ts b/src/ui/usePdiff.ts similarity index 98% rename from src/observatory/usePdiff.ts rename to src/ui/usePdiff.ts index b9fdcb8..b8faa51 100644 --- a/src/observatory/usePdiff.ts +++ b/src/ui/usePdiff.ts @@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect } from 'react' import type { Scenario } from './useScenarios' import { compareSnapshots } from './pdiff' import { captureIframe } from './captureIframe' -import { MSG_PROPS, MSG_RENDERED } from './constants' +import { MSG_PROPS, MSG_RENDERED } from '../shared/constants' export interface StepPairDiff { beforeUrl: string diff --git a/src/observatory/usePinnedVariants.test.ts b/src/ui/usePinnedVariants.test.ts similarity index 98% rename from src/observatory/usePinnedVariants.test.ts rename to src/ui/usePinnedVariants.test.ts index 97aebe8..b34ff7f 100644 --- a/src/observatory/usePinnedVariants.test.ts +++ b/src/ui/usePinnedVariants.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest' import { renderHook, act } from '@testing-library/react' import { usePinnedVariants } from './usePinnedVariants' -const COMPONENT = 'src/sandbox/TestButton.tsx' +const COMPONENT = 'src/components/TestButton.tsx' const storageKey = `observatory:pinned:${COMPONENT}` beforeEach(() => { diff --git a/src/observatory/usePinnedVariants.ts b/src/ui/usePinnedVariants.ts similarity index 98% rename from src/observatory/usePinnedVariants.ts rename to src/ui/usePinnedVariants.ts index d165921..33d99ad 100644 --- a/src/observatory/usePinnedVariants.ts +++ b/src/ui/usePinnedVariants.ts @@ -1,6 +1,6 @@ import { useState, useCallback } from 'react' import type { SerializableProps } from './resolveProps' -import { UNSET } from './generateProps' +import { UNSET } from '../shared/constants' export interface PinnedVariant { id: string diff --git a/src/observatory/useScenarios.test.ts b/src/ui/useScenarios.test.ts similarity index 100% rename from src/observatory/useScenarios.test.ts rename to src/ui/useScenarios.test.ts diff --git a/src/observatory/useScenarios.ts b/src/ui/useScenarios.ts similarity index 100% rename from src/observatory/useScenarios.ts rename to src/ui/useScenarios.ts diff --git a/src/observatory/useStress.ts b/src/ui/useStress.ts similarity index 94% rename from src/observatory/useStress.ts rename to src/ui/useStress.ts index 744acb9..e8b2b04 100644 --- a/src/observatory/useStress.ts +++ b/src/ui/useStress.ts @@ -1,7 +1,7 @@ import { useState, useCallback, useRef } from 'react' -import type { StressResult } from './analyzeHealth' +import type { StressResult } from '../shared/analyzeHealth' import type { SerializableProps } from './resolveProps' -import { API_STRESS } from './constants' +import { API_STRESS } from '../shared/constants' export interface StressRun { running: boolean diff --git a/src/observatory/useTimeline.test.ts b/src/ui/useTimeline.test.ts similarity index 100% rename from src/observatory/useTimeline.test.ts rename to src/ui/useTimeline.test.ts diff --git a/src/observatory/useTimeline.ts b/src/ui/useTimeline.ts similarity index 100% rename from src/observatory/useTimeline.ts rename to src/ui/useTimeline.ts diff --git a/tsconfig.node.json b/tsconfig.node.json index 8a67f62..0f2f573 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -22,5 +22,10 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["vite.config.ts"] + "include": [ + "vite.config.ts", + "vite.config.ui.ts", + "vite.config.render.ts", + "tsdown.config.ts" + ] } diff --git a/tsdown.config.ts b/tsdown.config.ts new file mode 100644 index 0000000..283a959 --- /dev/null +++ b/tsdown.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: { + plugin: './src/plugin/index.ts', + stressRender: './src/plugin/stressRender.ts', + }, + outDir: './dist', + format: 'esm', + platform: 'node', + target: 'node20', + dts: { build: true }, + external: ['typescript', 'vite', 'react', 'react-dom'], +}) diff --git a/vite.config.render.ts b/vite.config.render.ts new file mode 100644 index 0000000..bb61679 --- /dev/null +++ b/vite.config.render.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + build: { + outDir: 'dist/render', + emptyOutDir: true, + lib: { + entry: 'src/renderEntry.tsx', + formats: ['es'], + fileName: 'entry', + }, + rollupOptions: { + external: [ + 'react', + 'react-dom', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + 'react-dom/client', + ], + }, + }, +}) diff --git a/vite.config.ts b/vite.config.ts index 53d3b7e..5c59bf5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,13 +1,11 @@ /// import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' -import { schemaPlugin } from './src/observatory/plugins/schemaPlugin' -import { stressPlugin } from './src/observatory/plugins/stressPlugin' -import { aiPlugin } from './src/observatory/plugins/aiPlugin' +import { observatory } from './src/plugin' // https://vite.dev/config/ export default defineConfig({ - plugins: [react(), schemaPlugin(), stressPlugin(), aiPlugin()], + plugins: [react(), ...observatory()], test: { environment: 'jsdom', globals: true, diff --git a/vite.config.ui.ts b/vite.config.ui.ts new file mode 100644 index 0000000..cb03003 --- /dev/null +++ b/vite.config.ui.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +/** + * Build config for the Observatory UI. + * Produces static assets in dist/client/ that the plugin serves at /__observatory. + */ +export default defineConfig({ + root: '.', + base: './', + plugins: [react()], + build: { + outDir: 'dist/client', + emptyOutDir: true, + }, +})