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: 'Fixture

Fixture

', + }), + }); + 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: "Fixture

Fixture

", +}); +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( + 'Home

Home

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 reference

CLI

' +
+          "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 & B

Heading

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( + 'ActualSVGXReal', + 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 <html>.", + ); + 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 <head>. 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<T>(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 @@ +<!doctype html> +<!-- SPDX-FileCopyrightText: 2024-2026 Temps Contributors --> +<!-- SPDX-License-Identifier: MIT OR Apache-2.0 --> +<html lang="en" class="tds"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>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}

+ ))} + +
+ )} +
+
{ + e.preventDefault(); + if (!start.isPending && !running) start.mutate(); + }} + > + + {(props) => ( + setUrl(e.target.value)} + placeholder="https://your-site.com" + /> + )} + + + {(props) => ( + setLimit(Number(e.target.value))} + /> + )} + + +
+

+ 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…

+ ) : ( +
{ + e.preventDefault(); + if (draft && !save.isPending) save.mutate(); + }} + > + {!automation.data?.access.configured && ( + + {automation.data?.access.reason} +

+ After setup, each successful deployment creates a report + of broken routes and SEO issues. +

+ +
+ )} + {automation.data?.notice && ( + {automation.data.notice} + )} +
+ {[ + ["enabled", "Automatically crawl successful deployments"], + ["productionOnly", "Production only"], + ].map(([key, label]) => ( +
+ + setDraft({ ...settings, [key!]: value === true }) + } + /> + +
+ ))} +
+ + {(props) => ( + + setDraft({ + ...settings, + maxPages: Number(e.target.value), + }) + } + /> + )} + +
+

Projects

+ {!automation.data?.projects.length ? ( +

+ Project controls appear as deployment events arrive. All + projects are included by default. +

+ ) : ( + automation.data.projects.map((project) => ( +
+ + setDraft({ + ...settings, + excludedProjects: + value === true + ? settings.excludedProjects.filter( + (id) => id !== project.id, + ) + : [...settings.excludedProjects, project.id], + }) + } + /> + +
+ )) + )} +
+

+ Crawls wait five seconds after deployment. Reports identify + the deployment that triggered them. Disabling automation + removes queued work; cancel an active crawl separately. +

+
+ + {save.isSuccess && !draft && ( + + Settings saved. + + )} + {!!automation.data?.queued && ( + + )} +
+
+ )} +
+
+ + { + 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 ( +
+
{children}
+
+ ) +} + +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 }