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 (
-
- {label}
-
- )
-}
-
-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 (
-
- {label}
-
- )
-}
-
-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,
+ },
+})