diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 625d56f..a3561d7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -55,3 +55,21 @@ jobs:
bun run test
bun run build
bunx tsc --noEmit
+
+ site-crawl:
+ name: Site Crawl plugin
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+ with:
+ bun-version: 1.4.2
+ - name: Install locked dependencies
+ working-directory: site-crawl-plugin
+ run: bun install --frozen-lockfile --ignore-scripts
+ - name: Check, test and compile native plugin
+ working-directory: site-crawl-plugin
+ run: |
+ bun run check
+ bun test src
+ bun run build
diff --git a/README.md b/README.md
index 250da00..82ca3a5 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ Plugins in this repo use either the Rust [`temps-plugin-sdk`](https://github.com
| Plugin | Description |
| --- | --- |
+| [`site-crawl-plugin`](./site-crawl-plugin) | Find broken internal routes, trace referring pages, and audit server-rendered SEO metadata. TypeScript; protocol v2. |
| [`example-plugin`](./example-plugin) | Minimal SEO crawler — the shortest path to understanding the plugin protocol, UI bundle layout, and SQLite-backed persistence. |
| [`lighthouse-plugin`](./lighthouse-plugin) | Runs Google Lighthouse audits after every deployment and tracks Core Web Vitals over time. |
| [`indexnow-plugin`](./indexnow-plugin) | Automatically submits deployed URLs to IndexNow-supporting search engines (Bing, Yandex, Seznam). |
@@ -102,3 +103,43 @@ the external-plugin protocol version.
## License
Dual-licensed under Apache 2.0 or MIT, matching the main [Temps](https://github.com/gotempsh/temps) repo. See [`LICENSE`](./LICENSE) and [`LICENSE-MIT`](./LICENSE-MIT).
+
+## Site Crawl
+
+Site Crawl is an administrator-only crawler with a Temps sidebar UI and SQLite-backed reports. It follows same-origin links and up to five sitemap documents, respects robots.txt and nofollow, records redirects and HTTP/network errors, and checks titles, descriptions, canonicals, headings, language, and noindex/sitemap conflicts. Each affected URL includes its referring pages and suggested fixes. Reports can be cancelled, exported as JSON, or deleted; the newest 30 are retained. Interrupted crawls are marked after restart.
+
+```sh
+cd site-crawl-plugin
+bun install --frozen-lockfile
+bun run check
+bun test src
+bun run build
+```
+
+The native executable is `site-crawl-plugin/dist/site-crawl`. This version is a source implementation, not a published registry release. Install through the normal reviewed Temps plugin publishing/installation flow; it is not added to the public catalog by this change. The folder is self-contained for publication from a dedicated plugin repository. `bun run dev` starts a loopback-only development UI at `http://127.0.0.1:3198`; that entrypoint is not distributed as the plugin.
+
+Crawls are limited to 500 URLs, one running job, a 20-minute job deadline, 10 seconds per HTTP request, 8 MiB per response, and at least 250 ms between requests (or a longer robots crawl delay). Only public HTTP/HTTPS origins on standard ports are supported. DNS results are validated and pinned for each connection; private, loopback, reserved addresses and off-origin redirects are not fetched. Manual crawls need no host API grants or AI provider. Automatic crawls require the `events_read` host permission. Native plugins still run with the host OS account's permissions; they are not sandboxed.
+
+Checks analyze returned HTML, not a browser-rendered DOM. JavaScript-only routes, authenticated pages, external links, fragment targets, and orphan pages absent from links/sitemaps are outside this first version. Missing metadata is guidance, not a guarantee of ranking or indexing. Canonical and noindex rules follow [Google Search Central's crawling and indexing guidance](https://developers.google.com/search/docs/crawling-indexing).
+
+### Crawl after deployments
+
+Site Crawl subscribes to `deployment.succeeded`. Grant **Events read** in Temps **Settings → Plugins → Permissions**. The sidebar shows automation settings even when the permission is missing, with a direct setup link. Automatic crawling is enabled by default for successful **production** deployments. Uncheck **Production only** to include preview and other environments. Disable individual projects after their first deployment event arrives, or pause automation globally.
+
+Each event queues the deployment URL after a five-second settling delay. Reports include project, environment, and deployment IDs. Crawls inspect the URL as served at crawl time; a later deployment can replace its contents before a queued crawl begins. Failed deployments are not crawled, and deployments without a supported public URL are reported as skipped.
+
+One crawl runs at a time, including manual crawls. Up to 20 deployments wait in a persistent queue; overflow is skipped with a visible notice. The last 200 deployment identities are retained to suppress duplicate events. Queued work resumes after restart; interrupted active crawls are marked interrupted rather than silently restarted. Changing project/environment settings removes queued jobs that no longer qualify. Administrators can clear the queue and cancel the active crawl separately.
+
+Live permission discovery is checked before each queued crawl. Revoking Events read pauses queued work and stops new host event delivery; an already-started crawl continues until completion or cancellation. Permissions are retried every 30 seconds. Older hosts without capability discovery can still run manual crawls but cannot activate this automation.
+
+### Design system
+
+The plugin UI uses React and an attributed snapshot of the Temps design-system components from the `design-system-ds` worktree: PageContainer, PageHeader, Button, Field, Callout, Status, PageState, and their Radix-based UI primitives. See `site-crawl-plugin/web/vendor/README.md` for provenance and update instructions. The Vite build embeds the UI into the native executable; no local-worktree dependency or external frontend service is required. Light/dark themes, keyboard-accessible dialogs and tabs, and responsive tables are supported.
+
+The 8 MiB response bound accommodates larger documentation HTML. Responses above this limit retain their observed HTTP status and report an incomplete-inspection warning rather than claiming a broken route. Existing saved reports retain their original results; rerun a crawl to apply the new behavior.
+
+### Tokenizer-based parsing
+
+HTML analysis and sitemap discovery use `htmlparser2` callbacks instead of constructing a Cheerio DOM. Only bounded SEO fields and crawl targets are retained: titles up to 1,000 characters, descriptions up to 2,000, and at most 2,000 links per page. Script/template/noscript/SVG content cannot introduce phantom page metadata or crawl links. XML sitemap parsing preserves namespaced URL discovery and document/URL caps.
+
+HTTP downloads still buffer at most 8 MiB before tokenization; this is not network-streaming analysis and does not execute JavaScript. HTML nesting over 128 levels and XML nesting over 64 levels stop inspection with a contextual warning/notice. Existing DNS, redirects, robots, scheduling and permission checks continue to apply.
diff --git a/site-crawl-plugin/.gitignore b/site-crawl-plugin/.gitignore
new file mode 100644
index 0000000..0dcce0e
--- /dev/null
+++ b/site-crawl-plugin/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+dist/
+.data/
+
+web/dist/
diff --git a/site-crawl-plugin/bun.lock b/site-crawl-plugin/bun.lock
new file mode 100644
index 0000000..cb98779
--- /dev/null
+++ b/site-crawl-plugin/bun.lock
@@ -0,0 +1,386 @@
+{
+ "lockfileVersion": 2,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "@temps-plugins/site-crawl",
+ "dependencies": {
+ "@radix-ui/react-alert-dialog": "1.1.23",
+ "@radix-ui/react-checkbox": "1.3.11",
+ "@radix-ui/react-label": "2.1.15",
+ "@radix-ui/react-slot": "1.3.3",
+ "@radix-ui/react-tabs": "1.1.21",
+ "@tanstack/react-query": "5.102.8",
+ "@temps-sdk/plugin": "0.1.0-beta.1",
+ "class-variance-authority": "0.7.1",
+ "clsx": "2.1.1",
+ "htmlparser2": "10.0.0",
+ "ipaddr.js": "2.2.0",
+ "lucide-react": "1.45.0",
+ "react": "19.3.0",
+ "react-dom": "19.3.0",
+ "robots-parser": "3.0.1",
+ "tailwind-merge": "3.6.0",
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "4.3.3",
+ "@types/bun": "1.3.3",
+ "@types/react": "19.3.0",
+ "@types/react-dom": "19.3.0",
+ "@types/ws": "8.18.1",
+ "tailwindcss": "4.3.3",
+ "typescript": "5.9.3",
+ "vite": "7.3.5",
+ "ws": "8.21.0",
+ },
+ },
+ },
+ "overrides": {
+ "esbuild": "0.28.1",
+ },
+ "packages": {
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
+
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
+ "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
+
+ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="],
+
+ "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dialog": "1.1.23", "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA=="],
+
+ "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ=="],
+
+ "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.15", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA=="],
+
+ "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
+
+ "@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="],
+
+ "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA=="],
+
+ "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg=="],
+
+ "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w=="],
+
+ "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.6", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ=="],
+
+ "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.16", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ=="],
+
+ "@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="],
+
+ "@radix-ui/react-label": ["@radix-ui/react-label@2.1.15", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g=="],
+
+ "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.17", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ=="],
+
+ "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="],
+
+ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="],
+
+ "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-collection": "1.1.15", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-is-hydrated": "0.1.3", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ=="],
+
+ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="],
+
+ "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.21", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-direction": "1.1.4", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-roving-focus": "1.1.19", "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog=="],
+
+ "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="],
+
+ "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ=="],
+
+ "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg=="],
+
+ "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw=="],
+
+ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="],
+
+ "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw=="],
+
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.3", "", { "os": "android", "cpu": "arm" }, "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw=="],
+
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.3", "", { "os": "android", "cpu": "arm64" }, "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ=="],
+
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ=="],
+
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg=="],
+
+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg=="],
+
+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ=="],
+
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.3", "", { "os": "linux", "cpu": "arm" }, "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg=="],
+
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.3", "", { "os": "linux", "cpu": "arm" }, "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg=="],
+
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA=="],
+
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ=="],
+
+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.3", "", { "os": "linux", "cpu": "none" }, "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg=="],
+
+ "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.3", "", { "os": "linux", "cpu": "none" }, "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ=="],
+
+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A=="],
+
+ "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w=="],
+
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.3", "", { "os": "linux", "cpu": "none" }, "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA=="],
+
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.3", "", { "os": "linux", "cpu": "none" }, "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA=="],
+
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw=="],
+
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA=="],
+
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.3", "", { "os": "linux", "cpu": "x64" }, "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ=="],
+
+ "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ=="],
+
+ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.3", "", { "os": "none", "cpu": "arm64" }, "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg=="],
+
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q=="],
+
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA=="],
+
+ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.3", "", { "os": "win32", "cpu": "x64" }, "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ=="],
+
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.3", "", { "os": "win32", "cpu": "x64" }, "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA=="],
+
+ "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
+
+ "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
+
+ "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
+
+ "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
+
+ "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
+
+ "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
+
+ "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
+
+ "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
+
+ "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
+
+ "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
+
+ "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
+
+ "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
+
+ "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
+
+ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
+
+ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
+
+ "@tanstack/query-core": ["@tanstack/query-core@5.102.8", "", {}, "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg=="],
+
+ "@tanstack/react-query": ["@tanstack/react-query@5.102.8", "", { "dependencies": { "@tanstack/query-core": "5.102.8" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A=="],
+
+ "@temps-sdk/plugin": ["@temps-sdk/plugin@0.1.0-beta.1", "", {}, "sha512-mvadvLOj7UNtXy9+p+eJv+c+p2cbOZh62I5NnKwl+aUkCuKk7Np0PZEKwdftiziO2+4SY2SbMG6A7Q7a56QiMw=="],
+
+ "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="],
+
+ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
+
+ "@types/node": ["@types/node@22.20.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A=="],
+
+ "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
+
+ "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
+
+ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
+
+ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
+
+ "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
+
+ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
+
+ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
+
+ "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
+
+ "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
+
+ "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
+
+ "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
+
+ "enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="],
+
+ "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
+
+ "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
+
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
+
+ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+
+ "htmlparser2": ["htmlparser2@10.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.1", "entities": "^6.0.0" } }, "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g=="],
+
+ "ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
+
+ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
+
+ "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
+
+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
+
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
+
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
+
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
+
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
+
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
+
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
+
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
+
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
+
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
+
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
+
+ "lucide-react": ["lucide-react@1.45.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-yH1ubCAduho9UR7oJhRXIQXogksRILBiTuZC4/bQIGeB9JOkxMlSuEHyyZpo1Z3S0yWJO2KTSUZbjiNvVxeOUw=="],
+
+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
+ "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
+
+ "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
+
+ "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
+
+ "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
+
+ "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
+
+ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
+
+ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
+
+ "robots-parser": ["robots-parser@3.0.1", "", {}, "sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ=="],
+
+ "rollup": ["rollup@4.63.3", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.3", "@rollup/rollup-android-arm64": "4.63.3", "@rollup/rollup-darwin-arm64": "4.63.3", "@rollup/rollup-darwin-x64": "4.63.3", "@rollup/rollup-freebsd-arm64": "4.63.3", "@rollup/rollup-freebsd-x64": "4.63.3", "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", "@rollup/rollup-linux-arm-musleabihf": "4.63.3", "@rollup/rollup-linux-arm64-gnu": "4.63.3", "@rollup/rollup-linux-arm64-musl": "4.63.3", "@rollup/rollup-linux-loong64-gnu": "4.63.3", "@rollup/rollup-linux-loong64-musl": "4.63.3", "@rollup/rollup-linux-ppc64-gnu": "4.63.3", "@rollup/rollup-linux-ppc64-musl": "4.63.3", "@rollup/rollup-linux-riscv64-gnu": "4.63.3", "@rollup/rollup-linux-riscv64-musl": "4.63.3", "@rollup/rollup-linux-s390x-gnu": "4.63.3", "@rollup/rollup-linux-x64-gnu": "4.63.3", "@rollup/rollup-linux-x64-musl": "4.63.3", "@rollup/rollup-openbsd-x64": "4.63.3", "@rollup/rollup-openharmony-arm64": "4.63.3", "@rollup/rollup-win32-arm64-msvc": "4.63.3", "@rollup/rollup-win32-ia32-msvc": "4.63.3", "@rollup/rollup-win32-x64-gnu": "4.63.3", "@rollup/rollup-win32-x64-msvc": "4.63.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw=="],
+
+ "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
+
+ "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
+
+ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
+
+ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
+
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
+ "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
+
+ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
+
+ "vite": ["vite@7.3.5", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww=="],
+
+ "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+ }
+}
diff --git a/site-crawl-plugin/package.json b/site-crawl-plugin/package.json
new file mode 100644
index 0000000..4cf4c95
--- /dev/null
+++ b/site-crawl-plugin/package.json
@@ -0,0 +1,52 @@
+{
+ "name": "@temps-plugins/site-crawl",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "Find broken internal routes and technical SEO issues in deployed sites",
+ "scripts": {
+ "test": "bun test src",
+ "check": "tsc --noEmit",
+ "build": "bun run build:ui && bun build src/index.ts --compile --outfile dist/site-crawl",
+ "dev": "bun run build:ui && bun src/dev.ts",
+ "build:ui": "vite build"
+ },
+ "dependencies": {
+ "@radix-ui/react-alert-dialog": "1.1.23",
+ "@radix-ui/react-checkbox": "1.3.11",
+ "@radix-ui/react-label": "2.1.15",
+ "@radix-ui/react-slot": "1.3.3",
+ "@radix-ui/react-tabs": "1.1.21",
+ "@tanstack/react-query": "5.102.8",
+ "@temps-sdk/plugin": "0.1.0-beta.1",
+ "class-variance-authority": "0.7.1",
+ "clsx": "2.1.1",
+ "htmlparser2": "10.0.0",
+ "ipaddr.js": "2.2.0",
+ "lucide-react": "1.45.0",
+ "react": "19.3.0",
+ "react-dom": "19.3.0",
+ "robots-parser": "3.0.1",
+ "tailwind-merge": "3.6.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "4.3.3",
+ "@types/bun": "1.3.3",
+ "@types/react": "19.3.0",
+ "@types/react-dom": "19.3.0",
+ "@types/ws": "8.18.1",
+ "tailwindcss": "4.3.3",
+ "typescript": "5.9.3",
+ "vite": "7.3.5",
+ "ws": "8.21.0"
+ },
+ "temps": {
+ "name": "site-crawl",
+ "title": "Site Crawl",
+ "category": "SEO",
+ "entrypoint": "src/index.ts"
+ },
+ "overrides": {
+ "esbuild": "0.28.1"
+ }
+}
diff --git a/site-crawl-plugin/src/app.test.ts b/site-crawl-plugin/src/app.test.ts
new file mode 100644
index 0000000..dcf6f0f
--- /dev/null
+++ b/site-crawl-plugin/src/app.test.ts
@@ -0,0 +1,113 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+import { expect, test } from "bun:test";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { Store } from "./store";
+import { createApp } from "./app";
+import type { Report } from "./types";
+const request = (path: string, method = "GET", body?: unknown) =>
+ new Request(`http://plugin${path}`, {
+ method,
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ });
+test("API validates input, persists crawl evidence and exports/deletes report", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "site-crawl-test-"));
+ const store = new Store(dir);
+ const app = createApp(store, {
+ delayMs: 0,
+ fetch: async (url) => ({
+ status: url.pathname === "/" ? 200 : 404,
+ headers: { "content-type": "text/html" },
+ body: '
FixtureFixture
',
+ }),
+ });
+ try {
+ expect(
+ (
+ await app.fetch(
+ request("/api/reports", "POST", { url: "file:///etc/passwd" }),
+ )
+ ).status,
+ ).toBe(400);
+ expect(
+ (
+ await app.fetch(
+ request("/api/reports", "POST", {
+ url: "https://example.com",
+ maxPages: 501,
+ }),
+ )
+ ).status,
+ ).toBe(400);
+ expect(
+ (
+ await app.fetch(
+ request("/api/reports", "POST", {
+ url: "https://example.com",
+ maxPages: 1.5,
+ }),
+ )
+ ).status,
+ ).toBe(400);
+ const created = await app.fetch(
+ request("/api/reports", "POST", {
+ url: "https://example.com",
+ maxPages: 5,
+ }),
+ );
+ expect(created.status).toBe(202);
+ const { id } = (await created.json()) as { id: string };
+ expect(
+ (
+ await app.fetch(
+ request("/api/reports", "POST", { url: "https://example.com" }),
+ )
+ ).status,
+ ).toBe(409);
+ for (let i = 0; i < 100 && store.get(id)?.state === "running"; i++)
+ await Bun.sleep(5);
+ expect(store.get(id)?.state).toBe("completed");
+ expect(store.get(id)?.pages[0]?.title).toBe("Fixture");
+ const exported = await app.fetch(request(`/api/reports/${id}/export`));
+ expect(exported.status).toBe(200);
+ expect(exported.headers.get("content-disposition")).toContain(id);
+ expect(
+ (await app.fetch(request(`/api/reports/${id}`, "DELETE"))).status,
+ ).toBe(200);
+ expect(store.get(id)).toBeNull();
+ expect((await app.fetch(request(`/api/reports/${id}`))).status).toBe(404);
+ } finally {
+ await app.close();
+ rmSync(dir, { recursive: true });
+ }
+});
+test("restart marks active crawl interrupted; retained history is bounded", () => {
+ const dir = mkdtempSync(join(tmpdir(), "site-crawl-store-"));
+ let store = new Store(dir);
+ try {
+ for (let i = 0; i < 35; i++)
+ store.save({
+ id: String(i),
+ url: "https://example.com/",
+ maxPages: 1,
+ state: i === 34 ? "running" : "completed",
+ startedAt: new Date(i * 1000).toISOString(),
+ finishedAt: null,
+ pages: [],
+ notices: [],
+ discovered: 0,
+ limited: false,
+ error: null,
+ } satisfies Report);
+ expect(store.list()).toHaveLength(30);
+ store.close();
+ store = new Store(dir);
+ expect(store.get("34")?.state).toBe("interrupted");
+ expect(store.get("34")?.error).toContain("restarted");
+ } finally {
+ store.close();
+ rmSync(dir, { recursive: true });
+ }
+});
diff --git a/site-crawl-plugin/src/app.ts b/site-crawl-plugin/src/app.ts
new file mode 100644
index 0000000..29fe9a1
--- /dev/null
+++ b/site-crawl-plugin/src/app.ts
@@ -0,0 +1,156 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+import type { PluginEvent } from "@temps-sdk/plugin";
+import { Scheduler, type SchedulerOptions } from "./automation";
+import { normalize } from "./http";
+import { Store } from "./store";
+import { message } from "./types";
+async function readJson(request: Request): Promise {
+ const reader = request.body?.getReader();
+ let bytes = 0;
+ const chunks: Uint8Array[] = [];
+ if (reader)
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ bytes += value.length;
+ if (bytes > 4096) {
+ await reader.cancel();
+ throw new Error("Request exceeds 4 KiB.");
+ }
+ chunks.push(value);
+ }
+ try {
+ return JSON.parse(Buffer.concat(chunks).toString());
+ } catch {
+ throw new Error("Send a valid JSON object.");
+ }
+}
+export function createApp(store: Store, options: SchedulerOptions = {}) {
+ const scheduler = new Scheduler(store, options);
+ scheduler.kick();
+ const json = (body: unknown, status = 200) =>
+ Response.json(body, { status, headers: { "Cache-Control": "no-store" } });
+ return {
+ deployment(event: PluginEvent) {
+ scheduler.deployment(event);
+ },
+ async fetch(request: Request): Promise {
+ const path = new URL(request.url).pathname;
+ try {
+ if (request.method === "GET" && path === "/api/automation")
+ return json(await scheduler.status());
+ if (request.method === "PUT" && path === "/api/automation") {
+ try {
+ return json(scheduler.update(await readJson(request)));
+ } catch (error) {
+ return json({ error: message(error) }, 400);
+ }
+ }
+ if (request.method === "DELETE" && path === "/api/automation/queue") {
+ scheduler.clearQueue();
+ return json({ cleared: true });
+ }
+ if (request.method === "GET" && path === "/api/reports")
+ return json(store.list());
+ if (request.method === "POST" && path === "/api/reports") {
+ if (scheduler.busy())
+ return json(
+ {
+ error:
+ "A crawl is running or queued. Wait or cancel it before starting another.",
+ },
+ 409,
+ );
+ let body: unknown;
+ try {
+ body = await readJson(request);
+ } catch (error) {
+ return json({ error: message(error) }, 400);
+ }
+ if (
+ !body ||
+ typeof body !== "object" ||
+ !("url" in body) ||
+ typeof body.url !== "string"
+ )
+ return json({ error: "A site URL is required." }, 400);
+ const input = body as { url: string; maxPages?: unknown };
+ const maxPages = input.maxPages ?? 100;
+ if (
+ typeof maxPages !== "number" ||
+ !Number.isInteger(maxPages) ||
+ maxPages < 1 ||
+ maxPages > 500
+ )
+ return json(
+ { error: "Page limit must be an integer from 1 to 500." },
+ 400,
+ );
+ let target: URL;
+ try {
+ target = normalize(input.url);
+ } catch (error) {
+ return json({ error: message(error) }, 400);
+ }
+ if (scheduler.busy())
+ return json(
+ { error: "A crawl is already running or queued." },
+ 409,
+ );
+ return json({ id: scheduler.manual(target.href, maxPages) }, 202);
+ }
+ const match =
+ /^\/api\/reports\/([a-f0-9-]{36})(\/cancel|\/export)?$/.exec(path);
+ if (match) {
+ const id = match[1]!;
+ const report = store.get(id);
+ if (!report) return json({ error: "Report not found." }, 404);
+ if (request.method === "GET" && match[2] === "/export")
+ return new Response(JSON.stringify(report, null, 2), {
+ headers: {
+ "Content-Type": "application/json",
+ "Content-Disposition": `attachment; filename="site-crawl-${id}.json"`,
+ "Cache-Control": "no-store",
+ },
+ });
+ if (request.method === "GET" && !match[2]) return json(report);
+ if (request.method === "POST" && match[2] === "/cancel") {
+ if (!scheduler.cancel(id))
+ return json({ error: "This crawl is no longer running." }, 409);
+ return json({ cancelling: true });
+ }
+ if (request.method === "DELETE" && !match[2]) {
+ if (scheduler.isActive(id))
+ return json(
+ { error: "Cancel the crawl before deleting its report." },
+ 409,
+ );
+ store.delete(id);
+ return json({ deleted: true });
+ }
+ }
+ return json({ error: "Route not found." }, 404);
+ } catch (error) {
+ console.error(
+ JSON.stringify({
+ level: "error",
+ operation: `${request.method} ${path}`,
+ error: message(error),
+ }),
+ );
+ return json(
+ {
+ error:
+ "The report or settings could not be saved or loaded. Check plugin storage and retry.",
+ },
+ 500,
+ );
+ }
+ },
+ async close() {
+ await scheduler.close();
+ store.close();
+ },
+ };
+}
diff --git a/site-crawl-plugin/src/assets.ts b/site-crawl-plugin/src/assets.ts
new file mode 100644
index 0000000..47361b0
--- /dev/null
+++ b/site-crawl-plugin/src/assets.ts
@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+import html from "../web/dist/index.html" with { type: "text" };
+import css from "../web/dist/style.css" with { type: "text" };
+import js from "../web/dist/app.js" with { type: "text" };
+export const assets = new Map([
+ [
+ "index.html",
+ {
+ content: Buffer.from(html as unknown as string),
+ contentType: "text/html; charset=utf-8",
+ immutable: false,
+ },
+ ],
+ [
+ "style.css",
+ {
+ content: Buffer.from(css),
+ contentType: "text/css; charset=utf-8",
+ immutable: false,
+ },
+ ],
+ [
+ "app.js",
+ {
+ content: Buffer.from(js),
+ contentType: "application/javascript; charset=utf-8",
+ immutable: false,
+ },
+ ],
+]);
diff --git a/site-crawl-plugin/src/automation.test.ts b/site-crawl-plugin/src/automation.test.ts
new file mode 100644
index 0000000..45bbff4
--- /dev/null
+++ b/site-crawl-plugin/src/automation.test.ts
@@ -0,0 +1,176 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+import { expect, test } from "bun:test";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import type { PluginEvent, TempsClient } from "@temps-sdk/plugin";
+import { Scheduler, DEFAULT_AUTOMATION } from "./automation";
+import { Store } from "./store";
+import { eventAccess } from "./host";
+const event = (
+ id: number,
+ environment = "production",
+ project = 1,
+): PluginEvent => ({
+ id: String(id),
+ event_type: "deployment.succeeded",
+ project_id: project,
+ timestamp: new Date().toISOString(),
+ data: {
+ deployment_id: id,
+ environment_id: 1,
+ environment_name: environment,
+ url: "https://example.com/",
+ },
+});
+const access = async () => ({
+ configured: true,
+ reason: null,
+ setupPath: "/settings/plugins",
+});
+const fetchPage = async (url: URL) => ({
+ status: url.pathname === "/" ? 200 : 404,
+ headers: { "content-type": "text/html" },
+ body: "FixtureFixture
",
+});
+async function until(check: () => boolean) {
+ for (let i = 0; i < 200; i++) {
+ if (check()) return;
+ await Bun.sleep(5);
+ }
+ throw new Error("Crawl did not finish");
+}
+test("deployment queue filters, deduplicates and attributes completed reports", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "crawl-auto-"));
+ const store = new Store(dir);
+ const scheduler = new Scheduler(store, {
+ hostAccess: access,
+ settleMs: 0,
+ delayMs: 0,
+ fetch: fetchPage,
+ });
+ try {
+ scheduler.deployment(event(1, "preview"));
+ scheduler.deployment({ ...event(2), event_type: "deployment.failed" });
+ scheduler.deployment(event(3));
+ scheduler.deployment(event(3));
+ expect(() => scheduler.manual("https://example.com/", 1)).toThrow("queued");
+ await until(
+ () => store.list().length === 1 && store.list()[0]?.state === "completed",
+ );
+ expect(store.list()[0]?.trigger?.deploymentId).toBe(3);
+ expect(store.list()[0]?.trigger?.projectId).toBe(1);
+ scheduler.update({
+ ...DEFAULT_AUTOMATION,
+ productionOnly: false,
+ excludedProjects: [2],
+ });
+ scheduler.deployment(event(4, "preview"));
+ scheduler.deployment(event(5, "production", 2));
+ await until(
+ () =>
+ store.list().length === 2 &&
+ store.list().every((r) => r.state === "completed"),
+ );
+ expect((await scheduler.status()).queued).toBe(0);
+ } finally {
+ await scheduler.close();
+ store.close();
+ rmSync(dir, { recursive: true });
+ }
+});
+test("queue persists across restart, stays bounded and obeys permission revocation", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "crawl-auto-"));
+ let store = new Store(dir);
+ let scheduler = new Scheduler(store, {
+ settleMs: 0,
+ hostAccess: async () => ({ ...(await access()), configured: false }),
+ });
+ try {
+ for (let i = 1; i <= 25; i++) scheduler.deployment(event(i));
+ await Bun.sleep(20);
+ expect((await scheduler.status()).queued).toBe(20);
+ expect(store.list()).toHaveLength(0);
+ await scheduler.close();
+ store.close();
+ store = new Store(dir);
+ scheduler = new Scheduler(store, {
+ hostAccess: access,
+ settleMs: 0,
+ delayMs: 0,
+ fetch: fetchPage,
+ });
+ scheduler.kick();
+ await until(
+ () =>
+ store.list().length === 20 &&
+ store.list().every((r) => r.state === "completed"),
+ );
+ scheduler.deployment(event(25));
+ expect((await scheduler.status()).queued).toBe(0);
+ } finally {
+ await scheduler.close();
+ store.close();
+ rmSync(dir, { recursive: true });
+ }
+});
+test("settings prune queued work and crawls never overlap", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "crawl-auto-"));
+ const store = new Store(dir);
+ let active = 0;
+ let peak = 0;
+ const scheduler = new Scheduler(store, {
+ hostAccess: access,
+ settleMs: 0,
+ delayMs: 0,
+ fetch: async (url) => {
+ active++;
+ peak = Math.max(peak, active);
+ await Bun.sleep(5);
+ active--;
+ return fetchPage(url);
+ },
+ });
+ try {
+ scheduler.deployment(event(1));
+ scheduler.deployment(event(2));
+ await until(
+ () =>
+ store.list().length === 2 &&
+ store.list().every((r) => r.state === "completed"),
+ );
+ expect(peak).toBe(1);
+ scheduler.deployment(event(3));
+ scheduler.update({ ...DEFAULT_AUTOMATION, enabled: false });
+ expect((await scheduler.status()).queued).toBe(0);
+ expect(() =>
+ scheduler.update({ ...DEFAULT_AUTOMATION, maxPages: 501 }),
+ ).toThrow();
+ expect(() =>
+ scheduler.update({ ...DEFAULT_AUTOMATION, excludedProjects: [-1] }),
+ ).toThrow();
+ scheduler.deployment({ ...event(4), data: {} });
+ expect(store.list()).toHaveLength(2);
+ } finally {
+ await scheduler.close();
+ store.close();
+ rmSync(dir, { recursive: true });
+ }
+});
+test("host permission discovery validates grants and malformed responses", async () => {
+ const client = (result: unknown) =>
+ ({
+ call: async (method: string) => {
+ expect(method).toBe("get_host_capabilities");
+ return result;
+ },
+ }) as unknown as TempsClient;
+ expect(
+ (await eventAccess(client({ permissions: ["events_read"] }))).configured,
+ ).toBe(true);
+ expect((await eventAccess(client({ permissions: [] }))).configured).toBe(
+ false,
+ );
+ await expect(eventAccess(client({}))).rejects.toThrow("permission discovery");
+});
diff --git a/site-crawl-plugin/src/automation.ts b/site-crawl-plugin/src/automation.ts
new file mode 100644
index 0000000..988f265
--- /dev/null
+++ b/site-crawl-plugin/src/automation.ts
@@ -0,0 +1,348 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+import type { PluginEvent } from "@temps-sdk/plugin";
+import { crawl, type CrawlOptions } from "./crawler";
+import { normalize } from "./http";
+import { Store } from "./store";
+import {
+ message,
+ type AutomationSettings,
+ type DeploymentTrigger,
+ type HostEventAccess,
+ type Report,
+} from "./types";
+export const DEFAULT_AUTOMATION: AutomationSettings = {
+ enabled: true,
+ productionOnly: true,
+ maxPages: 100,
+ excludedProjects: [],
+};
+interface Pending {
+ key: string;
+ url: string;
+ trigger: DeploymentTrigger;
+ readyAt: number;
+ maxPages: number;
+}
+interface QueueState {
+ pending: Pending[];
+ seen: string[];
+ projects: { id: number; url: string }[];
+ lastEventAt: string | null;
+ notice: string | null;
+}
+export interface SchedulerOptions extends CrawlOptions {
+ hostAccess?: () => Promise;
+ settleMs?: number;
+}
+export class Scheduler {
+ private active: {
+ id: string;
+ controller: AbortController;
+ done: Promise;
+ } | null = null;
+ private timer: ReturnType | null = null;
+ private dispatching = false;
+ private closed = false;
+ constructor(
+ private store: Store,
+ private options: SchedulerOptions = {},
+ ) {}
+ private queue(): QueueState {
+ return this.store.metadata("queue", {
+ pending: [],
+ seen: [],
+ projects: [],
+ lastEventAt: null,
+ notice: null,
+ });
+ }
+ settings() {
+ return this.store.metadata("automation", {
+ ...DEFAULT_AUTOMATION,
+ excludedProjects: [],
+ });
+ }
+ async access(): Promise {
+ try {
+ return (
+ (await this.options.hostAccess?.()) ?? {
+ configured: false,
+ reason:
+ "Automatic crawling requires the Temps host and Events read permission.",
+ setupPath: "/settings/plugins",
+ }
+ );
+ } catch {
+ return {
+ configured: false,
+ reason:
+ "Cannot verify Events read permission. Check the plugin connection and permissions.",
+ setupPath: "/settings/plugins",
+ };
+ }
+ }
+ async status() {
+ const queue = this.queue();
+ return {
+ settings: this.settings(),
+ access: await this.access(),
+ queued: queue.pending.length,
+ activeReportId: this.active?.id ?? null,
+ projects: queue.projects,
+ lastEventAt: queue.lastEventAt,
+ notice: queue.notice,
+ };
+ }
+ update(input: unknown): AutomationSettings {
+ if (!input || typeof input !== "object")
+ throw new Error("Send automation settings as a JSON object.");
+ const body = input as Record;
+ if (
+ typeof body.enabled !== "boolean" ||
+ typeof body.productionOnly !== "boolean" ||
+ typeof body.maxPages !== "number" ||
+ !Number.isInteger(body.maxPages) ||
+ body.maxPages < 1 ||
+ body.maxPages > 500 ||
+ !Array.isArray(body.excludedProjects) ||
+ body.excludedProjects.length > 200 ||
+ body.excludedProjects.some((id) => !Number.isSafeInteger(id) || id <= 0)
+ )
+ throw new Error(
+ "Settings require enabled, productionOnly, a URL limit from 1 to 500, and at most 200 excluded project IDs.",
+ );
+ const settings: AutomationSettings = {
+ enabled: body.enabled,
+ productionOnly: body.productionOnly,
+ maxPages: body.maxPages,
+ excludedProjects: [...new Set(body.excludedProjects)] as number[],
+ };
+ const queue = this.queue();
+ const count = queue.pending.length;
+ queue.pending = queue.pending.filter((job) =>
+ this.eligible(job.trigger, settings),
+ );
+ if (queue.pending.length !== count)
+ queue.notice = `${count - queue.pending.length} queued crawl(s) removed by the new automation settings.`;
+ this.store.transaction(() => {
+ this.store.setMetadata("automation", settings);
+ this.store.setMetadata("queue", queue);
+ });
+ this.kick();
+ return settings;
+ }
+ private eligible(trigger: DeploymentTrigger, settings: AutomationSettings) {
+ return (
+ settings.enabled &&
+ !settings.excludedProjects.includes(trigger.projectId) &&
+ (!settings.productionOnly ||
+ trigger.environmentName.toLowerCase() === "production")
+ );
+ }
+ deployment(event: PluginEvent) {
+ if (this.closed || event.event_type !== "deployment.succeeded") return;
+ const data = event.data;
+ const projectId = event.project_id ?? data.project_id;
+ if (
+ typeof projectId !== "number" ||
+ !Number.isSafeInteger(projectId) ||
+ projectId <= 0 ||
+ typeof data.environment_id !== "number" ||
+ !Number.isSafeInteger(data.environment_id) ||
+ data.environment_id <= 0 ||
+ typeof data.deployment_id !== "number" ||
+ !Number.isSafeInteger(data.deployment_id) ||
+ data.deployment_id <= 0 ||
+ typeof data.environment_name !== "string" ||
+ data.environment_name.length > 100
+ )
+ return;
+ const trigger = {
+ projectId,
+ environmentId: data.environment_id,
+ deploymentId: data.deployment_id,
+ environmentName: data.environment_name,
+ };
+ const queue = this.queue();
+ const key = `${projectId}:${trigger.environmentId}:${trigger.deploymentId}`;
+ if (queue.seen.includes(key)) return;
+ queue.seen.push(key);
+ queue.seen = queue.seen.slice(-200);
+ queue.lastEventAt = new Date().toISOString();
+ let url: string;
+ try {
+ if (typeof data.url !== "string")
+ throw new Error("Deployment has no URL.");
+ url = normalize(data.url).href;
+ } catch {
+ queue.notice =
+ "A deployment was received without a supported public HTTP/HTTPS URL. Start a manual crawl using its public domain.";
+ this.store.setMetadata("queue", queue);
+ return;
+ }
+ queue.projects = [
+ { id: projectId, url },
+ ...queue.projects.filter((project) => project.id !== projectId),
+ ].slice(0, 200);
+ if (this.eligible(trigger, this.settings())) {
+ if (queue.pending.length >= 20)
+ queue.notice =
+ "The 20-deployment crawl queue is full. This deployment was not queued; run a manual crawl after the queue clears.";
+ else
+ queue.pending.push({
+ key,
+ url,
+ trigger,
+ readyAt: Date.now() + (this.options.settleMs ?? 5000),
+ maxPages: this.settings().maxPages,
+ });
+ }
+ this.store.setMetadata("queue", queue);
+ this.kick();
+ }
+ private makeReport(
+ url: string,
+ maxPages: number,
+ trigger?: DeploymentTrigger,
+ ): Report {
+ return {
+ id: crypto.randomUUID(),
+ url,
+ maxPages,
+ ...(trigger ? { trigger } : {}),
+ state: "running",
+ startedAt: new Date().toISOString(),
+ finishedAt: null,
+ pages: [],
+ notices: [],
+ discovered: 0,
+ limited: false,
+ error: null,
+ };
+ }
+ busy() {
+ return (
+ this.active !== null ||
+ this.dispatching ||
+ this.queue().pending.length > 0
+ );
+ }
+ manual(url: string, maxPages: number): string {
+ if (this.busy())
+ throw new Error(
+ "A crawl is running or queued. Wait or cancel it before starting a manual crawl.",
+ );
+ const report = this.makeReport(url, maxPages);
+ this.store.save(report);
+ this.run(report);
+ return report.id;
+ }
+ private run(report: Report) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 20 * 60 * 1000);
+ const done = crawl(report, controller.signal, {
+ ...this.options,
+ save: (value) => this.store.save(value),
+ })
+ .catch((error) => {
+ console.error(
+ JSON.stringify({
+ level: "error",
+ report_id: report.id,
+ error: message(error),
+ }),
+ );
+ })
+ .finally(() => {
+ clearTimeout(timer);
+ this.active = null;
+ this.kick();
+ });
+ this.active = { id: report.id, controller, done };
+ }
+ kick() {
+ if (this.closed || this.active || this.dispatching || this.timer) return;
+ const queue = this.queue();
+ if (!queue.pending.length) return;
+ this.timer = setTimeout(
+ () => {
+ this.timer = null;
+ void this.dispatch().catch((error) =>
+ console.error(
+ JSON.stringify({
+ level: "error",
+ operation: "dispatch deployment crawl",
+ error: message(error),
+ }),
+ ),
+ );
+ },
+ Math.max(0, queue.pending[0]!.readyAt - Date.now()),
+ );
+ }
+ private async dispatch() {
+ if (this.closed || this.active || this.dispatching) return;
+ this.dispatching = true;
+ try {
+ const access = await this.access();
+ if (this.closed) return;
+ const queue = this.queue();
+ const job = queue.pending[0];
+ if (!job) return;
+ if (!access.configured) {
+ queue.notice =
+ "Queued deployment crawls are paused until Events read permission is available.";
+ this.store.setMetadata("queue", queue);
+ this.timer = setTimeout(() => {
+ this.timer = null;
+ this.kick();
+ }, 30000);
+ return;
+ }
+ queue.pending.shift();
+ if (!this.eligible(job.trigger, this.settings())) {
+ this.store.setMetadata("queue", queue);
+ return;
+ }
+ const report = this.makeReport(job.url, job.maxPages, job.trigger);
+ this.store.transaction(() => {
+ this.store.save(report);
+ this.store.setMetadata("queue", queue);
+ });
+ this.run(report);
+ } catch (error) {
+ // Storage/dispatch failures must not create an immediate retry loop.
+ this.timer = setTimeout(() => {
+ this.timer = null;
+ this.kick();
+ }, 30000);
+ throw error;
+ } finally {
+ this.dispatching = false;
+ this.kick();
+ }
+ }
+ cancel(id: string) {
+ if (this.active?.id !== id) return false;
+ this.active.controller.abort();
+ return true;
+ }
+ isActive(id: string) {
+ return this.active?.id === id;
+ }
+ clearQueue() {
+ const queue = this.queue();
+ queue.pending = [];
+ queue.notice = "Queued deployment crawls were cancelled.";
+ this.store.setMetadata("queue", queue);
+ }
+ async close() {
+ this.closed = true;
+ if (this.timer) clearTimeout(this.timer);
+ if (this.active) {
+ this.active.controller.abort();
+ await this.active.done;
+ }
+ }
+}
diff --git a/site-crawl-plugin/src/crawler.test.ts b/site-crawl-plugin/src/crawler.test.ts
new file mode 100644
index 0000000..bd5b22a
--- /dev/null
+++ b/site-crawl-plugin/src/crawler.test.ts
@@ -0,0 +1,279 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+import { describe, expect, test } from "bun:test";
+import { crawl } from "./crawler";
+import {
+ normalize,
+ publicAddress,
+ type FetchPage,
+ type HttpResult,
+} from "./http";
+import { analyzeHtml } from "./seo";
+import type { Report } from "./types";
+const result = (
+ body: string,
+ status = 200,
+ headers: Record = {},
+): HttpResult => ({
+ body,
+ status,
+ headers: { "content-type": "text/html", ...headers },
+});
+function report(maxPages = 50): Report {
+ return {
+ id: crypto.randomUUID(),
+ url: "https://example.com/",
+ maxPages,
+ state: "running",
+ startedAt: new Date().toISOString(),
+ finishedAt: null,
+ pages: [],
+ notices: [],
+ discovered: 0,
+ limited: false,
+ error: null,
+ };
+}
+function fixture(routes: Record) {
+ const calls: string[] = [];
+ const fetch: FetchPage = async (url) => {
+ calls.push(url.href);
+ return routes[url.pathname + url.search] ?? result("", 404);
+ };
+ return { calls, fetch };
+}
+async function run(routes: Record, max = 50) {
+ const f = fixture(routes),
+ r = report(max);
+ await crawl(r, new AbortController().signal, { fetch: f.fetch, delayMs: 0 });
+ return { ...f, report: r };
+}
+describe("URL and network safety", () => {
+ for (const ip of [
+ "127.0.0.1",
+ "10.0.0.1",
+ "172.16.0.1",
+ "192.168.0.1",
+ "169.254.169.254",
+ "100.64.0.1",
+ "0.0.0.0",
+ "224.0.0.1",
+ "::1",
+ "fc00::1",
+ "fe80::1",
+ "::ffff:127.0.0.1",
+ "2001:db8::1",
+ ])
+ test(`blocks ${ip}`, () => expect(publicAddress(ip)).toBe(false));
+ test("accepts public addresses", () => {
+ expect(publicAddress("93.184.216.34")).toBe(true);
+ expect(publicAddress("2606:4700:4700::1111")).toBe(true);
+ });
+ for (const url of [
+ "file:///etc/passwd",
+ "ftp://example.com",
+ "https://user:secret@example.com",
+ "http://example.com:3000",
+ ])
+ test(`rejects unsupported URL ${url}`, () =>
+ expect(() => normalize(url)).toThrow());
+ test("normalizes fragments while preserving distinct queries", () => {
+ expect(normalize("/page?q=2#section", "https://example.com").href).toBe(
+ "https://example.com/page?q=2",
+ );
+ });
+});
+describe("crawl evidence", () => {
+ test("finds broken routes, source pages, sitemap noindex, duplicate titles and loops", async () => {
+ const { report: r, calls } = await run({
+ "/robots.txt": result(
+ "User-agent: *\nDisallow: /private\nSitemap: https://example.com/sitemap.xml",
+ 200,
+ { "content-type": "text/plain" },
+ ),
+ "/sitemap.xml": result(
+ "https://example.com/hidden",
+ ),
+ "/": result(
+ 'HomeHome
BrokenPrivateOtherLoopExternal',
+ ),
+ "/other": result('HomeBroken again'),
+ "/hidden": result(
+ 'Hidden',
+ ),
+ "/loop": result("", 302, { location: "/loop" }),
+ });
+ expect(r.state).toBe("completed");
+ const broken = r.pages.find((p) => p.url.endsWith("/broken"))!;
+ expect(broken.status).toBe(404);
+ expect(broken.sources).toEqual([
+ "https://example.com/",
+ "https://example.com/other",
+ ]);
+ expect(
+ r.pages.find((p) => p.url.endsWith("/hidden"))!.issues.map((i) => i.code),
+ ).toContain("sitemap_noindex");
+ expect(r.pages.find((p) => p.url.endsWith("/loop"))!.issues[0]!.code).toBe(
+ "redirect_loop",
+ );
+ expect(r.pages[0]!.issues.map((i) => i.code)).toContain("duplicate_title");
+ expect(
+ calls.some(
+ (url) => url.includes("/private") || url.includes("other.example"),
+ ),
+ ).toBe(false);
+ });
+ test("follows same-origin redirects and records external destinations without fetching", async () => {
+ const { report: r, calls } = await run({
+ "/": result(
+ 'HomeOldOut',
+ ),
+ "/old": result("", 301, { location: "/new" }),
+ "/new": result("New"),
+ "/out": result("", 302, { location: "http://169.254.169.254/latest" }),
+ });
+ expect(r.pages.find((p) => p.url.endsWith("/old"))!.finalUrl).toBe(
+ "https://example.com/new",
+ );
+ expect(r.pages.find((p) => p.url.endsWith("/out"))!.issues[0]!.code).toBe(
+ "external_redirect",
+ );
+ expect(calls.some((url) => url.includes("169.254"))).toBe(false);
+ });
+ test("fails closed when robots is unavailable", async () => {
+ for (const status of [429, 500, 503, 401, 403]) {
+ const { report: r, calls } = await run({
+ "/robots.txt": result("", status),
+ });
+ expect(r.state).toBe("failed");
+ expect(calls).toHaveLength(1);
+ }
+ });
+ test("page limit and discovery queue stay bounded", async () => {
+ const { report: r } = await run(
+ {
+ "/": result(
+ Array.from(
+ { length: 100 },
+ (_, i) => `Link`,
+ ).join(""),
+ ),
+ },
+ 2,
+ );
+ expect(r.pages).toHaveLength(2);
+ expect(r.discovered).toBeLessThanOrEqual(8);
+ expect(r.limited).toBe(true);
+ });
+ test("honors nofollow and base URL", async () => {
+ const { calls } = await run({
+ "/": result(
+ 'PageSkip',
+ ),
+ "/docs/page": result(
+ 'Skip',
+ ),
+ });
+ expect(calls).toContain("https://example.com/docs/page");
+ expect(calls.some((url) => url.includes("skip"))).toBe(false);
+ });
+ test("restricts sitemap hosts and nested sitemap budget", async () => {
+ const { calls } = await run({
+ "/robots.txt": result(
+ "Sitemap: http://169.254.169.254/map.xml\nSitemap: https://example.com/maps.xml",
+ ),
+ "/maps.xml": result(
+ "https://example.com/child.xml",
+ ),
+ "/child.xml": result(
+ "https://example.com/from-map",
+ ),
+ "/": result("Home"),
+ });
+ expect(calls).toContain("https://example.com/from-map");
+ expect(calls.some((url) => url.includes("169.254"))).toBe(false);
+ });
+ test("cancellation preserves partial report", async () => {
+ const r = report(),
+ controller = new AbortController();
+ const f = fixture({ "/": result('Next') });
+ await crawl(r, controller.signal, {
+ fetch: f.fetch,
+ delayMs: 0,
+ save: (report) => {
+ if (report.pages.length === 1) controller.abort();
+ },
+ });
+ expect(r.state).toBe("cancelled");
+ expect(r.pages).toHaveLength(1);
+ expect(r.finishedAt).not.toBeNull();
+ });
+ test("network errors are retained with route evidence", async () => {
+ const r = report();
+ const f = fixture({ "/": result('Timeout') });
+ await crawl(r, new AbortController().signal, {
+ delayMs: 0,
+ fetch: async (url, signal) => {
+ if (url.pathname === "/timeout")
+ throw new Error("Connection timed out");
+ return f.fetch(url, signal);
+ },
+ });
+ expect(r.pages[1]!.issues[0]!.code).toBe("network_error");
+ expect(r.pages[1]!.sources).toEqual(["https://example.com/"]);
+ });
+});
+test("SEO detects absent and malformed metadata without claiming a ranking score", () => {
+ const result = analyzeHtml(
+ '',
+ "https://example.com/",
+ "noindex",
+ );
+ expect(result.issues.map((i) => i.code)).toEqual(
+ expect.arrayContaining([
+ "missing_title",
+ "missing_description",
+ "missing_h1",
+ "missing_language",
+ "noindex",
+ "multiple_canonicals",
+ "invalid_canonical",
+ ]),
+ );
+});
+
+test("oversized responses preserve HTTP status without claiming a broken route", async () => {
+ const { CrawlError } = await import("./types");
+ const r = report(1);
+ await crawl(r, new AbortController().signal, {
+ delayMs: 0,
+ fetch: async (url) => {
+ if (url.pathname !== "/") return result("", 404);
+ throw new CrawlError(
+ "body_limit",
+ "Inspection incomplete: response exceeds the 8 MiB crawl limit.",
+ 200,
+ );
+ },
+ });
+ expect(r.pages[0]?.status).toBe(200);
+ expect(r.pages[0]?.issues[0]?.severity).toBe("warning");
+ expect(r.pages[0]?.issues[0]?.fix).toContain(
+ "not evidence of a broken route",
+ );
+});
+test("large documentation HTML still extracts routes and metadata", async () => {
+ const { report: r } = await run(
+ {
+ "/": result(
+ 'CLI referenceCLI
' +
+ "x".repeat(1343812) +
+ '
Next',
+ ),
+ "/next": result("Next"),
+ },
+ 2,
+ );
+ expect(r.pages[0]?.title).toBe("CLI reference");
+ expect(r.pages.map((p) => new URL(p.url).pathname)).toEqual(["/", "/next"]);
+});
diff --git a/site-crawl-plugin/src/crawler.ts b/site-crawl-plugin/src/crawler.ts
new file mode 100644
index 0000000..182538a
--- /dev/null
+++ b/site-crawl-plugin/src/crawler.ts
@@ -0,0 +1,360 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+import robotsParser from "robots-parser";
+import { parseSitemap } from "./sitemap";
+import { setTimeout as delay } from "node:timers/promises";
+import { fetchPublic, normalize, USER_AGENT, type FetchPage } from "./http";
+import { analyzeHtml } from "./seo";
+import { CrawlError, message, type Page, type Report } from "./types";
+export interface CrawlOptions {
+ fetch?: FetchPage;
+ delayMs?: number;
+ save?: (report: Report) => void;
+}
+const issue = (
+ code: string,
+ text: string,
+ fix: string,
+ severity: "error" | "warning" | "info" = "error",
+) => ({ code, severity, message: text, fix });
+export async function crawl(
+ report: Report,
+ signal: AbortSignal,
+ options: CrawlOptions = {},
+): Promise {
+ const fetcher = options.fetch ?? fetchPublic;
+ const start = normalize(report.url);
+ const origin = start.origin;
+ const queue: string[] = [];
+ const sources = new Map>();
+ const visited = new Set();
+ const sitemapPages = new Set();
+ const save = () => {
+ report.discovered = sources.size;
+ options.save?.(report);
+ };
+ const notice = (text: string) => {
+ if (report.notices.length < 30 && !report.notices.includes(text))
+ report.notices.push(text);
+ };
+ const enqueue = (raw: string, source: string) => {
+ let url: URL;
+ try {
+ url = normalize(raw, start.href);
+ } catch {
+ return;
+ }
+ if (url.origin !== origin) return;
+ if (!sources.has(url.href)) {
+ if (sources.size >= report.maxPages * 4) {
+ report.limited = true;
+ return;
+ }
+ sources.set(url.href, new Set());
+ queue.push(url.href);
+ }
+ const refs = sources.get(url.href)!;
+ if (refs.size < 20) refs.add(source);
+ };
+ let spacing = options.delayMs ?? 250;
+ let lastRequest = 0;
+ const request: FetchPage = async (url, requestSignal) => {
+ await delay(Math.max(0, spacing - (Date.now() - lastRequest)), undefined, {
+ signal: requestSignal,
+ });
+ lastRequest = Date.now();
+ return fetcher(url, requestSignal);
+ };
+ let allowed = (_url: string) => true;
+ const follow = async (input: string, checkRobots = true) => {
+ let url = normalize(input);
+ const chain: string[] = [];
+ const seen = new Set();
+ for (let hop = 0; hop <= 5; hop++) {
+ if (seen.has(url.href))
+ throw new CrawlError("redirect_loop", `Redirect loop at ${url.href}`);
+ if (checkRobots && !allowed(url.href))
+ throw new CrawlError(
+ "robots_blocked",
+ "Crawling is disallowed by robots.txt.",
+ );
+ seen.add(url.href);
+ const result = await request(url, signal);
+ if (
+ [301, 302, 303, 307, 308].includes(result.status) &&
+ result.headers.location
+ ) {
+ chain.push(url.href);
+ const target = normalize(result.headers.location, url.href);
+ if (target.origin !== origin)
+ return {
+ ...result,
+ finalUrl: url.href,
+ chain,
+ external: target.href,
+ };
+ url = target;
+ continue;
+ }
+ return { ...result, finalUrl: url.href, chain, external: null };
+ }
+ throw new CrawlError("redirect_limit", "Redirect chain exceeds five hops.");
+ };
+ try {
+ const robotsUrl = `${origin}/robots.txt`;
+ const robotsResult = await follow(robotsUrl, false);
+ if (robotsResult.external)
+ throw new CrawlError(
+ "robots_unavailable",
+ "robots.txt redirects outside this origin. Crawl stopped rather than guessing its rules.",
+ );
+ if (
+ robotsResult.status >= 500 ||
+ robotsResult.status === 429 ||
+ [401, 403].includes(robotsResult.status)
+ )
+ throw new CrawlError(
+ "robots_unavailable",
+ `robots.txt returned ${robotsResult.status}; crawl stopped. Try again after access is restored.`,
+ );
+ if (
+ robotsResult.status !== 200 &&
+ ![404, 410].includes(robotsResult.status)
+ )
+ throw new CrawlError(
+ "robots_unavailable",
+ `Cannot determine robots.txt rules (HTTP ${robotsResult.status}).`,
+ );
+ const robots = robotsParser(
+ robotsUrl,
+ robotsResult.status === 200 ? robotsResult.body : "",
+ );
+ allowed = (url) => robots.isAllowed(url, USER_AGENT) !== false;
+ const crawlDelay = robots.getCrawlDelay(USER_AGENT);
+ if (crawlDelay !== undefined && crawlDelay > 30)
+ throw new CrawlError(
+ "crawl_delay",
+ "robots.txt requests a delay over 30 seconds; this interactive crawl cannot honor it.",
+ );
+ if (crawlDelay !== undefined)
+ spacing = Math.max(spacing, crawlDelay * 1000);
+ if (robotsResult.status !== 200)
+ notice(
+ "No robots.txt was found. Public internal URLs are eligible for this crawl.",
+ );
+ enqueue(start.href, "Start URL");
+ const sitemapQueue = robots.getSitemaps().length
+ ? robots.getSitemaps().slice(0, 5)
+ : [`${origin}/sitemap.xml`];
+ const mapsSeen = new Set();
+ while (sitemapQueue.length && mapsSeen.size < 5) {
+ const raw = sitemapQueue.shift()!;
+ let mapUrl: URL;
+ try {
+ mapUrl = normalize(raw, start.href);
+ } catch {
+ continue;
+ }
+ if (mapUrl.origin !== origin || mapsSeen.has(mapUrl.href)) continue;
+ mapsSeen.add(mapUrl.href);
+ try {
+ const sitemap = await follow(mapUrl.href);
+ if (sitemap.status !== 200 || sitemap.external) {
+ notice(`Sitemap not read: ${mapUrl.href} (HTTP ${sitemap.status}).`);
+ continue;
+ }
+ if (/= 400)
+ page.issues.push(
+ issue(
+ "http_error",
+ `Route returned HTTP ${result.status}.`,
+ "Fix the route or update the pages linking to it. Redirect intentionally moved pages to their replacements.",
+ ),
+ );
+ else if (result.status >= 300)
+ page.issues.push(
+ issue(
+ "redirect_missing_location",
+ `HTTP ${result.status} did not resolve to a page.`,
+ "Return a valid redirect destination or a successful page response.",
+ "warning",
+ ),
+ );
+ else if (
+ /text\/html|application\/xhtml\+xml/i.test(
+ result.headers["content-type"] ?? "",
+ )
+ ) {
+ const parsed = analyzeHtml(
+ result.body,
+ result.finalUrl,
+ result.headers["x-robots-tag"],
+ );
+ page.title = parsed.title;
+ page.description = parsed.description;
+ page.canonical = parsed.canonical;
+ page.issues.push(...parsed.issues);
+ for (const link of parsed.links) enqueue(link, result.finalUrl);
+ if (parsed.canonical && new URL(parsed.canonical).origin === origin)
+ enqueue(parsed.canonical, `Canonical from ${result.finalUrl}`);
+ }
+ if (result.chain.length)
+ page.issues.push(
+ issue(
+ "redirect",
+ `${result.chain.length} redirect hop(s).`,
+ "Link directly to the final URL where possible.",
+ "info",
+ ),
+ );
+ } catch (error) {
+ if (signal.aborted) throw error;
+ const code = error instanceof CrawlError ? error.code : "network_error";
+ if (error instanceof CrawlError && error.status !== undefined)
+ page.status = error.status;
+ page.issues.push(
+ issue(
+ code,
+ message(error),
+ code === "body_limit" || code === "markup_depth"
+ ? "The response exceeded an inspection resource limit; this is not evidence of a broken route. Reduce the HTML payload or inspect this page separately."
+ : code === "robots_blocked"
+ ? "Confirm this exclusion is intentional. Blocked pages were not inspected."
+ : "Check the URL and server, then run another crawl.",
+ code === "body_limit" || code === "markup_depth"
+ ? "warning"
+ : code === "robots_blocked"
+ ? "info"
+ : "error",
+ ),
+ );
+ }
+ page.durationMs = Date.now() - began;
+ report.pages.push(page);
+ save();
+ }
+ if (queue.length) {
+ report.limited = true;
+ notice(
+ `Stopped at ${report.maxPages} URLs. Increase the limit to inspect more discovered routes.`,
+ );
+ }
+ const titles = new Map>();
+ for (const page of report.pages)
+ if (page.title) {
+ const group = titles.get(page.title) ?? new Set();
+ group.add(page.finalUrl);
+ titles.set(page.title, group);
+ }
+ const byUrl = new Map(report.pages.map((page) => [page.url, page]));
+ for (const page of report.pages) {
+ if ((titles.get(page.title)?.size ?? 0) > 1)
+ page.issues.push(
+ issue(
+ "duplicate_title",
+ "Another crawled page has the same title.",
+ "Give distinct pages descriptive, unique titles.",
+ "warning",
+ ),
+ );
+ if (page.canonical && (byUrl.get(page.canonical)?.status ?? 0) >= 400)
+ page.issues.push(
+ issue(
+ "broken_canonical",
+ "Canonical target returned an HTTP error.",
+ "Point the canonical to an accessible preferred page.",
+ "warning",
+ ),
+ );
+ if (
+ sitemapPages.has(page.url) &&
+ page.issues.some((item) => item.code === "noindex")
+ )
+ page.issues.push(
+ issue(
+ "sitemap_noindex",
+ "Sitemap includes a page marked noindex.",
+ "Remove intentionally excluded URLs from the sitemap or correct the indexing directive.",
+ "warning",
+ ),
+ );
+ }
+ report.state = "completed";
+ } catch (error) {
+ report.state = signal.aborted ? "cancelled" : "failed";
+ report.error = message(error);
+ } finally {
+ for (const page of report.pages)
+ page.sources = [...(sources.get(page.url) ?? [])];
+ report.finishedAt = new Date().toISOString();
+ save();
+ }
+}
diff --git a/site-crawl-plugin/src/dev.ts b/site-crawl-plugin/src/dev.ts
new file mode 100644
index 0000000..902bcb4
--- /dev/null
+++ b/site-crawl-plugin/src/dev.ts
@@ -0,0 +1,26 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+// Local development only. The distributed entrypoint is index.ts, which requires Temps authentication.
+import { assets } from "./assets";
+import { createApp } from "./app";
+import { Store } from "./store";
+const app = createApp(new Store(".data"));
+const server = Bun.serve({
+ hostname: "127.0.0.1",
+ port: 3198,
+ fetch(request) {
+ const path = new URL(request.url).pathname;
+ const file = assets.get(path === "/" ? "index.html" : path.slice(1));
+ if (request.method === "GET" && file)
+ return new Response(file.content, {
+ headers: { "Content-Type": file.contentType },
+ });
+ return app.fetch(request);
+ },
+});
+console.log(`Site Crawl development UI: ${server.url}`);
+process.on("SIGINT", async () => {
+ server.stop(true);
+ await app.close();
+ process.exit(0);
+});
diff --git a/site-crawl-plugin/src/globals.d.ts b/site-crawl-plugin/src/globals.d.ts
new file mode 100644
index 0000000..3131d09
--- /dev/null
+++ b/site-crawl-plugin/src/globals.d.ts
@@ -0,0 +1,14 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+declare module "*.html" {
+ const text: string;
+ export default text;
+}
+declare module "*.css" {
+ const text: string;
+ export default text;
+}
+declare module "*.js" {
+ const text: string;
+ export default text;
+}
diff --git a/site-crawl-plugin/src/host.ts b/site-crawl-plugin/src/host.ts
new file mode 100644
index 0000000..cf5794e
--- /dev/null
+++ b/site-crawl-plugin/src/host.ts
@@ -0,0 +1,29 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+import type { TempsClient } from "@temps-sdk/plugin";
+import type { HostEventAccess } from "./types";
+/** beta.1 exposes generic protocol calls; newer host discovery is validated at this boundary. */
+export async function eventAccess(
+ client: TempsClient,
+): Promise {
+ const call = client.call.bind(client) as (
+ method: string,
+ params: Record,
+ ) => Promise;
+ const result = await call("get_host_capabilities", {});
+ if (
+ !result ||
+ typeof result !== "object" ||
+ !("permissions" in result) ||
+ !Array.isArray(result.permissions)
+ )
+ throw new Error("Host does not expose plugin permission discovery.");
+ const configured = result.permissions.includes("events_read");
+ return {
+ configured,
+ reason: configured
+ ? null
+ : "Grant Events read to Site Crawl in Settings → Plugins → Permissions to receive deployment events.",
+ setupPath: "/settings/plugins",
+ };
+}
diff --git a/site-crawl-plugin/src/http.ts b/site-crawl-plugin/src/http.ts
new file mode 100644
index 0000000..3d2f1dd
--- /dev/null
+++ b/site-crawl-plugin/src/http.ts
@@ -0,0 +1,148 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+import { lookup } from "node:dns/promises";
+import http from "node:http";
+import https from "node:https";
+import ipaddr from "ipaddr.js";
+import { CrawlError } from "./types";
+export const USER_AGENT = "TempsSiteCrawl";
+export const MAX_BODY = 8 * 1024 * 1024;
+export interface HttpResult {
+ status: number;
+ headers: Record;
+ body: string;
+}
+export type FetchPage = (url: URL, signal: AbortSignal) => Promise;
+export function publicAddress(address: string): boolean {
+ try {
+ const parsed = ipaddr.process(address);
+ return parsed.range() === "unicast";
+ } catch {
+ return false;
+ }
+}
+export function normalize(input: string, base?: string): URL {
+ let url: URL;
+ try {
+ url = new URL(input, base);
+ } catch {
+ throw new CrawlError("invalid_url", "Enter a complete HTTP or HTTPS URL.");
+ }
+ if (
+ !["http:", "https:"].includes(url.protocol) ||
+ url.username ||
+ url.password ||
+ url.port
+ )
+ throw new CrawlError(
+ "invalid_url",
+ "Use HTTP or HTTPS on the standard port, without credentials.",
+ );
+ if (url.href.length > 2048)
+ throw new CrawlError("invalid_url", "URL exceeds 2,048 characters.");
+ url.hash = "";
+ return url;
+}
+/** Resolve once and pin the socket to a validated address; re-run for every redirect. */
+export const fetchPublic: FetchPage = async (url, signal) => {
+ normalize(url.href);
+ if (signal.aborted) throw new CrawlError("cancelled", "Crawl cancelled.");
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
+ const addresses = await Promise.race([
+ lookup(hostname, { all: true }),
+ new Promise((_, reject) => {
+ const timer = setTimeout(
+ () => reject(new CrawlError("dns_timeout", "DNS lookup timed out.")),
+ 5000,
+ );
+ timer.unref();
+ }),
+ ]);
+ if (
+ !addresses.length ||
+ addresses.some((item) => !publicAddress(item.address))
+ )
+ throw new CrawlError(
+ "unsafe_address",
+ "This host resolves to a private, local, or reserved address. Only public sites can be crawled.",
+ );
+ const address = addresses.find((item) => item.family === 4) ?? addresses[0]!;
+ return new Promise((resolve, reject) => {
+ const request = (url.protocol === "https:" ? https : http).request(
+ url,
+ {
+ method: "GET",
+ signal,
+ agent: false,
+ family: address.family,
+ headers: {
+ "User-Agent": `${USER_AGENT}/1.0`,
+ Accept:
+ "text/html,application/xhtml+xml,application/xml,text/plain;q=0.9,*/*;q=0.1",
+ "Accept-Encoding": "identity",
+ },
+ lookup: (_host, options, callback) =>
+ options.all
+ ? callback(null, [address])
+ : callback(null, address.address, address.family),
+ },
+ (response) => {
+ const headers: Record = {};
+ for (const [key, value] of Object.entries(response.headers))
+ if (value !== undefined)
+ headers[key] = Array.isArray(value) ? value.join(", ") : value;
+ const status = response.statusCode ?? 0;
+ if (status >= 300 && status < 400 && headers.location) {
+ response.destroy();
+ resolve({ status, headers, body: "" });
+ return;
+ }
+ const encoding = headers["content-encoding"];
+ if (encoding && encoding !== "identity") {
+ response.destroy();
+ reject(
+ new CrawlError(
+ "encoding",
+ "Server sent compressed data despite an identity request.",
+ ),
+ );
+ return;
+ }
+ let size = 0;
+ const chunks: Buffer[] = [];
+ response.on("data", (chunk: Buffer) => {
+ size += chunk.length;
+ if (size > MAX_BODY) {
+ response.destroy(
+ new CrawlError(
+ "body_limit",
+ "Inspection incomplete: response exceeds the 8 MiB crawl limit.",
+ status,
+ ),
+ );
+ return;
+ }
+ chunks.push(chunk);
+ });
+ response.on("end", () =>
+ resolve({
+ status,
+ headers,
+ body: Buffer.concat(chunks).toString("utf8"),
+ }),
+ );
+ response.on("error", reject);
+ },
+ );
+ const timer = setTimeout(
+ () =>
+ request.destroy(
+ new CrawlError("timeout", "Request exceeded 10 seconds."),
+ ),
+ 10_000,
+ );
+ request.on("close", () => clearTimeout(timer));
+ request.on("error", reject);
+ request.end();
+ });
+};
diff --git a/site-crawl-plugin/src/index.ts b/site-crawl-plugin/src/index.ts
new file mode 100644
index 0000000..f616b5a
--- /dev/null
+++ b/site-crawl-plugin/src/index.ts
@@ -0,0 +1,76 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+import {
+ runPlugin,
+ createManifest,
+ extractAuthContext,
+} from "@temps-sdk/plugin";
+import { assets } from "./assets";
+import { createApp } from "./app";
+import { Store } from "./store";
+import { eventAccess } from "./host";
+let app: ReturnType | undefined;
+await runPlugin({
+ manifest: () => ({
+ ...createManifest("site-crawl", "0.1.0")
+ .displayName("Site Crawl")
+ .description(
+ "Find broken internal routes and technical SEO issues in deployed sites",
+ )
+ .addNav("Site Crawl", "scan-search", "/")
+ .event("deployment.succeeded")
+ .build(),
+ host_permissions: ["events_read"],
+ }),
+ embeddedUiAssets: () => assets,
+ handler(ctx) {
+ app = createApp(new Store(ctx.dataDir), {
+ hostAccess: () => eventAccess(ctx.temps),
+ });
+ return async (req, res) => {
+ // Reports are instance-wide, so all data/API access is administrator-only.
+ const caller = extractAuthContext(req);
+ if (!caller || (!caller.isAdmin() && caller.role !== "platform_admin")) {
+ res.writeHead(403, { "Content-Type": "application/json" });
+ res.end(
+ JSON.stringify({
+ error:
+ "An administrator account is required to crawl sites and view reports.",
+ }),
+ );
+ return;
+ }
+ const chunks: Buffer[] = [];
+ let size = 0;
+ await new Promise((resolve) => {
+ req.on("data", (chunk: Buffer) => {
+ size += chunk.length;
+ if (size <= 4096) chunks.push(Buffer.from(chunk));
+ });
+ req.on("end", resolve);
+ });
+ if (size > 4096) {
+ res.writeHead(413);
+ res.end(JSON.stringify({ error: "Request too large" }));
+ return;
+ }
+ const method = req.method ?? "GET";
+ const request = new Request(`http://plugin${req.url ?? "/"}`, {
+ method,
+ headers: { "Content-Type": "application/json" },
+ ...(method === "GET" || method === "HEAD"
+ ? {}
+ : { body: Buffer.concat(chunks) }),
+ });
+ const response = await app!.fetch(request);
+ res.writeHead(response.status, Object.fromEntries(response.headers));
+ res.end(Buffer.from(await response.arrayBuffer()));
+ };
+ },
+ onEvent(_ctx, event) {
+ app?.deployment(event);
+ },
+ async onShutdown() {
+ await app?.close();
+ },
+});
diff --git a/site-crawl-plugin/src/runtime.test.ts b/site-crawl-plugin/src/runtime.test.ts
new file mode 100644
index 0000000..ff17269
--- /dev/null
+++ b/site-crawl-plugin/src/runtime.test.ts
@@ -0,0 +1,215 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+import { expect, test } from "bun:test";
+import { spawn } from "node:child_process";
+import { createInterface } from "node:readline";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import type WebSocket from "ws";
+import { createConnection } from "node:net";
+// Import the implementation: Bun's built-in ws shim does not support Unix sockets.
+const UnixWebSocket = (
+ await import(new URL("../node_modules/ws/wrapper.mjs", import.meta.url).href)
+).default as typeof WebSocket;
+
+test("compiled plugin handshake, authentication, report API and embedded UI", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "site-crawl-runtime-"));
+ const socket = join(dir, "plugin.sock");
+ const binary = join(dir, "site-crawl");
+ const uiBuild = Bun.spawn(["bun", "run", "build:ui"], {
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+ expect(await uiBuild.exited).toBe(0);
+ const build = Bun.spawn(
+ ["bun", "build", "src/index.ts", "--compile", "--outfile", binary],
+ { stdout: "pipe", stderr: "pipe" },
+ );
+ expect(await build.exited).toBe(0);
+ const secret = crypto.randomUUID();
+ const child = spawn(
+ binary,
+ ["--socket-path", socket, "--data-dir", join(dir, "data")],
+ { stdio: ["pipe", "pipe", "pipe"] },
+ );
+ let stderr = "";
+ child.stderr.on("data", (chunk) => {
+ stderr = (stderr + chunk.toString()).slice(-8192);
+ });
+ const lines = createInterface({ input: child.stdout });
+ const iterator = lines[Symbol.asyncIterator]();
+ const line = async () => {
+ const result = await Promise.race([
+ iterator.next(),
+ Bun.sleep(10000).then(() => {
+ throw new Error("Plugin handshake timed out");
+ }),
+ ]);
+ if (result.done)
+ throw new Error(
+ `Plugin exited during handshake (code=${child.exitCode}, signal=${child.signalCode}): ${stderr}`,
+ );
+ return JSON.parse(result.value);
+ };
+ let channel: WebSocket | undefined;
+ const headers = (role: string) => ({
+ "x-temps-auth-signature": secret,
+ "x-temps-user-id": "1",
+ "x-temps-user-email": "admin@example.test",
+ "x-temps-user-role": role,
+ });
+ const get = (path: string, extra: RequestInit = {}) =>
+ fetch(`http://localhost${path}`, { unix: socket, ...extra });
+ try {
+ const hello = await line();
+ expect(hello.type).toBe("hello");
+ expect(hello.protocol_version).toBe(2);
+ expect(hello.manifest.name).toBe("site-crawl");
+ expect(hello.manifest.host_permissions).toContain("events_read");
+ child.stdin.write(
+ JSON.stringify({
+ protocol_version: 2,
+ auth_secret: secret,
+ database_url: null,
+ host_data_dir: null,
+ }) + "\n",
+ );
+ const ready = await line();
+ expect(ready.type).toBe("ready");
+ expect(ready.has_ui).toBe(true);
+ expect((await get("/_temps/channel")).status).toBe(401);
+ channel = new UnixWebSocket("ws://localhost/_temps/channel", {
+ createConnection: () => createConnection(socket),
+ headers: { "x-temps-auth-signature": secret },
+ });
+ await new Promise((resolve, reject) => {
+ channel!.once("open", resolve);
+ channel!.once("error", reject);
+ });
+ channel.on("message", (raw) => {
+ const request = JSON.parse(raw.toString());
+ channel!.send(
+ JSON.stringify({
+ type: "response",
+ id: request.id,
+ outcome: {
+ ok: {
+ method: request.call.method,
+ result: { permissions: ["events_read"] },
+ },
+ },
+ }),
+ );
+ });
+ let response: Response | undefined;
+ for (let i = 0; i < 100; i++) {
+ response = await get("/api/reports", { headers: headers("admin") });
+ if (response.status !== 503) break;
+ await Bun.sleep(10);
+ }
+ expect(response?.status).toBe(200);
+ expect(await response!.json()).toEqual([]);
+ expect(
+ (await get("/api/reports", { headers: { "x-temps-user-role": "admin" } }))
+ .status,
+ ).toBe(401);
+ expect(
+ (
+ await get("/api/reports", {
+ headers: { ...headers("admin"), "x-temps-auth-signature": "wrong" },
+ })
+ ).status,
+ ).toBe(401);
+ expect(
+ (await get("/api/reports", { headers: headers("reader") })).status,
+ ).toBe(403);
+ expect(
+ (await get("/api/reports", { headers: headers("platform_admin") }))
+ .status,
+ ).toBe(200);
+ const ui = await get("/ui/", { headers: headers("admin") });
+ expect(ui.status).toBe(200);
+ expect(await ui.text()).toContain("Site Crawl");
+ const created = await get("/api/reports", {
+ method: "POST",
+ headers: headers("admin"),
+ body: JSON.stringify({ url: "http://127.0.0.1/", maxPages: 1 }),
+ });
+ expect(created.status).toBe(202);
+ const { id } = (await created.json()) as { id: string };
+ let report;
+ for (let i = 0; i < 100; i++) {
+ report = (await (
+ await get(`/api/reports/${id}`, { headers: headers("admin") })
+ ).json()) as { state: string; error: string };
+ if (report.state !== "running") break;
+ await Bun.sleep(10);
+ }
+ expect(report?.state).toBe("failed");
+ expect(report?.error).toContain("private, local, or reserved");
+ const automation = await get("/api/automation", {
+ headers: headers("admin"),
+ });
+ expect(automation.status).toBe(200);
+ expect(
+ ((await automation.json()) as { access: { configured: boolean } }).access
+ .configured,
+ ).toBe(true);
+ const deployment = {
+ id: "deployment-test",
+ event_type: "deployment.succeeded",
+ timestamp: new Date().toISOString(),
+ project_id: 1,
+ data: {
+ deployment_id: 42,
+ environment_id: 1,
+ environment_name: "production",
+ url: "http://127.0.0.1/",
+ },
+ };
+ expect(
+ (
+ await get("/_events", {
+ method: "POST",
+ headers: headers("admin"),
+ body: JSON.stringify(deployment),
+ })
+ ).status,
+ ).toBe(200);
+ expect(
+ (
+ await get("/_events", {
+ method: "POST",
+ headers: headers("admin"),
+ body: JSON.stringify(deployment),
+ })
+ ).status,
+ ).toBe(200);
+ let automatic:
+ { state: string; trigger?: { deploymentId: number } } | undefined;
+ for (let i = 0; i < 140; i++) {
+ const reports = (await (
+ await get("/api/reports", { headers: headers("admin") })
+ ).json()) as { state: string; trigger?: { deploymentId: number } }[];
+ automatic = reports.find((r) => r.trigger?.deploymentId === 42);
+ if (automatic?.state === "failed") {
+ expect(reports).toHaveLength(2);
+ break;
+ }
+ await Bun.sleep(50);
+ }
+ expect(automatic?.state).toBe("failed");
+ expect(automatic?.trigger?.deploymentId).toBe(42);
+ } finally {
+ channel?.close();
+ child.kill("SIGTERM");
+ await Promise.race([
+ new Promise((resolve) => child.once("exit", resolve)),
+ Bun.sleep(2000),
+ ]);
+ if (child.exitCode === null) child.kill("SIGKILL");
+ lines.close();
+ rmSync(dir, { recursive: true, force: true });
+ }
+}, 30000);
diff --git a/site-crawl-plugin/src/seo.test.ts b/site-crawl-plugin/src/seo.test.ts
new file mode 100644
index 0000000..5aa626a
--- /dev/null
+++ b/site-crawl-plugin/src/seo.test.ts
@@ -0,0 +1,104 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+import { expect, test } from "bun:test";
+import { analyzeHtml } from "./seo";
+import { parseSitemap } from "./sitemap";
+const url = "https://example.com/page";
+test("extracts mixed-case metadata, decoded text, duplicate canonicals and relative links", () => {
+ const r = analyzeHtml(
+ 'A & BHeading
Next',
+ url,
+ );
+ expect(r.title).toBe("A & B");
+ expect(r.description).toBe("One & two");
+ expect(r.canonical).toBe(url);
+ expect(r.links).toEqual(["https://example.com/docs/next?a=1&b=2"]);
+ expect(r.issues.map((i) => i.code)).toEqual(["multiple_canonicals"]);
+});
+test("ignores script, template, SVG and noscript decoys", () => {
+ const r = analyzeHtml(
+ 'FalseActualXFalse
Real',
+ url,
+ );
+ expect(r.title).toBe("Actual");
+ expect(r.links).toEqual(["https://example.com/real"]);
+ expect(r.issues.some((i) => i.code === "missing_h1")).toBe(true);
+});
+test("honors all robots metadata and header directives even after link discovery", () => {
+ const r = analyzeHtml(
+ 'Next',
+ url,
+ );
+ expect(r.links).toEqual([]);
+ expect(r.issues.some((i) => i.code === "noindex")).toBe(true);
+ expect(analyzeHtml('Next', url, "nofollow").links).toEqual(
+ [],
+ );
+ expect(
+ analyzeHtml('NoYes', url)
+ .links,
+ ).toEqual(["https://example.com/yes"]);
+});
+test("bounds title, description, URL collection and oversized hrefs", () => {
+ const r = analyzeHtml(
+ "" +
+ "x".repeat(10000) +
+ '' +
+ Array.from({ length: 2002 }, (_, i) => `X`).join(""),
+ url,
+ );
+ expect(r.title.length).toBe(1000);
+ expect(r.description.length).toBe(2000);
+ expect(r.links.length).toBe(2000);
+ expect(r.issues.some((i) => i.code === "link_limit")).toBe(true);
+ expect(
+ analyzeHtml('X', url).links,
+ ).toEqual([]);
+});
+test("handles omitted head, malformed anchors, invalid base and body metadata", () => {
+ const r = analyzeHtml(
+ 'TitleOneTwo',
+ url,
+ );
+ expect(r.title).toBe("Title");
+ expect(r.description).toBe("");
+ expect(r.links).toEqual([
+ "https://example.com/one",
+ "https://example.com/two",
+ ]);
+});
+test("tokenizes sitemap XML with namespaces, decoded URLs, and bounded entries", () => {
+ const r = parseSitemap(
+ 'https://example.com/a?x=1&y=2https://example.com/imagehttps://example.com/b',
+ 1,
+ );
+ expect(r.kind).toBe("urlset");
+ expect(r.locations).toEqual(["https://example.com/a?x=1&y=2"]);
+ expect(r.limited).toBe(true);
+ expect(
+ parseSitemap(
+ "https://example.com/imagehttps://example.com/page",
+ 10,
+ ).locations,
+ ).toEqual(["https://example.com/page"]);
+ const index = parseSitemap(
+ "" +
+ Array.from(
+ { length: 7 },
+ (_, i) => `https://example.com/${i}.xml`,
+ ).join("") +
+ "",
+ 100,
+ );
+ expect(index.locations.length).toBe(5);
+ expect(index.limited).toBe(true);
+});
+
+test("rejects excessive HTML and XML nesting before building deep parser stacks", () => {
+ expect(() => analyzeHtml("".repeat(130), url)).toThrow("128 levels");
+ expect(() => parseSitemap("
" + "".repeat(65), 100)).toThrow(
+ "64-level",
+ );
+});
diff --git a/site-crawl-plugin/src/seo.ts b/site-crawl-plugin/src/seo.ts
new file mode 100644
index 0000000..04af9b0
--- /dev/null
+++ b/site-crawl-plugin/src/seo.ts
@@ -0,0 +1,245 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+import { Parser } from "htmlparser2";
+import { normalize } from "./http";
+import { CrawlError, type Issue } from "./types";
+export function analyzeHtml(html: string, url: string, xRobots = "") {
+ const fields = extractHtml(html);
+ const issues: Issue[] = [];
+ const add = (
+ code: string,
+ severity: Issue["severity"],
+ message: string,
+ fix: string,
+ ) => issues.push({ code, severity, message, fix });
+ const title = fields.title.trim();
+ const description = fields.description.trim();
+ if (!title)
+ add(
+ "missing_title",
+ "warning",
+ "No page title.",
+ "Add a descriptive, unique in the HTML head.",
+ );
+ if (!description)
+ add(
+ "missing_description",
+ "warning",
+ "No meta description.",
+ "Describe this page in a meta description. Search engines may choose a different snippet.",
+ );
+ if (!fields.hasH1)
+ add(
+ "missing_h1",
+ "info",
+ "No primary heading.",
+ "Add a clear heading that describes this page.",
+ );
+ if (!fields.language.trim())
+ add(
+ "missing_language",
+ "info",
+ "Document language is not declared.",
+ "Set the appropriate lang attribute on .",
+ );
+ const robots = [
+ fields.noindex ? "noindex" : "",
+ fields.nofollow ? "nofollow" : "",
+ xRobots,
+ ]
+ .join(",")
+ .toLowerCase();
+ if (/\b(noindex|none)\b/.test(robots))
+ add(
+ "noindex",
+ "warning",
+ "Indexing is disabled by a robots directive.",
+ "If this page should appear in search, remove its noindex directive. Keep it for intentionally private or excluded pages.",
+ );
+
+ let canonical: string | null = null;
+ if (fields.canonicalCount > 1)
+ add(
+ "multiple_canonicals",
+ "warning",
+ "Multiple canonical URLs are declared.",
+ "Keep one consistent canonical URL for this page.",
+ );
+ const raw = fields.canonical;
+ if (raw) {
+ try {
+ canonical = normalize(raw, url).href;
+ if (canonical !== url)
+ add(
+ "alternate_canonical",
+ "info",
+ "This page points to a different canonical URL.",
+ "Verify the target is the intended preferred page and is reachable.",
+ );
+ } catch {
+ add(
+ "invalid_canonical",
+ "warning",
+ "Canonical URL is invalid or unsupported.",
+ "Use a valid public HTTP or HTTPS canonical URL.",
+ );
+ }
+ } else
+ add(
+ "missing_canonical",
+ "info",
+ "No canonical URL is declared.",
+ "Consider a canonical URL where duplicate URLs could exist. This is not automatically an indexing error.",
+ );
+ let base = url;
+ try {
+ const rawBase = fields.base;
+ if (rawBase) base = normalize(rawBase, url).href;
+ } catch {
+ /* invalid base falls back to the document */
+ }
+ const links: string[] = [];
+ const nofollow = /\b(nofollow|none)\b/.test(robots);
+ if (!nofollow)
+ for (const link of fields.links) {
+ if (/\bnofollow\b/i.test(link.rel)) continue;
+ try {
+ links.push(normalize(link.href, base).href);
+ } catch {
+ /* unsupported crawl target */
+ }
+ }
+ if (fields.linkCount > 2000)
+ add(
+ "link_limit",
+ "info",
+ "Only the first 2,000 links were inspected.",
+ "Split very large navigation lists across smaller pages.",
+ );
+ return { title, description, canonical, links: [...new Set(links)], issues };
+}
+
+/** Tokenizer callbacks retain only bounded SEO fields, never a document tree. */
+function extractHtml(html: string) {
+ const fields = {
+ title: "",
+ description: "",
+ hasH1: false,
+ language: "",
+ noindex: false,
+ nofollow: false,
+ canonicalCount: 0,
+ canonical: undefined as string | undefined,
+ base: undefined as string | undefined,
+ links: [] as { href: string; rel: string }[],
+ linkCount: 0,
+ };
+ let inHead = true,
+ titleOpen = false,
+ titleSeen = false,
+ descriptionSeen = false,
+ languageSeen = false,
+ baseSeen = false;
+ let ignored = 0;
+ let depth = 0;
+ const suppressed = new Set([
+ "script",
+ "style",
+ "noscript",
+ "svg",
+ "math",
+ "template",
+ ]);
+ const headTags = new Set([
+ "html",
+ "head",
+ "title",
+ "base",
+ "link",
+ "meta",
+ "script",
+ "style",
+ "noscript",
+ "template",
+ ]);
+ const parser = new Parser(
+ {
+ onopentag(name, attrs) {
+ if (++depth > 128)
+ throw new CrawlError(
+ "markup_depth",
+ "Inspection incomplete: HTML nesting exceeds 128 levels.",
+ );
+ if (suppressed.has(name)) {
+ ignored++;
+ return;
+ }
+ if (ignored) return;
+ // HTML permits an omitted . Its first body element ends that head.
+ if (name === "body" || !headTags.has(name)) inHead = false;
+ if (name === "html" && !languageSeen) {
+ fields.language = (attrs.lang ?? "").slice(0, 100);
+ languageSeen = true;
+ }
+ if (name === "title" && inHead && !titleSeen) {
+ titleSeen = true;
+ titleOpen = true;
+ }
+ if (name === "h1") fields.hasH1 = true;
+ if (name === "meta" && inHead) {
+ const key = attrs.name?.toLowerCase();
+ const content = attrs.content ?? "";
+ if (key === "description" && !descriptionSeen) {
+ fields.description = content.trim().slice(0, 2000);
+ descriptionSeen = true;
+ }
+ if (key === "robots") {
+ fields.noindex ||= /\b(noindex|none)\b/i.test(content);
+ fields.nofollow ||= /\b(nofollow|none)\b/i.test(content);
+ }
+ }
+ if (
+ name === "link" &&
+ inHead &&
+ attrs.rel?.toLowerCase().split(/\s+/).includes("canonical")
+ ) {
+ if (fields.canonicalCount === 0)
+ fields.canonical = attrs.href?.slice(0, 2049);
+ fields.canonicalCount++;
+ }
+ if (name === "base" && attrs.href !== undefined && !baseSeen) {
+ fields.base = attrs.href.slice(0, 2049);
+ baseSeen = true;
+ }
+ if (name === "a" && attrs.href !== undefined) {
+ fields.linkCount++;
+ if (fields.linkCount <= 2000)
+ fields.links.push({
+ href: attrs.href.slice(0, 2049),
+ rel: /\bnofollow\b/i.test(attrs.rel ?? "") ? "nofollow" : "",
+ });
+ }
+ },
+ ontext(text) {
+ if (titleOpen && !ignored && fields.title.length < 1000)
+ fields.title += (fields.title ? text : text.trimStart()).slice(
+ 0,
+ 1000 - fields.title.length,
+ );
+ },
+ onclosetag(name) {
+ depth = Math.max(0, depth - 1);
+ if (suppressed.has(name) && ignored > 0) {
+ ignored--;
+ return;
+ }
+ if (ignored) return;
+ if (name === "title") titleOpen = false;
+ if (name === "head") inHead = false;
+ },
+ },
+ { decodeEntities: true },
+ );
+ parser.end(html);
+ return fields;
+}
diff --git a/site-crawl-plugin/src/sitemap.ts b/site-crawl-plugin/src/sitemap.ts
new file mode 100644
index 0000000..9720b4e
--- /dev/null
+++ b/site-crawl-plugin/src/sitemap.ts
@@ -0,0 +1,73 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+import { Parser } from "htmlparser2";
+import { CrawlError } from "./types";
+/** XML tokenizer: retain only direct loc children and bounded URL strings. */
+export function parseSitemap(
+ xml: string,
+ maxUrls: number,
+): {
+ kind: "urlset" | "sitemapindex" | null;
+ locations: string[];
+ limited: boolean;
+} {
+ let kind: "urlset" | "sitemapindex" | null = null;
+ const locations: string[] = [];
+ const stack: string[] = [];
+ let prefix = "";
+ let text: string | null = null,
+ count = 0;
+ const parser = new Parser(
+ {
+ onopentag(name) {
+ if (stack.length >= 64)
+ throw new CrawlError(
+ "markup_depth",
+ "Sitemap nesting exceeds the 64-level inspection limit.",
+ );
+ const local = name.split(":").pop()!;
+ stack.push(name);
+ if (stack.length === 1)
+ prefix = name.slice(0, name.length - local.length);
+ if (
+ stack.length === 1 &&
+ (local === "urlset" || local === "sitemapindex")
+ )
+ kind = local;
+ if (
+ stack.length === 3 &&
+ name === `${prefix}loc` &&
+ stack[1] === `${prefix}${kind === "urlset" ? "url" : "sitemap"}`
+ )
+ text = "";
+ },
+ ontext(value) {
+ if (text !== null && text.length < 2049)
+ text += (text ? value : value.trimStart()).slice(
+ 0,
+ 2049 - text.length,
+ );
+ },
+ onclosetag() {
+ if (
+ stack.length === 3 &&
+ stack[2] === `${prefix}loc` &&
+ text !== null
+ ) {
+ count++;
+ if (locations.length < (kind === "sitemapindex" ? 5 : maxUrls))
+ locations.push(text.trim());
+ text = null;
+ }
+ stack.pop();
+ },
+ },
+ { xmlMode: true, decodeEntities: true },
+ );
+ parser.end(xml);
+ return {
+ kind,
+ locations,
+ limited: count > (kind === "sitemapindex" ? 5 : maxUrls),
+ };
+}
diff --git a/site-crawl-plugin/src/store.ts b/site-crawl-plugin/src/store.ts
new file mode 100644
index 0000000..606c2fa
--- /dev/null
+++ b/site-crawl-plugin/src/store.ts
@@ -0,0 +1,97 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+import { Database } from "bun:sqlite";
+import { mkdirSync } from "node:fs";
+import { join } from "node:path";
+import type { Report } from "./types";
+export class Store {
+ private db: Database;
+ constructor(directory: string) {
+ mkdirSync(directory, { recursive: true });
+ this.db = new Database(join(directory, "site-crawl.sqlite"), {
+ create: true,
+ });
+ this.db.exec(
+ "PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS reports (id TEXT PRIMARY KEY, started_at TEXT NOT NULL, state TEXT NOT NULL, payload TEXT NOT NULL)",
+ );
+ this.db.exec(
+ "CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
+ );
+ for (const row of this.db
+ .query<{ payload: string }, []>(
+ "SELECT payload FROM reports WHERE state='running'",
+ )
+ .all()) {
+ const report = JSON.parse(row.payload) as Report;
+ report.state = "interrupted";
+ report.finishedAt = new Date().toISOString();
+ report.error =
+ "The plugin restarted during this crawl. Start a new crawl to finish checking the site.";
+ this.save(report);
+ }
+ }
+ save(report: Report) {
+ this.db
+ .query(
+ "INSERT INTO reports(id,started_at,state,payload) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET state=excluded.state,payload=excluded.payload",
+ )
+ .run(report.id, report.startedAt, report.state, JSON.stringify(report));
+ this.db.exec(
+ "DELETE FROM reports WHERE state != 'running' AND id NOT IN (SELECT id FROM reports ORDER BY started_at DESC LIMIT 30)",
+ );
+ }
+ list() {
+ return this.db
+ .query<{ payload: string }, []>(
+ "SELECT payload FROM reports ORDER BY started_at DESC LIMIT 30",
+ )
+ .all()
+ .map((row) => {
+ const report = JSON.parse(row.payload) as Report;
+ return {
+ ...report,
+ pages: undefined,
+ checked: report.pages.length,
+ errors: report.pages.filter((page) =>
+ page.issues.some((issue) => issue.severity === "error"),
+ ).length,
+ issues: report.pages.reduce(
+ (sum, page) => sum + page.issues.length,
+ 0,
+ ),
+ };
+ });
+ }
+ get(id: string): Report | null {
+ const row = this.db
+ .query<{ payload: string }, [string]>(
+ "SELECT payload FROM reports WHERE id=?",
+ )
+ .get(id);
+ return row ? (JSON.parse(row.payload) as Report) : null;
+ }
+ delete(id: string) {
+ this.db.query("DELETE FROM reports WHERE id=?").run(id);
+ }
+ metadata(key: string, fallback: T): T {
+ const row = this.db
+ .query<{ value: string }, [string]>(
+ "SELECT value FROM metadata WHERE key=?",
+ )
+ .get(key);
+ return row ? (JSON.parse(row.value) as T) : fallback;
+ }
+ setMetadata(key: string, value: unknown) {
+ this.db
+ .query(
+ "INSERT INTO metadata(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
+ )
+ .run(key, JSON.stringify(value));
+ }
+ transaction(action: () => void) {
+ this.db.transaction(action)();
+ }
+ close() {
+ this.db.close();
+ }
+}
diff --git a/site-crawl-plugin/src/types.ts b/site-crawl-plugin/src/types.ts
new file mode 100644
index 0000000..b47fa27
--- /dev/null
+++ b/site-crawl-plugin/src/types.ts
@@ -0,0 +1,65 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+export type Severity = "error" | "warning" | "info";
+export interface Issue {
+ code: string;
+ severity: Severity;
+ message: string;
+ fix: string;
+}
+export interface Page {
+ url: string;
+ finalUrl: string;
+ status: number | null;
+ durationMs: number;
+ title: string;
+ description: string;
+ canonical: string | null;
+ sources: string[];
+ redirects: string[];
+ issues: Issue[];
+}
+export interface Report {
+ id: string;
+ trigger?: DeploymentTrigger;
+ url: string;
+ maxPages: number;
+ state: "running" | "completed" | "cancelled" | "failed" | "interrupted";
+ startedAt: string;
+ finishedAt: string | null;
+ pages: Page[];
+ notices: string[];
+ discovered: number;
+ limited: boolean;
+ error: string | null;
+}
+export class CrawlError extends Error {
+ constructor(
+ public code: string,
+ message: string,
+ public status?: number,
+ ) {
+ super(message);
+ this.name = "CrawlError";
+ }
+}
+export function message(error: unknown): string {
+ return error instanceof Error ? error.message : "Unexpected crawl failure";
+}
+export interface DeploymentTrigger {
+ projectId: number;
+ environmentId: number;
+ environmentName: string;
+ deploymentId: number;
+}
+export interface AutomationSettings {
+ enabled: boolean;
+ productionOnly: boolean;
+ maxPages: number;
+ excludedProjects: number[];
+}
+export interface HostEventAccess {
+ configured: boolean;
+ reason: string | null;
+ setupPath: string;
+}
diff --git a/site-crawl-plugin/tsconfig.json b/site-crawl-plugin/tsconfig.json
new file mode 100644
index 0000000..06be346
--- /dev/null
+++ b/site-crawl-plugin/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noEmit": true,
+ "resolveJsonModule": true,
+ "types": ["bun"],
+ "skipLibCheck": true,
+ "allowImportingTsExtensions": true,
+ "jsx": "react-jsx"
+ },
+ "include": [
+ "src/**/*.ts",
+ "scripts/**/*.ts",
+ "web/**/*.tsx",
+ "web/**/*.ts",
+ "vite.config.ts"
+ ]
+}
diff --git a/site-crawl-plugin/vite.config.ts b/site-crawl-plugin/vite.config.ts
new file mode 100644
index 0000000..dcbf5ca
--- /dev/null
+++ b/site-crawl-plugin/vite.config.ts
@@ -0,0 +1,25 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+import { defineConfig } from "vite";
+import tailwindcss from "@tailwindcss/vite";
+export default defineConfig({
+ root: "web",
+ base: "./",
+ plugins: [tailwindcss()],
+ build: {
+ outDir: "dist",
+ emptyOutDir: true,
+ cssCodeSplit: false,
+ rollupOptions: {
+ onwarn(warning, warn) {
+ if (
+ warning.code === "MODULE_LEVEL_DIRECTIVE" &&
+ warning.message.includes("use client")
+ )
+ return;
+ warn(warning);
+ },
+ output: { entryFileNames: "app.js", assetFileNames: "style[extname]" },
+ },
+ },
+});
diff --git a/site-crawl-plugin/web/index.html b/site-crawl-plugin/web/index.html
new file mode 100644
index 0000000..dd11784
--- /dev/null
+++ b/site-crawl-plugin/web/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ Site Crawl · Temps
+
+
+
+
+
+
diff --git a/site-crawl-plugin/web/main.tsx b/site-crawl-plugin/web/main.tsx
new file mode 100644
index 0000000..af47214
--- /dev/null
+++ b/site-crawl-plugin/web/main.tsx
@@ -0,0 +1,741 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+import { useEffect, useState, type ReactNode } from "react";
+import { createRoot } from "react-dom/client";
+import {
+ QueryClient,
+ QueryClientProvider,
+ useQuery,
+ useMutation,
+ useQueryClient,
+} from "@tanstack/react-query";
+import {
+ ScanSearch,
+ RefreshCw,
+ Download,
+ Moon,
+ Sun,
+ Settings2,
+} from "lucide-react";
+import { PageContainer, PageHeader } from "./vendor/ds/page-header";
+import { Button } from "./vendor/ds/button";
+import { Field } from "./vendor/ds/field";
+import { Callout } from "./vendor/ds/callout";
+import { Status, type StatusTone } from "./vendor/ds/status";
+import { PageState } from "./vendor/ds/page-state";
+import {
+ Input,
+ Checkbox,
+ Label,
+ Table,
+ TableHeader,
+ TableBody,
+ TableRow,
+ TableHead,
+ TableCell,
+ Tabs,
+ TabsList,
+ TabsTrigger,
+ TabsContent,
+ AlertDialog,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogCancel,
+} from "./vendor/ui";
+import type { Report, AutomationSettings, HostEventAccess } from "../src/types";
+import "./style.css";
+const base = location.pathname.includes("/ui")
+ ? location.pathname.split("/ui")[0]
+ : "";
+async function api(
+ path: string,
+ method = "GET",
+ body?: unknown,
+): Promise {
+ const response = await fetch(`${base}/api${path}`, {
+ method,
+ headers: { "Content-Type": "application/json" },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ });
+ const result = await response.json();
+ if (!response.ok)
+ throw new Error(result.error || "Request failed. Retry the operation.");
+ return result;
+}
+type Summary = Omit & {
+ checked: number;
+ errors: number;
+ issues: number;
+};
+type Automation = {
+ settings: AutomationSettings;
+ access: HostEventAccess;
+ queued: number;
+ projects: { id: number; url: string }[];
+ notice: string | null;
+};
+const tone = (state: Report["state"]): StatusTone =>
+ state === "running"
+ ? "running"
+ : state === "completed"
+ ? "ok"
+ : state === "failed"
+ ? "error"
+ : "idle";
+function Section({
+ title,
+ children,
+ actions,
+}: {
+ title: string;
+ children: ReactNode;
+ actions?: ReactNode;
+}) {
+ return (
+
+
+
{title}
+ {actions &&
{actions}
}
+
+ {children}
+
+ );
+}
+function App() {
+ const cache = useQueryClient();
+ const [selected, setSelected] = useState(null);
+ const [tab, setTab] = useState("reports");
+ const [filter, setFilter] = useState("all");
+ const [url, setUrl] = useState("");
+ const [limit, setLimit] = useState(100);
+ const [draft, setDraft] = useState(null);
+ const [confirmation, setConfirmation] = useState<"delete" | "queue" | null>(
+ null,
+ );
+ const [dark, setDark] = useState(() => {
+ try {
+ return (
+ localStorage.getItem("site-crawl-theme") === "dark" ||
+ (!localStorage.getItem("site-crawl-theme") &&
+ matchMedia("(prefers-color-scheme: dark)").matches)
+ );
+ } catch {
+ return false;
+ }
+ });
+ useEffect(() => {
+ document.documentElement.classList.toggle("dark", dark);
+ try {
+ localStorage.setItem("site-crawl-theme", dark ? "dark" : "light");
+ } catch {}
+ }, [dark]);
+ const reports = useQuery({
+ queryKey: ["reports"],
+ queryFn: () => api("/reports"),
+ refetchInterval: 1500,
+ });
+ const automation = useQuery({
+ queryKey: ["automation"],
+ queryFn: () => api("/automation"),
+ refetchInterval: 15000,
+ });
+ const id = selected ?? reports.data?.[0]?.id;
+ const report = useQuery({
+ queryKey: ["report", id],
+ queryFn: () => api(`/reports/${id}`),
+ enabled: !!id,
+ refetchInterval: (q) => (q.state.data?.state === "running" ? 1500 : false),
+ });
+ const refresh = () => cache.invalidateQueries();
+ const start = useMutation({
+ mutationFn: () =>
+ api<{ id: string }>("/reports", "POST", { url, maxPages: limit }),
+ onSuccess: (r) => {
+ setSelected(r.id);
+ setTab("reports");
+ void refresh();
+ },
+ });
+ const save = useMutation({
+ mutationFn: () => api("/automation", "PUT", draft),
+ onSuccess: () => {
+ setDraft(null);
+ void refresh();
+ },
+ });
+ const action = useMutation({
+ mutationFn: ({ path, method }: { path: string; method: string }) =>
+ api(path, method),
+ onSuccess: (_, args) => {
+ if (args.method === "DELETE" && args.path.startsWith("/reports/"))
+ setSelected(null);
+ setConfirmation(null);
+ void refresh();
+ },
+ });
+ const settings = draft ?? automation.data?.settings;
+ const errors = [
+ reports.error,
+ automation.error,
+ report.error,
+ start.error,
+ save.error,
+ action.error,
+ ].filter(Boolean);
+ const running =
+ reports.data?.some((r) => r.state === "running") ||
+ (automation.data?.queued ?? 0) > 0;
+ const r = report.data;
+ const pages =
+ r?.pages.filter(
+ (p) => filter === "all" || p.issues.some((i) => i.severity === filter),
+ ) ?? [];
+ return (
+
+
+
+
+ >
+ }
+ />
+ {errors.length > 0 && (
+
+ {errors.map((e, i) => (
+ {e?.message}
+ ))}
+
+
+ )}
+
+
+
+ Public URLs · Same-origin links · Respects robots.txt · Server HTML,
+ without JavaScript rendering
+
+ {running && (
+
+ A crawl is running or queued. Wait for it to finish, or cancel it
+ before starting another.
+
+ )}
+
+
+
+ Crawl reports
+
+
+ Deployment automation
+
+
+
+
+ {reports.isPending ? (
+
+ Loading reports…
+
+ ) : !reports.data?.length ? (
+
+ ) : (
+
+
+
+ Site
+ Status
+ URLs
+ Started
+
+
+
+ {reports.data.map((item) => (
+
+
+
+ {item.trigger && (
+
+ Deployment #{item.trigger.deploymentId} ·{" "}
+ {item.trigger.environmentName}
+
+ )}
+
+
+
+
+ {item.checked}
+
+ {new Date(item.startedAt).toLocaleString()}
+
+
+ ))}
+
+
+ )}
+
+ {r && (
+
+
+ {r.state === "running" ? (
+
+ ) : (
+
+ )}
+ >
+ }
+ >
+
+
+
+
+ {r.url}
+
+
+
+ {[
+ [r.pages.length, "URLs checked"],
+ [
+ r.pages.filter((p) =>
+ p.issues.some((i) => i.severity === "error"),
+ ).length,
+ "Routes with errors",
+ ],
+ [
+ r.pages.reduce(
+ (n, p) =>
+ n +
+ p.issues.filter((i) => i.severity === "warning")
+ .length,
+ 0,
+ ),
+ "SEO warnings",
+ ],
+ ].map(([value, label]) => (
+
+
- {label}
+ -
+ {value}
+
+
+ ))}
+
+ {r.trigger && (
+
+ Deployment #{r.trigger.deploymentId} ·{" "}
+ {r.trigger.environmentName} · automatic crawl
+
+ )}
+ {r.error &&
{r.error}}
+ {r.notices.map((notice, i) => (
+
{notice}
+ ))}
+ {r.state === "running" && (
+
+ Crawling… {r.discovered} URLs discovered. You can leave this
+ page; the crawl continues.
+
+ )}
+
+ {[
+ ["all", "All URLs"],
+ ["error", "Broken routes"],
+ ["warning", "SEO warnings"],
+ ].map(([value, label]) => (
+
+ ))}
+
+ {!pages.length ? (
+
+ {r.state === "running"
+ ? "Waiting for matching results…"
+ : "No matching URLs in this report."}
+
+ ) : (
+
+ {pages.map((page) => (
+
+
+ i.severity === "error")
+ ? "error"
+ : page.issues.some(
+ (i) => i.severity === "warning",
+ )
+ ? "warn"
+ : "idle"
+ }
+ label={
+ page.status === null
+ ? "Not checked"
+ : String(page.status)
+ }
+ />
+
+ {page.url}
+
+
+ {page.issues.length} issues
+
+
+
+ {page.title && (
+
{page.title}
+ )}
+ {page.finalUrl !== page.url && (
+
+ Final URL: {page.finalUrl}
+
+ )}
+ {page.issues.map((issue, i) => (
+
+ {issue.fix}
+
+ ))}
+ {!page.issues.length && (
+
No issues found by these checks.
+ )}
+
Discovered from
+ {page.sources.map((source) => (
+
+ {source}
+
+ ))}
+
+
+ ))}
+
+ )}
+
+
+ )}
+
+
+
+ }
+ >
+ {!settings ? (
+ Loading automation settings…
+ ) : (
+
+ )}
+
+
+
+ {
+ if (!open && !action.isPending) setConfirmation(null);
+ }}
+ >
+
+
+
+ {confirmation === "delete"
+ ? "Delete this crawl report?"
+ : "Clear the deployment queue?"}
+
+
+ {confirmation === "delete"
+ ? `This permanently removes the saved report for ${r?.url}.`
+ : "Queued deployments will not be crawled. An active crawl will continue."}
+
+
+ {action.error && (
+
+ {action.error.message}
+
+ )}
+
+
+ Keep {confirmation === "delete" ? "report" : "queue"}
+
+
+
+
+
+
+ );
+}
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/site-crawl-plugin/web/style.css b/site-crawl-plugin/web/style.css
new file mode 100644
index 0000000..f5837de
--- /dev/null
+++ b/site-crawl-plugin/web/style.css
@@ -0,0 +1,56 @@
+/* SPDX-FileCopyrightText: 2024-2026 Temps Contributors */
+/* SPDX-License-Identifier: MIT OR Apache-2.0 */
+@import "tailwindcss";
+@import "./vendor/ds/tokens.css";
+@source '.';
+@custom-variant dark (&:is(.dark *));
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-success: var(--success);
+ --color-success-foreground: var(--success-foreground);
+ --color-warning: var(--warning);
+ --color-warning-foreground: var(--warning-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --font-sans: var(--font-sans);
+ --font-mono: var(--font-mono);
+ --radius-sm: 0.25rem;
+ --radius-md: 0.375rem;
+ --radius-lg: 0.5rem;
+}
+@layer base {
+ * {
+ border-color: var(--border);
+ }
+ body {
+ margin: 0;
+ background: var(--background);
+ color: var(--foreground);
+ font-family: var(--font-sans);
+ -webkit-font-smoothing: antialiased;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation: none !important;
+ transition: none !important;
+ }
+}
diff --git a/site-crawl-plugin/web/vendor/README.md b/site-crawl-plugin/web/vendor/README.md
new file mode 100644
index 0000000..aeb8c23
--- /dev/null
+++ b/site-crawl-plugin/web/vendor/README.md
@@ -0,0 +1,7 @@
+# Temps design-system source snapshot
+
+Source: `gotempsh/temps`, worktree `design-system-ds`, HEAD `eeed336a2085bb3887aac97cbeae67325c1ee102` (including the worktree contents at integration time).
+
+These are the actual components from `web/packages/ds/src` and their required primitives from `web/src/components/ui`. Only import paths are adapted to make this plugin independently buildable. `cn.ts` supplies the same clsx/tailwind-merge helper. `ds/tokens.css` is copied without changes. Copyright and dual-license notices are preserved.
+
+The source packages currently reference files outside their package roots and workspace dependencies. This checked-in subset avoids a dependency on a developer's filesystem. Replace it with published standalone packages when available. Refresh components and tokens from the source worktree together; do not make plugin-specific visual edits here.
diff --git a/site-crawl-plugin/web/vendor/cn.ts b/site-crawl-plugin/web/vendor/cn.ts
new file mode 100644
index 0000000..487a037
--- /dev/null
+++ b/site-crawl-plugin/web/vendor/cn.ts
@@ -0,0 +1,5 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
diff --git a/site-crawl-plugin/web/vendor/ds/button.tsx b/site-crawl-plugin/web/vendor/ds/button.tsx
new file mode 100644
index 0000000..cdb523d
--- /dev/null
+++ b/site-crawl-plugin/web/vendor/ds/button.tsx
@@ -0,0 +1,70 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+
+import { forwardRef } from 'react'
+import { Loader2 } from 'lucide-react'
+import { Button as BaseButton, type ButtonProps as BaseButtonProps } from '../ui'
+import { cn } from '../cn'
+
+export interface ButtonProps extends BaseButtonProps {
+ /**
+ * True while the action this button triggers is in flight. Renders a
+ * spinner and `busyLabel` (falling back to the normal children), and
+ * blocks re-submission — but never sets the native `disabled` attribute.
+ * A disabled button drops keyboard focus mid-action and some screen
+ * readers stop announcing it, right when the user most needs to know
+ * their click registered. Use `aria-disabled` + a no-op click guard
+ * instead, so the button stays focused, visible, and honestly labeled.
+ */
+ busy?: boolean
+ busyLabel?: React.ReactNode
+}
+
+export const Button = forwardRef(
+ ({ busy = false, busyLabel, children, className, onClick, asChild, ...props }, ref) => {
+ const handleClick = (event: React.MouseEvent) => {
+ if (busy) {
+ event.preventDefault()
+ return
+ }
+ onClick?.(event)
+ }
+
+ // `asChild` hands rendering to Radix `Slot`, which requires exactly one
+ // React element child to merge its props onto — the spinner/busyLabel
+ // composition below would add a sibling node and break that contract
+ // (a busy `asChild` button is also a contradiction in terms: `asChild`
+ // means "render as this other element", not "render a spinner inside
+ // it"), so pass `children` straight through unmodified in that case.
+ if (asChild) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ return (
+
+ {busy ? : null}
+ {busy ? (busyLabel ?? children) : children}
+
+ )
+ },
+)
+Button.displayName = 'Button'
diff --git a/site-crawl-plugin/web/vendor/ds/callout.tsx b/site-crawl-plugin/web/vendor/ds/callout.tsx
new file mode 100644
index 0000000..0f1afd8
--- /dev/null
+++ b/site-crawl-plugin/web/vendor/ds/callout.tsx
@@ -0,0 +1,49 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+
+import type { ReactNode } from 'react'
+import { AlertTriangle, CheckCircle2, Info, XCircle } from 'lucide-react'
+import { cn } from '../cn'
+
+export type CalloutTone = 'info' | 'success' | 'warning' | 'error'
+
+const TONE_META: Record = {
+ info: { icon: Info, classes: 'border-border bg-muted/40 text-foreground' },
+ success: {
+ icon: CheckCircle2,
+ classes: 'border-success/30 bg-success/10 text-foreground',
+ },
+ warning: {
+ icon: AlertTriangle,
+ classes: 'border-warning/30 bg-warning/10 text-foreground',
+ },
+ error: {
+ icon: XCircle,
+ classes: 'border-destructive/30 bg-destructive/10 text-foreground',
+ },
+}
+
+export interface CalloutProps {
+ tone?: CalloutTone
+ title?: ReactNode
+ children: ReactNode
+ className?: string
+}
+
+/** A tone-only inline notice — for in-page banners, not toasts. See RULES.md § Notifications. */
+export function Callout({ tone = 'info', title, children, className }: CalloutProps) {
+ const meta = TONE_META[tone]
+ const Icon = meta.icon
+ return (
+
+
+
+ {title ?
{title}
: null}
+
{children}
+
+
+ )
+}
diff --git a/site-crawl-plugin/web/vendor/ds/field.tsx b/site-crawl-plugin/web/vendor/ds/field.tsx
new file mode 100644
index 0000000..85c75ed
--- /dev/null
+++ b/site-crawl-plugin/web/vendor/ds/field.tsx
@@ -0,0 +1,72 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+
+import { useId, type ReactNode } from 'react'
+import { Label } from '../ui'
+import { Callout } from './callout'
+import { cn } from '../cn'
+
+export interface FieldProps {
+ label: ReactNode
+ /** Renders the field optional to make required the default, the honest case for most forms. */
+ optional?: boolean
+ description?: ReactNode
+ /** A single field-level validation message (e.g. `formState.errors.name?.message`). */
+ error?: string
+ children: (props: { id: string; 'aria-describedby'?: string; 'aria-invalid'?: boolean }) => ReactNode
+ className?: string
+}
+
+/**
+ * One labeled form control: label, the control (via render prop so `Field`
+ * stays agnostic to react-hook-form/plain state), help text, and an error
+ * message — wired together with matching `id`/`aria-describedby` so a
+ * screen reader announces the error when the control receives focus.
+ */
+export function Field({ label, optional, description, error, children, className }: FieldProps) {
+ const id = useId()
+ const descId = description ? `${id}-description` : undefined
+ const errorId = error ? `${id}-error` : undefined
+ const describedBy = [descId, errorId].filter(Boolean).join(' ') || undefined
+
+ return (
+
+
+
+ {optional ? Optional : null}
+
+ {children({ id, 'aria-describedby': describedBy, 'aria-invalid': !!error })}
+ {description ? (
+
+ {description}
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ )
+}
+
+export interface FormErrorsProps {
+ /** Field-label -> message. Pass react-hook-form's `formState.errors` mapped to this shape. */
+ errors: Record
+ className?: string
+}
+
+/** A summary of every current validation error, for above a long form's sticky save bar. */
+export function FormErrors({ errors, className }: FormErrorsProps) {
+ const entries = Object.entries(errors).filter((entry): entry is [string, string] => !!entry[1])
+ if (entries.length === 0) return null
+ return (
+
+
+ {entries.map(([field, message]) => (
+ - {message}
+ ))}
+
+
+ )
+}
diff --git a/site-crawl-plugin/web/vendor/ds/page-header.tsx b/site-crawl-plugin/web/vendor/ds/page-header.tsx
new file mode 100644
index 0000000..417bdda
--- /dev/null
+++ b/site-crawl-plugin/web/vendor/ds/page-header.tsx
@@ -0,0 +1,75 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+
+import type { ReactNode } from 'react'
+import { cn } from '../cn'
+
+// Promoted from web/src/components/layout/PageContainer.tsx (it was already
+// solid — full-width shell, responsive padding). web/src's own
+// PageContainer.tsx now re-exports this module instead of defining its own
+// copy, so there is exactly one implementation. `PageHeader` is extended
+// here with the optional `verdict` slot the record recipe needs (title ->
+// verdict -> facts).
+
+export interface PageContainerProps {
+ /** Extra classes on the padded outer wrapper. */
+ className?: string
+ /** Extra classes on the content wrapper (e.g. spacing). */
+ innerClassName?: string
+ children: ReactNode
+}
+
+/** Standard full-width page shell — owns responsive horizontal padding. */
+export function PageContainer({ className, innerClassName, children }: PageContainerProps) {
+ return (
+
+ )
+}
+
+export interface PageHeaderProps {
+ title: ReactNode
+ description?: ReactNode
+ /**
+ * The record recipe's second beat: a `Status` or similar one-glance
+ * verdict, rendered directly under the title before any facts. Omit on
+ * list/settings pages — only record (`Detail`) pages have a verdict.
+ */
+ verdict?: ReactNode
+ actions?: ReactNode
+ className?: string
+}
+
+export function PageHeader({
+ title,
+ description,
+ verdict,
+ actions,
+ className,
+}: PageHeaderProps) {
+ return (
+
+
+
+
{title}
+ {verdict}
+
+ {description ? (
+
{description}
+ ) : null}
+
+ {actions ? (
+
+ {actions}
+
+ ) : null}
+
+ )
+}
diff --git a/site-crawl-plugin/web/vendor/ds/page-state.tsx b/site-crawl-plugin/web/vendor/ds/page-state.tsx
new file mode 100644
index 0000000..73cc89c
--- /dev/null
+++ b/site-crawl-plugin/web/vendor/ds/page-state.tsx
@@ -0,0 +1,97 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+
+import type { ReactNode } from 'react'
+import type { LucideIcon } from 'lucide-react'
+import { Button } from '../ui'
+import { cn } from '../cn'
+
+/**
+ * Consolidates `EmptyPlaceholder` and `EmptyState`
+ * (web/src/components/ui/empty-placeholder.tsx, empty-state.tsx — 101 lines
+ * combined, near-duplicates) into one primitive covering every reason a page
+ * has nothing to show:
+ *
+ * - `empty`: the resource exists and is configured, there's just nothing in
+ * it yet ("No deployments yet").
+ * - `not-set-up`: CLAUDE.md's rule, made a first-class variant instead of a
+ * nice-to-have. A feature that depends on optional operator config (AI
+ * provider, S3 bucket, SMTP, DNS token) must never render nothing —
+ * `requirement` and `example` are REQUIRED for this variant so the
+ * surface always says what's missing and what it would do, and `action`
+ * should link straight to the settings page that configures it.
+ * - `failed`: a request errored. Pairs with a retry action.
+ */
+export type PageStateVariant = 'empty' | 'not-set-up' | 'failed'
+
+interface PageStateBaseProps {
+ icon: LucideIcon
+ title: string
+ description?: ReactNode
+ action?: ReactNode
+ size?: 'default' | 'compact'
+ className?: string
+}
+
+interface EmptyPageStateProps extends PageStateBaseProps {
+ variant: 'empty' | 'failed'
+}
+
+interface NotSetUpPageStateProps extends PageStateBaseProps {
+ variant: 'not-set-up'
+ /** What's missing, in plain words — "No AI provider configured." */
+ requirement: string
+ /** A concrete example of what the feature would do once configured. */
+ example: ReactNode
+ /** Link straight to the settings page that configures it. */
+ settingsHref: string
+ settingsLabel?: string
+}
+
+export type PageStateProps = EmptyPageStateProps | NotSetUpPageStateProps
+
+export function PageState(props: PageStateProps) {
+ const { icon: Icon, title, size = 'default', className } = props
+ const compact = size === 'compact'
+
+ return (
+
+
+
+
+
+
{title}
+ {props.variant === 'not-set-up' ? (
+
+
{props.requirement}
+
+ Example:
+ {props.example}
+
+
+ ) : props.description ? (
+
{props.description}
+ ) : null}
+
+ {props.variant === 'not-set-up' ? (
+
+ ) : (
+ props.action
+ )}
+
+ )
+}
diff --git a/site-crawl-plugin/web/vendor/ds/status.tsx b/site-crawl-plugin/web/vendor/ds/status.tsx
new file mode 100644
index 0000000..e277326
--- /dev/null
+++ b/site-crawl-plugin/web/vendor/ds/status.tsx
@@ -0,0 +1,134 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+
+import {
+ Circle,
+ CircleCheck,
+ CircleX,
+ LoaderCircle,
+ TriangleAlert,
+ type LucideIcon,
+} from 'lucide-react'
+import { Badge } from '../ui'
+import { cn } from '../cn'
+
+/**
+ * The console's whole status vocabulary. Generalizes the shape already used
+ * by `AlertStateBadge`/`StatusDot` (web/src/components/metrics/alert-format.tsx)
+ * and `STATUS_META` (alert-status.ts) beyond alerting to every tone-driven
+ * state in the app — deployments, services, backups, nodes. Five tones only;
+ * do not add a sixth without checking every Badge variant="..." call site
+ * first (variants map 1:1 onto `--success`/`--warning`/`--destructive`).
+ *
+ * Each tone pairs a color with a shape (an icon), never color alone — a
+ * small dot still exists for the compact `variant="dot"` row form where a
+ * full icon would be too heavy, but the default badge form always shows the
+ * icon so meaning survives colorblindness at a glance, not just via the word.
+ */
+export type StatusTone = 'ok' | 'warn' | 'error' | 'idle' | 'running'
+
+interface StatusToneMeta {
+ label: string
+ dotClass: string
+ icon: LucideIcon
+ badgeVariant: 'success' | 'warning' | 'destructive' | 'secondary' | 'default'
+ pulse?: boolean
+ spin?: boolean
+}
+
+export const STATUS_TONES: Record = {
+ ok: {
+ label: 'OK',
+ dotClass: 'bg-success',
+ icon: CircleCheck,
+ badgeVariant: 'success',
+ },
+ warn: {
+ label: 'Warn',
+ dotClass: 'bg-warning',
+ icon: TriangleAlert,
+ badgeVariant: 'warning',
+ },
+ error: {
+ label: 'Error',
+ dotClass: 'bg-destructive',
+ icon: CircleX,
+ badgeVariant: 'destructive',
+ pulse: true,
+ },
+ idle: {
+ label: 'Idle',
+ dotClass: 'bg-muted-foreground',
+ icon: Circle,
+ badgeVariant: 'secondary',
+ },
+ running: {
+ label: 'Running',
+ dotClass: 'bg-primary',
+ icon: LoaderCircle,
+ badgeVariant: 'default',
+ pulse: true,
+ spin: true,
+ },
+}
+
+export function StatusDot({
+ tone,
+ className,
+}: {
+ tone: StatusTone
+ className?: string
+}) {
+ const meta = STATUS_TONES[tone]
+ return (
+
+ {meta.pulse ? (
+
+ ) : null}
+
+
+ )
+}
+
+export interface StatusProps {
+ tone: StatusTone
+ /** Overrides the tone's default word (e.g. "3 series firing" instead of "Error"). */
+ label?: string
+ /** Renders icon + badge (default) or a compact dot + word, for dense table rows. */
+ variant?: 'badge' | 'dot'
+ className?: string
+}
+
+/**
+ * The one status primitive: a tone-driven icon (or, in `variant="dot"`, a
+ * small dot) + word, in a `Badge` by default. Color never stands alone —
+ * shape and word both carry the meaning too, so it survives colorblindness
+ * and B/W printing.
+ */
+export function Status({ tone, label, variant = 'badge', className }: StatusProps) {
+ const meta = STATUS_TONES[tone]
+ const text = label ?? meta.label
+ if (variant === 'dot') {
+ return (
+
+
+ {text}
+
+ )
+ }
+ const Icon = meta.icon
+ return (
+
+
+ {text}
+
+ )
+}
diff --git a/site-crawl-plugin/web/vendor/ds/tokens.css b/site-crawl-plugin/web/vendor/ds/tokens.css
new file mode 100644
index 0000000..05aff20
--- /dev/null
+++ b/site-crawl-plugin/web/vendor/ds/tokens.css
@@ -0,0 +1,116 @@
+/* SPDX-FileCopyrightText: 2024-2026 Temps Contributors */
+/* SPDX-License-Identifier: MIT OR Apache-2.0 */
+
+/* GENERATED FILE — do not hand-edit. Run `node scripts/tokens.mjs build` from
+ web/packages/ds after changing tokens.json. `bun run lint` (tokens:check)
+ fails the build if this file drifts from tokens.json or from the app's own
+ web/src/globals.css.
+
+ Scoped to .tds, never :root — this file must never fight the host
+ app's own :root theme. Consumers opt in by adding the .tds class
+ to a subtree (the sandbox app's does this; see design-system/). */
+
+.tds {
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.145 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.985 0 0);
+ --secondary-foreground: oklch(0.145 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.955 0 0);
+ --accent-foreground: oklch(0.145 0 0);
+ --destructive: oklch(0.627 0.2193 23.03);
+ --destructive-foreground: oklch(0.985 0 0);
+ --success: oklch(0.627 0.1684 150.31);
+ --success-foreground: oklch(0.985 0 0);
+ --warning: oklch(0.773 0.1776 69.61);
+ --warning-foreground: oklch(0.145 0 0);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.59 0.2032 256.82);
+ --chart-1: oklch(0.59 0.2032 256.82);
+ --chart-2: oklch(0.723 0.1844 150.31);
+ --chart-3: oklch(0.773 0.1776 69.61);
+ --chart-4: oklch(0.627 0.2193 23.03);
+ --chart-5: oklch(0.645 0.2253 296.37);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.145 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.955 0 0);
+ --sidebar-accent-foreground: oklch(0.145 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.59 0.2032 256.82);
+ --shadow-2xs: 0px 4px 10px 0px hsl(0 0% 0% / 0.05);
+ --shadow-xs: 0px 4px 10px 0px hsl(0 0% 0% / 0.05);
+ --shadow-sm: 0px 4px 10px 0px hsl(0 0% 0% / 0.1), 0px 1px 2px -1px hsl(0 0% 0% / 0.1);
+ --shadow: 0px 4px 10px 0px hsl(0 0% 0% / 0.1), 0px 1px 2px -1px hsl(0 0% 0% / 0.1);
+ --shadow-md: 0px 4px 10px 0px hsl(0 0% 0% / 0.1), 0px 2px 4px -1px hsl(0 0% 0% / 0.1);
+ --shadow-lg: 0px 4px 10px 0px hsl(0 0% 0% / 0.1), 0px 4px 6px -1px hsl(0 0% 0% / 0.1);
+ --shadow-xl: 0px 4px 10px 0px hsl(0 0% 0% / 0.1), 0px 8px 10px -1px hsl(0 0% 0% / 0.1);
+ --shadow-2xl: 0px 4px 10px 0px hsl(0 0% 0% / 0.25);
+ --radius: 0.5rem;
+ --radius-sm: 0.25rem;
+ --radius-md: 0.375rem;
+ --radius-lg: 0.5rem;
+ --radius-xl: 0.75rem;
+ --spacing: 0.25rem;
+ --tracking-normal: 0rem;
+ --letter-spacing: 0rem;
+ --font-sans: Geist, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ --font-serif: Geist, -apple-system, BlinkMacSystemFont, sans-serif;
+ --font-mono: 'Geist Mono', ui-monospace, 'Cascadia Code', 'SF Mono', 'Consolas', monospace;
+}
+
+.tds.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.985 0 0);
+ --primary-foreground: oklch(0.145 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.178 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.322 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.627 0.2193 23.03);
+ --destructive-foreground: oklch(0.985 0 0);
+ --success: oklch(0.627 0.1684 150.31);
+ --success-foreground: oklch(0.985 0 0);
+ --warning: oklch(0.773 0.1776 69.61);
+ --warning-foreground: oklch(0.145 0 0);
+ --border: oklch(0.322 0 0);
+ --input: oklch(0.322 0 0);
+ --ring: oklch(0.59 0.2032 256.82);
+ --chart-1: oklch(0.59 0.2032 256.82);
+ --chart-2: oklch(0.723 0.1844 150.31);
+ --chart-3: oklch(0.773 0.1776 69.61);
+ --chart-4: oklch(0.627 0.2193 23.03);
+ --chart-5: oklch(0.645 0.2253 296.37);
+ --sidebar: oklch(0.145 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.985 0 0);
+ --sidebar-primary-foreground: oklch(0.145 0 0);
+ --sidebar-accent: oklch(0.205 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(0.269 0 0);
+ --sidebar-ring: oklch(0.59 0.2032 256.82);
+ --shadow-2xs: 0px 4px 15px 0px hsl(0 0% 0% / 0.2);
+ --shadow-xs: 0px 4px 15px 0px hsl(0 0% 0% / 0.2);
+ --shadow-sm: 0px 4px 15px 0px hsl(0 0% 0% / 0.4), 0px 1px 2px -1px hsl(0 0% 0% / 0.4);
+ --shadow: 0px 4px 15px 0px hsl(0 0% 0% / 0.4), 0px 1px 2px -1px hsl(0 0% 0% / 0.4);
+ --shadow-md: 0px 4px 15px 0px hsl(0 0% 0% / 0.4), 0px 2px 4px -1px hsl(0 0% 0% / 0.4);
+ --shadow-lg: 0px 4px 15px 0px hsl(0 0% 0% / 0.4), 0px 4px 6px -1px hsl(0 0% 0% / 0.4);
+ --shadow-xl: 0px 4px 15px 0px hsl(0 0% 0% / 0.4), 0px 8px 10px -1px hsl(0 0% 0% / 0.4);
+ --shadow-2xl: 0px 4px 15px 0px hsl(0 0% 0% / 1);
+}
diff --git a/site-crawl-plugin/web/vendor/ui/alert-dialog.tsx b/site-crawl-plugin/web/vendor/ui/alert-dialog.tsx
new file mode 100644
index 0000000..9463f72
--- /dev/null
+++ b/site-crawl-plugin/web/vendor/ui/alert-dialog.tsx
@@ -0,0 +1,142 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+
+import * as React from 'react'
+import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
+
+import { cn } from '../cn'
+import { buttonVariants } from './button'
+
+const AlertDialog = AlertDialogPrimitive.Root
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+))
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogHeader.displayName = 'AlertDialogHeader'
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogFooter.displayName = 'AlertDialogFooter'
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/site-crawl-plugin/web/vendor/ui/badge.tsx b/site-crawl-plugin/web/vendor/ui/badge.tsx
new file mode 100644
index 0000000..83df848
--- /dev/null
+++ b/site-crawl-plugin/web/vendor/ui/badge.tsx
@@ -0,0 +1,44 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+
+import * as React from 'react'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '../cn'
+
+const badgeVariants = cva(
+ 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
+ {
+ variants: {
+ variant: {
+ default:
+ 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
+ secondary:
+ 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
+ destructive:
+ 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
+ success:
+ 'border-transparent bg-success text-success-foreground hover:bg-success/80',
+ warning:
+ 'border-transparent bg-warning text-warning-foreground hover:bg-warning/80',
+ outline: 'text-foreground',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ }
+)
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ )
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export { Badge, badgeVariants }
diff --git a/site-crawl-plugin/web/vendor/ui/button.tsx b/site-crawl-plugin/web/vendor/ui/button.tsx
new file mode 100644
index 0000000..4871711
--- /dev/null
+++ b/site-crawl-plugin/web/vendor/ui/button.tsx
@@ -0,0 +1,59 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '../cn'
+
+const buttonVariants = cva(
+ 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+ destructive:
+ 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
+ outline:
+ 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
+ secondary:
+ 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+ ghost: 'hover:bg-accent hover:text-accent-foreground',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-10 px-4 py-2',
+ sm: 'h-9 rounded-md px-3',
+ lg: 'h-11 rounded-md px-8',
+ icon: 'h-10 w-10',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ }
+)
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : 'button'
+ return (
+
+ )
+ }
+)
+Button.displayName = 'Button'
+
+export { Button, buttonVariants }
diff --git a/site-crawl-plugin/web/vendor/ui/checkbox.tsx b/site-crawl-plugin/web/vendor/ui/checkbox.tsx
new file mode 100644
index 0000000..4e2c7bb
--- /dev/null
+++ b/site-crawl-plugin/web/vendor/ui/checkbox.tsx
@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+
+import * as React from 'react'
+import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
+import { Check } from 'lucide-react'
+
+import { cn } from '../cn'
+
+const Checkbox = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+))
+Checkbox.displayName = CheckboxPrimitive.Root.displayName
+
+export { Checkbox }
diff --git a/site-crawl-plugin/web/vendor/ui/index.ts b/site-crawl-plugin/web/vendor/ui/index.ts
new file mode 100644
index 0000000..35b47ad
--- /dev/null
+++ b/site-crawl-plugin/web/vendor/ui/index.ts
@@ -0,0 +1,10 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+export * from "./button";
+export * from "./input";
+export * from "./label";
+export * from "./checkbox";
+export * from "./badge";
+export * from "./table";
+export * from "./alert-dialog";
+export * from "./tabs";
diff --git a/site-crawl-plugin/web/vendor/ui/input.tsx b/site-crawl-plugin/web/vendor/ui/input.tsx
new file mode 100644
index 0000000..0a59217
--- /dev/null
+++ b/site-crawl-plugin/web/vendor/ui/input.tsx
@@ -0,0 +1,26 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+
+import * as React from 'react'
+
+import { cn } from '../cn'
+
+const Input = React.forwardRef>(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Input.displayName = 'Input'
+
+export { Input }
diff --git a/site-crawl-plugin/web/vendor/ui/label.tsx b/site-crawl-plugin/web/vendor/ui/label.tsx
new file mode 100644
index 0000000..4a9a1d6
--- /dev/null
+++ b/site-crawl-plugin/web/vendor/ui/label.tsx
@@ -0,0 +1,27 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+
+import * as React from 'react'
+import * as LabelPrimitive from '@radix-ui/react-label'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '../cn'
+
+const labelVariants = cva(
+ 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
+)
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/site-crawl-plugin/web/vendor/ui/table.tsx b/site-crawl-plugin/web/vendor/ui/table.tsx
new file mode 100644
index 0000000..180d3a5
--- /dev/null
+++ b/site-crawl-plugin/web/vendor/ui/table.tsx
@@ -0,0 +1,120 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+
+import * as React from 'react'
+
+import { cn } from '../cn'
+
+const Table = React.forwardRef<
+ HTMLTableElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Table.displayName = 'Table'
+
+const TableHeader = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableHeader.displayName = 'TableHeader'
+
+const TableBody = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableBody.displayName = 'TableBody'
+
+const TableFooter = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+ tr]:last:border-b-0',
+ className
+ )}
+ {...props}
+ />
+))
+TableFooter.displayName = 'TableFooter'
+
+const TableRow = React.forwardRef<
+ HTMLTableRowElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableRow.displayName = 'TableRow'
+
+const TableHead = React.forwardRef<
+ HTMLTableCellElement,
+ React.ThHTMLAttributes
+>(({ className, ...props }, ref) => (
+ |
+))
+TableHead.displayName = 'TableHead'
+
+const TableCell = React.forwardRef<
+ HTMLTableCellElement,
+ React.TdHTMLAttributes
+>(({ className, ...props }, ref) => (
+ |
+))
+TableCell.displayName = 'TableCell'
+
+const TableCaption = React.forwardRef<
+ HTMLTableCaptionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableCaption.displayName = 'TableCaption'
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/site-crawl-plugin/web/vendor/ui/tabs.tsx b/site-crawl-plugin/web/vendor/ui/tabs.tsx
new file mode 100644
index 0000000..41b156c
--- /dev/null
+++ b/site-crawl-plugin/web/vendor/ui/tabs.tsx
@@ -0,0 +1,74 @@
+// SPDX-FileCopyrightText: 2024-2026 Temps Contributors
+// SPDX-License-Identifier: MIT OR Apache-2.0
+
+import * as React from 'react'
+import * as TabsPrimitive from '@radix-ui/react-tabs'
+
+import { cn } from '../cn'
+
+const Tabs = TabsPrimitive.Root
+
+const TabsList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsList.displayName = TabsPrimitive.List.displayName
+
+const TabsTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
+
+const TabsContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+TabsContent.displayName = TabsPrimitive.Content.displayName
+
+/** Scrolls only the tab strip; Radix retains keyboard navigation and panel semantics. */
+const ScrollableTabsList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+ button]:shrink-0 [&>button]:min-h-9',
+ className
+ )}
+ {...props}
+ />
+
+))
+ScrollableTabsList.displayName = 'ScrollableTabsList'
+
+export { Tabs, TabsList, ScrollableTabsList, TabsTrigger, TabsContent }