From 1501931b810bf279db3ed017eb2a8ecf8696f491 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Thu, 17 Sep 2026 20:22:11 +0200 Subject: [PATCH 1/3] feat(site-crawl): audit routes and SEO after deployments Signed-off-by: David Viejo --- .github/workflows/ci.yml | 18 + README.md | 29 ++ site-crawl-plugin/.gitignore | 3 + site-crawl-plugin/bun.lock | 88 +++++ site-crawl-plugin/package.json | 31 ++ site-crawl-plugin/src/app.test.ts | 113 ++++++ site-crawl-plugin/src/app.ts | 156 ++++++++ site-crawl-plugin/src/assets.ts | 31 ++ site-crawl-plugin/src/automation.test.ts | 176 +++++++++ site-crawl-plugin/src/automation.ts | 348 +++++++++++++++++ site-crawl-plugin/src/crawler.test.ts | 243 ++++++++++++ site-crawl-plugin/src/crawler.ts | 360 +++++++++++++++++ site-crawl-plugin/src/dev.ts | 26 ++ site-crawl-plugin/src/globals.d.ts | 14 + site-crawl-plugin/src/host.ts | 29 ++ site-crawl-plugin/src/http.ts | 147 +++++++ site-crawl-plugin/src/index.ts | 76 ++++ site-crawl-plugin/src/runtime.test.ts | 210 ++++++++++ site-crawl-plugin/src/seo.ts | 129 +++++++ site-crawl-plugin/src/store.ts | 97 +++++ site-crawl-plugin/src/types.ts | 64 +++ site-crawl-plugin/tsconfig.json | 14 + site-crawl-plugin/web/app.js | 331 ++++++++++++++++ site-crawl-plugin/web/index.html | 126 ++++++ site-crawl-plugin/web/style.css | 473 +++++++++++++++++++++++ 25 files changed, 3332 insertions(+) create mode 100644 site-crawl-plugin/.gitignore create mode 100644 site-crawl-plugin/bun.lock create mode 100644 site-crawl-plugin/package.json create mode 100644 site-crawl-plugin/src/app.test.ts create mode 100644 site-crawl-plugin/src/app.ts create mode 100644 site-crawl-plugin/src/assets.ts create mode 100644 site-crawl-plugin/src/automation.test.ts create mode 100644 site-crawl-plugin/src/automation.ts create mode 100644 site-crawl-plugin/src/crawler.test.ts create mode 100644 site-crawl-plugin/src/crawler.ts create mode 100644 site-crawl-plugin/src/dev.ts create mode 100644 site-crawl-plugin/src/globals.d.ts create mode 100644 site-crawl-plugin/src/host.ts create mode 100644 site-crawl-plugin/src/http.ts create mode 100644 site-crawl-plugin/src/index.ts create mode 100644 site-crawl-plugin/src/runtime.test.ts create mode 100644 site-crawl-plugin/src/seo.ts create mode 100644 site-crawl-plugin/src/store.ts create mode 100644 site-crawl-plugin/src/types.ts create mode 100644 site-crawl-plugin/tsconfig.json create mode 100644 site-crawl-plugin/web/app.js create mode 100644 site-crawl-plugin/web/index.html create mode 100644 site-crawl-plugin/web/style.css 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..bed274c 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,31 @@ 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, 1 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. diff --git a/site-crawl-plugin/.gitignore b/site-crawl-plugin/.gitignore new file mode 100644 index 0000000..be170c1 --- /dev/null +++ b/site-crawl-plugin/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.data/ diff --git a/site-crawl-plugin/bun.lock b/site-crawl-plugin/bun.lock new file mode 100644 index 0000000..228c928 --- /dev/null +++ b/site-crawl-plugin/bun.lock @@ -0,0 +1,88 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "@temps-plugins/site-crawl", + "dependencies": { + "@temps-sdk/plugin": "0.1.0-beta.1", + "cheerio": "1.1.2", + "ipaddr.js": "2.2.0", + "robots-parser": "3.0.1", + }, + "devDependencies": { + "@types/bun": "1.3.3", + "@types/ws": "8.18.1", + "typescript": "5.9.3", + "ws": "8.21.0", + }, + }, + }, + "packages": { + "@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/node": ["@types/node@22.20.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + + "cheerio": ["cheerio@1.1.2", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.0.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.12.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg=="], + + "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], + + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "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=="], + + "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], + + "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="], + + "robots-parser": ["robots-parser@3.0.1", "", {}, "sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "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=="], + + "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + } +} diff --git a/site-crawl-plugin/package.json b/site-crawl-plugin/package.json new file mode 100644 index 0000000..89672ef --- /dev/null +++ b/site-crawl-plugin/package.json @@ -0,0 +1,31 @@ +{ + "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 build src/index.ts --compile --outfile dist/site-crawl", + "dev": "bun src/dev.ts" + }, + "dependencies": { + "@temps-sdk/plugin": "0.1.0-beta.1", + "cheerio": "1.1.2", + "ipaddr.js": "2.2.0", + "robots-parser": "3.0.1" + }, + "devDependencies": { + "@types/bun": "1.3.3", + "@types/ws": "8.18.1", + "typescript": "5.9.3", + "ws": "8.21.0" + }, + "temps": { + "name": "site-crawl", + "title": "Site Crawl", + "category": "SEO", + "entrypoint": "src/index.ts" + } +} 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..9f6150b --- /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/index.html" with { type: "text" }; +import css from "../web/style.css" with { type: "text" }; +import js from "../web/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..39d48b7 --- /dev/null +++ b/site-crawl-plugin/src/crawler.test.ts @@ -0,0 +1,243 @@ +// 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", + ]), + ); +}); diff --git a/site-crawl-plugin/src/crawler.ts b/site-crawl-plugin/src/crawler.ts new file mode 100644 index 0000000..13c5b98 --- /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 { load } from "cheerio"; +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 (/ loc") + .slice(0, 5) + .each((_, el) => { + if (sitemapQueue.length < 5) + sitemapQueue.push(xml(el).text().trim()); + }); + else if (xml("urlset").length) { + xml("url > loc") + .slice(0, report.maxPages * 4) + .each((_, el) => { + const value = xml(el).text().trim(); + try { + const target = normalize(value, mapUrl.href); + if (target.origin === origin) { + sitemapPages.add(target.href); + enqueue(target.href, mapUrl.href); + } + } catch { + /* malformed sitemap URL */ + } + }); + if (xml("url > loc").length > report.maxPages * 4) + report.limited = true; + } else + notice( + `Sitemap did not contain a urlset or sitemapindex: ${mapUrl.href}`, + ); + } catch (error) { + if (signal.aborted) throw error; + notice(`Sitemap skipped: ${mapUrl.href}. ${message(error)}`); + } + } + if (sitemapQueue.length) { + report.limited = true; + notice("Sitemap discovery stopped at five documents."); + } + while (queue.length && report.pages.length < report.maxPages) { + if (signal.aborted) throw new CrawlError("cancelled", "Crawl cancelled."); + const url = queue.shift()!; + if (visited.has(url)) continue; + visited.add(url); + const page: Page = { + url, + finalUrl: url, + status: null, + durationMs: 0, + title: "", + description: "", + canonical: null, + sources: [], + redirects: [], + issues: [], + }; + const began = Date.now(); + try { + const result = await follow(url); + page.status = result.status; + page.finalUrl = result.finalUrl; + page.redirects = result.chain; + if (result.external) + page.issues.push( + issue( + "external_redirect", + `Redirect leaves the crawl origin: ${result.external}`, + "Verify the destination; external redirects are recorded but not followed.", + "info", + ), + ); + else if (result.status >= 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"; + page.issues.push( + issue( + code, + message(error), + code === "robots_blocked" + ? "Confirm this exclusion is intentional. Blocked pages were not inspected." + : "Check the URL and server, then run another crawl.", + 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..d1bcc65 --- /dev/null +++ b/site-crawl-plugin/src/http.ts @@ -0,0 +1,147 @@ +// 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 = 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", + "Response exceeds the 1 MiB crawl limit.", + ), + ); + 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..c865111 --- /dev/null +++ b/site-crawl-plugin/src/runtime.test.ts @@ -0,0 +1,210 @@ +// 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 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("Find the dead ends."); + 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.ts b/site-crawl-plugin/src/seo.ts new file mode 100644 index 0000000..5da7402 --- /dev/null +++ b/site-crawl-plugin/src/seo.ts @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: 2024-2026 Temps Contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 +import { load } from "cheerio"; +import { normalize } from "./http"; +import type { Issue } from "./types"; +export function analyzeHtml(html: string, url: string, xRobots = "") { + const $ = load(html); + const issues: Issue[] = []; + const add = ( + code: string, + severity: Issue["severity"], + message: string, + fix: string, + ) => issues.push({ code, severity, message, fix }); + const title = $("head title").first().text().trim().slice(0, 1000); + const description = + $('head meta[name="description" i]') + .first() + .attr("content") + ?.trim() + .slice(0, 2000) ?? ""; + 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 ($("h1").length === 0) + add( + "missing_h1", + "info", + "No primary heading.", + "Add a clear heading that describes this page.", + ); + if (!$("html").attr("lang")?.trim()) + add( + "missing_language", + "info", + "Document language is not declared.", + "Set the appropriate lang attribute on <html>.", + ); + const robots = [ + $('head meta[name="robots" i]') + .map((_, el) => $(el).attr("content") ?? "") + .get() + .join(","), + 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.", + ); + const canonicals = $('head link[rel~="canonical" i]'); + let canonical: string | null = null; + if (canonicals.length > 1) + add( + "multiple_canonicals", + "warning", + "Multiple canonical URLs are declared.", + "Keep one consistent canonical URL for this page.", + ); + const raw = canonicals.first().attr("href"); + 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 = $("base[href]").first().attr("href"); + 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) + $("a[href]") + .slice(0, 2000) + .each((_, el) => { + if (/\bnofollow\b/i.test($(el).attr("rel") ?? "")) return; + try { + links.push(normalize($(el).attr("href") ?? "", base).href); + } catch { + /* non-HTTP/oversized links are not crawl targets */ + } + }); + if ($("a[href]").length > 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 }; +} 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..a35ce93 --- /dev/null +++ b/site-crawl-plugin/src/types.ts @@ -0,0 +1,64 @@ +// 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, + ) { + 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..ba26b8d --- /dev/null +++ b/site-crawl-plugin/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "resolveJsonModule": true, + "types": ["bun"], + "skipLibCheck": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"] +} diff --git a/site-crawl-plugin/web/app.js b/site-crawl-plugin/web/app.js new file mode 100644 index 0000000..d124489 --- /dev/null +++ b/site-crawl-plugin/web/app.js @@ -0,0 +1,331 @@ +// SPDX-FileCopyrightText: 2024-2026 Temps Contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 +const $ = (id) => document.getElementById(id); +const base = location.pathname.includes("/ui") + ? location.pathname.split("/ui")[0] + : ""; +let selected = null, + filter = "all", + current = null, + timer = null; +const node = (tag, text, className) => { + const el = document.createElement(tag); + if (text !== undefined) el.textContent = text; + if (className) el.className = className; + return el; +}; +function showError(error) { + $("error").hidden = !error; + $("error").textContent = error?.message ?? ""; +} +async function api(path, options) { + const response = await fetch(`${base}/api${path}`, { + ...options, + headers: { "Content-Type": "application/json" }, + }); + const body = await response.json(); + if (!response.ok) throw new Error(body.error || "Request failed."); + return body; +} +function button(label, action, className) { + const el = node("button", label, className); + el.type = "button"; + el.onclick = () => Promise.resolve(action()).catch(showError); + return el; +} +async function refresh() { + const reports = await api("/reports"); + $("history").replaceChildren(); + $("start").disabled = reports.some((report) => report.state === "running"); + if (!reports.length) + $("history").append( + node("p", "Your first report will appear here.", "hint"), + ); + for (const report of reports) { + const item = button( + "", + () => select(report.id), + `history-item${selected === report.id ? " active" : ""}`, + ); + item.append( + node("strong", new URL(report.url).hostname), + node( + "small", + `${report.state} · ${report.checked} URLs · ${new Date(report.startedAt).toLocaleDateString()}`, + ), + ); + $("history").append(item); + } + if (!selected && reports.length) await select(reports[0].id); +} +async function select(id) { + selected = id; + current = await api(`/reports/${id}`); + render(); + await refresh(); + schedule(); +} +function schedule() { + clearTimeout(timer); + if (current?.state === "running") + timer = setTimeout(() => select(selected).catch(showError), 1500); +} +function render() { + const report = current; + if (!report) return; + const container = $("report"); + container.replaceChildren(); + const heading = node("div", undefined, "report-heading"), + title = node("div"); + title.append( + node("h2", report.url), + node( + "p", + `${report.state.toUpperCase()} · ${new Date(report.startedAt).toLocaleString()}`, + "report-state", + ), + ); + const actions = node("div", undefined, "actions"); + const download = node("a", "Export JSON"); + download.href = `${base}/api/reports/${report.id}/export`; + download.download = ""; + actions.append(download); + if (report.state === "running") + actions.append( + button("Cancel", async () => { + await api(`/reports/${report.id}/cancel`, { method: "POST" }); + await select(report.id); + }), + ); + else + actions.append( + button("Delete", async () => { + if (!confirm("Delete this saved crawl report?")) return; + await api(`/reports/${report.id}`, { method: "DELETE" }); + selected = null; + current = null; + container.replaceChildren( + node( + "p", + "Report deleted. Start a crawl or select another report.", + "hint", + ), + ); + await refresh(); + }), + ); + heading.append(title, actions); + container.append(heading); + const stats = node("div", undefined, "stats"); + for (const [value, label] of [ + [report.pages.length, "URLs checked"], + [ + report.pages.filter((p) => p.issues.some((i) => i.severity === "error")) + .length, + "Routes with errors", + ], + [ + report.pages.reduce( + (sum, p) => + sum + p.issues.filter((i) => i.severity === "warning").length, + 0, + ), + "SEO warnings", + ], + ]) { + const stat = node("div", undefined, "stat"); + stat.append(node("strong", String(value)), node("span", label)); + stats.append(stat); + } + container.append(stats); + if (report.trigger) + container.append( + node( + "p", + `Deployment #${report.trigger.deploymentId} · ${report.trigger.environmentName} · automatic crawl`, + "notice", + ), + ); + if (report.error) container.append(node("p", report.error, "notice")); + for (const text of report.notices) + container.append(node("p", text, "notice")); + if (report.state === "running") + container.append( + node( + "p", + `Crawling… ${report.discovered} URLs discovered. You can leave this page; the crawl continues.`, + "notice", + ), + ); + const filters = node("div", undefined, "filters"); + for (const [value, label] of [ + ["all", "All URLs"], + ["error", "Broken routes"], + ["warning", "SEO warnings"], + ]) { + const el = button(label, () => { + filter = value; + render(); + }); + el.setAttribute("aria-pressed", String(filter === value)); + filters.append(el); + } + container.append(filters); + const pages = report.pages.filter( + (page) => + filter === "all" || + page.issues.some((issue) => issue.severity === filter), + ); + if (!pages.length) + container.append( + node( + "p", + report.state === "running" + ? "Waiting for matching results…" + : "No matching URLs in this report.", + "hint", + ), + ); + for (const page of pages) { + const details = node("details", undefined, "page"), + summary = node("summary"); + const severity = page.issues.some((i) => i.severity === "error") + ? "error" + : page.issues.some((i) => i.severity === "warning") + ? "warning" + : ""; + summary.append( + node( + "span", + page.status === null ? "—" : String(page.status), + `badge ${severity}`, + ), + node("span", page.url, "page-url"), + node("span", String(page.issues.length), "badge"), + ); + details.append(summary); + const contents = node("div", undefined, "details"); + if (page.title) contents.append(node("p", page.title, "hint")); + if (page.finalUrl !== page.url) + contents.append(node("p", `Final URL: ${page.finalUrl}`, "hint")); + if (!page.issues.length) + contents.append(node("p", "No issues found by these checks.", "hint")); + for (const item of page.issues) { + const el = node("div", undefined, "issue"); + el.append( + node("strong", `${item.severity.toUpperCase()} · ${item.message}`), + node("p", item.fix), + ); + contents.append(el); + } + const sources = node("div", undefined, "sources"); + sources.append(node("h3", "Discovered from")); + for (const source of page.sources) sources.append(node("div", source)); + contents.append(sources); + details.append(contents); + container.append(details); + } +} +$("crawl-form").onsubmit = async (event) => { + event.preventDefault(); + showError(null); + $("start").disabled = true; + try { + const { id } = await api("/reports", { + method: "POST", + body: JSON.stringify({ + url: $("url").value, + maxPages: Number($("limit").value), + }), + }); + await select(id); + } catch (error) { + showError(error); + $("start").disabled = false; + } +}; +$("refresh").onclick = () => + (selected ? select(selected) : refresh()).catch(showError); +let automationState = null; +let automationDirty = false; +$("automation-form").oninput = () => { + automationDirty = true; +}; +async function loadAutomation(force = false) { + const state = await api("/automation"); + automationState = state; + $("automation-status").textContent = state.access.configured + ? state.settings.enabled + ? "Listening for successful deployments." + : "Automatic crawling is paused." + : state.access.reason; + if (state.notice) $("automation-status").textContent += ` ${state.notice}`; + $("automation-setup").hidden = state.access.configured; + $("queue-state").textContent = `${state.queued} queued`; + $("clear-queue").hidden = state.queued === 0; + if (automationDirty && !force) return; + $("auto-enabled").checked = state.settings.enabled; + $("auto-production").checked = state.settings.productionOnly; + $("auto-limit").value = state.settings.maxPages; + $("auto-projects").replaceChildren(); + if (!state.projects.length) + $("auto-projects").append( + node( + "p", + "Project controls appear as deployment events arrive. All projects are included by default.", + "hint", + ), + ); + for (const project of state.projects) { + const label = node("label", undefined, "check-label"); + const check = node("input"); + check.type = "checkbox"; + check.checked = !state.settings.excludedProjects.includes(project.id); + check.dataset.projectId = String(project.id); + check.onchange = () => { + automationDirty = true; + }; + label.append(check, node("span", new URL(project.url).hostname)); + $("auto-projects").append(label); + } + automationDirty = false; +} +$("automation-form").onsubmit = async (event) => { + event.preventDefault(); + showError(null); + try { + const excluded = new Set(automationState?.settings.excludedProjects ?? []); + for (const check of document.querySelectorAll("[data-project-id]")) { + const id = Number(check.dataset.projectId); + if (check.checked) excluded.delete(id); + else excluded.add(id); + } + await api("/automation", { + method: "PUT", + body: JSON.stringify({ + enabled: $("auto-enabled").checked, + productionOnly: $("auto-production").checked, + maxPages: Number($("auto-limit").value), + excludedProjects: [...excluded], + }), + }); + await loadAutomation(true); + } catch (error) { + showError(error); + } +}; +$("clear-queue").onclick = async () => { + try { + await api("/automation/queue", { method: "DELETE" }); + await loadAutomation(); + } catch (error) { + showError(error); + } +}; +Promise.all([refresh(), loadAutomation()]).catch(showError); +setInterval(() => { + if (!document.hidden) { + loadAutomation().catch(showError); + refresh().catch(showError); + } +}, 15000); diff --git a/site-crawl-plugin/web/index.html b/site-crawl-plugin/web/index.html new file mode 100644 index 0000000..b703f61 --- /dev/null +++ b/site-crawl-plugin/web/index.html @@ -0,0 +1,126 @@ +<!doctype html> +<!-- SPDX-FileCopyrightText: 2024-2026 Temps Contributors --> +<!-- SPDX-License-Identifier: MIT OR Apache-2.0 --> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <title>Site Crawl · Temps + + + +
+
+
TEMPS / SITE HEALTH
+

Find the dead ends.

+

+ Crawl your deployed site. Fix broken routes and make every page easier + to discover. +

+
+
+
+ +
+

+ Public URLs only · Same-origin links · Respects robots.txt · Server + HTML, without JavaScript rendering +

+
+
+
+

Crawl after deployments

+ +
+

+ Checking deployment event access… +

+ +
+ + + + +
+
+ Project controls +
+
+

+ One crawl at a time. Deployment crawls wait five seconds before + starting. Reports identify the deployment that triggered them. +

+ +
+ +
+ +
+
+ +

A route map with a fix list.

+

+ Enter your site URL to find HTTP errors, redirect loops, missing + metadata, and indexing conflicts. +

+

+ Reports show which pages link to each URL, so you know where to + make the change. +

+
+
+
+
+ Checks provide evidence, not a search ranking or a security guarantee. + No AI provider is required. +
+
+ + + diff --git a/site-crawl-plugin/web/style.css b/site-crawl-plugin/web/style.css new file mode 100644 index 0000000..1b37cd5 --- /dev/null +++ b/site-crawl-plugin/web/style.css @@ -0,0 +1,473 @@ +/* SPDX-FileCopyrightText: 2024-2026 Temps Contributors */ +/* SPDX-License-Identifier: MIT OR Apache-2.0 */ +:root { + color-scheme: light dark; + --bg: #fafaf8; + --panel: #fff; + --ink: #242722; + --muted: #686e65; + --line: #dedfd8; + --accent: #245b39; + --soft: #edf3ec; + --bad: #a72b24; + --warn: #85520f; + font-family: + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + font-size: 14px; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + background: var(--bg); + color: var(--ink); +} +main { + max-width: 1380px; + margin: auto; + padding: 40px 32px; +} +header { + margin-bottom: 28px; +} +.eyebrow { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.18em; + color: var(--accent); +} +h1 { + font-size: 36px; + letter-spacing: -1.3px; + font-weight: 600; + margin: 12px 0; +} +h2 { + font-size: 16px; + margin: 0; +} +.lead { + color: var(--muted); + line-height: 1.6; + margin: 0; +} +.panel { + border: 1px solid var(--line); + background: var(--panel); + padding: 20px; + border-radius: 10px; +} +form { + display: flex; + gap: 14px; + align-items: end; +} +label { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 12px; + font-weight: 600; +} +.url-field { + flex: 1; +} +input, +select, +button { + font: inherit; +} +input, +select { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 6px; + padding: 11px; + color: var(--ink); + min-width: 0; +} +input:focus, +select:focus, +button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +button { + cursor: pointer; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); + color: var(--ink); + padding: 10px 14px; +} +button:disabled { + opacity: 0.55; + cursor: not-allowed; +} +#start { + background: var(--accent); + color: white; + border-color: var(--accent); + min-width: 150px; + display: flex; + justify-content: space-between; + gap: 15px; +} +.hint, +footer { + color: var(--muted); + font-size: 12px; + line-height: 1.6; +} +.hint { + margin: 12px 0 0; +} +.workspace { + display: grid; + grid-template-columns: 235px minmax(0, 1fr); + gap: 28px; + margin-top: 32px; +} +aside { + border-right: 1px solid var(--line); + padding-right: 22px; +} +.section-heading, +.report-heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} +.text-button { + border: 0; + background: none; + padding: 5px; + color: var(--muted); + font-size: 12px; +} +#history { + margin-top: 16px; +} +.history-item { + display: block; + text-align: left; + width: 100%; + margin-bottom: 8px; + padding: 12px; + background: transparent; + border-color: transparent; +} +.history-item.active { + background: var(--soft); + border-color: var(--line); +} +.history-item strong { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; +} +.history-item small { + display: block; + margin-top: 6px; + color: var(--muted); + font-size: 11px; +} +.empty { + padding: 50px 25px; + max-width: 550px; + margin: auto; + text-align: center; +} +.empty-icon { + font-size: 35px; + color: var(--accent); + margin-bottom: 20px; +} +.empty p { + color: var(--muted); + line-height: 1.7; +} +.report-heading { + align-items: start; +} +.report-heading h2 { + overflow-wrap: anywhere; + line-height: 1.5; +} +.actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} +.stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin: 20px 0; +} +.stat { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 16px; +} +.stat strong { + display: block; + font-size: 27px; + font-weight: 550; +} +.stat span { + color: var(--muted); + font-size: 12px; +} +.filters { + display: flex; + gap: 8px; + margin: 20px 0; +} +.filters button[aria-pressed="true"] { + background: var(--ink); + color: var(--panel); + border-color: var(--ink); +} +.notice, +#error { + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 6px; + line-height: 1.6; + font-size: 12px; + margin: 12px 0; +} +.notice { + background: var(--soft); +} +#error { + border-color: var(--bad); + color: var(--bad); +} +.page { + border: 1px solid var(--line); + background: var(--panel); + border-radius: 8px; + margin-bottom: 10px; + overflow: hidden; +} +.page summary { + display: flex; + gap: 12px; + align-items: center; + cursor: pointer; + padding: 15px; + list-style: none; +} +.page summary::after { + content: "+"; + margin-left: auto; +} +.page[open] summary::after { + content: "−"; +} +.page-url { + overflow-wrap: anywhere; + min-width: 0; + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, monospace; +} +.badge { + padding: 4px 7px; + background: var(--soft); + border-radius: 4px; + font-size: 11px; + white-space: nowrap; +} +.badge.error { + color: var(--bad); + background: #fbedeb; +} +.badge.warning { + color: var(--warn); + background: #fff2dc; +} +.details { + border-top: 1px solid var(--line); + padding: 16px; +} +.issue { + margin: 0 0 16px; +} +.issue strong { + display: block; + font-size: 12px; + line-height: 1.6; +} +.issue p { + font-size: 12px; + line-height: 1.6; + color: var(--muted); + margin: 3px 0; +} +.sources { + border-top: 1px solid var(--line); + padding-top: 12px; +} +.sources h3 { + font-size: 11px; + margin: 0 0 8px; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.sources div { + overflow-wrap: anywhere; + font-size: 11px; + line-height: 1.7; + color: var(--muted); +} +.report-state { + color: var(--muted); + font-size: 12px; + margin: 5px 0; +} +footer { + border-top: 1px solid var(--line); + margin-top: 42px; + padding-top: 16px; +} +a { + color: var(--accent); +} +@media (max-width: 760px) { + main { + padding: 24px 16px; + } + h1 { + font-size: 29px; + } + form { + flex-wrap: wrap; + } + .url-field { + flex-basis: 100%; + } + form > label:not(.url-field) { + flex: 1; + } + #start { + flex: 1; + } + .workspace { + grid-template-columns: 1fr; + gap: 20px; + } + aside { + border-right: 0; + padding: 0; + border-bottom: 1px solid var(--line); + } + #history { + display: flex; + overflow-x: auto; + } + .history-item { + min-width: 185px; + max-width: 185px; + } + .report-heading { + flex-direction: column; + } + .stat { + padding: 12px; + } + .stat strong { + font-size: 23px; + } + .empty { + padding: 25px 0; + } + .filters { + flex-wrap: wrap; + } + .page summary { + padding: 12px; + gap: 8px; + } +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #151815; + --panel: #1c201c; + --ink: #e4e8e1; + --muted: #a4ada0; + --line: #333b31; + --accent: #6fb18a; + --soft: #242f26; + } + .badge.error { + background: #392622; + color: #ffaba0; + } + .badge.warning { + background: #372f20; + color: #e7bd7b; + } + #start { + color: #102016; + } +} + +.automation-panel { + margin-top: 16px; +} +#automation-form { + margin-top: 16px; + flex-wrap: wrap; + align-items: center; +} +.check-label { + flex-direction: row; + align-items: center; + gap: 8px; + font-weight: 400; +} +.check-label input { + accent-color: var(--accent); +} +#auto-limit { + width: 90px; +} +.project-controls { + margin-top: 16px; + font-size: 12px; +} +.project-controls summary { + cursor: pointer; +} +#auto-projects { + display: grid; + gap: 10px; + margin-top: 12px; +} +#automation-setup { + display: inline-block; + margin-top: 8px; + font-size: 12px; +} +#automation-setup[hidden] { + display: none; +} +@media (max-width: 760px) { + #automation-form { + align-items: stretch; + } + #automation-form .check-label { + flex-basis: 100%; + } +} From 0e04f5d5a46d6a23e1db0e1064d796cef3ba7f68 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Thu, 17 Sep 2026 20:44:32 +0200 Subject: [PATCH 2/3] fix(site-crawl): adopt Temps design system and inspect larger pages Signed-off-by: David Viejo --- README.md | 8 +- site-crawl-plugin/.gitignore | 2 + site-crawl-plugin/bun.lock | 330 ++++++++ site-crawl-plugin/package.json | 27 +- site-crawl-plugin/src/assets.ts | 6 +- site-crawl-plugin/src/crawler.test.ts | 36 + site-crawl-plugin/src/crawler.ts | 16 +- site-crawl-plugin/src/http.ts | 5 +- site-crawl-plugin/src/runtime.test.ts | 7 +- site-crawl-plugin/src/types.ts | 1 + site-crawl-plugin/tsconfig.json | 11 +- site-crawl-plugin/vite.config.ts | 25 + site-crawl-plugin/web/app.js | 331 -------- site-crawl-plugin/web/index.html | 122 +-- site-crawl-plugin/web/main.tsx | 741 ++++++++++++++++++ site-crawl-plugin/web/style.css | 521 ++---------- site-crawl-plugin/web/vendor/README.md | 7 + site-crawl-plugin/web/vendor/cn.ts | 5 + site-crawl-plugin/web/vendor/ds/button.tsx | 70 ++ site-crawl-plugin/web/vendor/ds/callout.tsx | 49 ++ site-crawl-plugin/web/vendor/ds/field.tsx | 72 ++ .../web/vendor/ds/page-header.tsx | 75 ++ .../web/vendor/ds/page-state.tsx | 97 +++ site-crawl-plugin/web/vendor/ds/status.tsx | 134 ++++ site-crawl-plugin/web/vendor/ds/tokens.css | 116 +++ .../web/vendor/ui/alert-dialog.tsx | 142 ++++ site-crawl-plugin/web/vendor/ui/badge.tsx | 44 ++ site-crawl-plugin/web/vendor/ui/button.tsx | 59 ++ site-crawl-plugin/web/vendor/ui/checkbox.tsx | 31 + site-crawl-plugin/web/vendor/ui/index.ts | 10 + site-crawl-plugin/web/vendor/ui/input.tsx | 26 + site-crawl-plugin/web/vendor/ui/label.tsx | 27 + site-crawl-plugin/web/vendor/ui/table.tsx | 120 +++ site-crawl-plugin/web/vendor/ui/tabs.tsx | 74 ++ 34 files changed, 2414 insertions(+), 933 deletions(-) create mode 100644 site-crawl-plugin/vite.config.ts delete mode 100644 site-crawl-plugin/web/app.js create mode 100644 site-crawl-plugin/web/main.tsx create mode 100644 site-crawl-plugin/web/vendor/README.md create mode 100644 site-crawl-plugin/web/vendor/cn.ts create mode 100644 site-crawl-plugin/web/vendor/ds/button.tsx create mode 100644 site-crawl-plugin/web/vendor/ds/callout.tsx create mode 100644 site-crawl-plugin/web/vendor/ds/field.tsx create mode 100644 site-crawl-plugin/web/vendor/ds/page-header.tsx create mode 100644 site-crawl-plugin/web/vendor/ds/page-state.tsx create mode 100644 site-crawl-plugin/web/vendor/ds/status.tsx create mode 100644 site-crawl-plugin/web/vendor/ds/tokens.css create mode 100644 site-crawl-plugin/web/vendor/ui/alert-dialog.tsx create mode 100644 site-crawl-plugin/web/vendor/ui/badge.tsx create mode 100644 site-crawl-plugin/web/vendor/ui/button.tsx create mode 100644 site-crawl-plugin/web/vendor/ui/checkbox.tsx create mode 100644 site-crawl-plugin/web/vendor/ui/index.ts create mode 100644 site-crawl-plugin/web/vendor/ui/input.tsx create mode 100644 site-crawl-plugin/web/vendor/ui/label.tsx create mode 100644 site-crawl-plugin/web/vendor/ui/table.tsx create mode 100644 site-crawl-plugin/web/vendor/ui/tabs.tsx diff --git a/README.md b/README.md index bed274c..f66008f 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ 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, 1 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. +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). @@ -131,3 +131,9 @@ Each event queues the deployment URL after a five-second settling delay. Reports 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. diff --git a/site-crawl-plugin/.gitignore b/site-crawl-plugin/.gitignore index be170c1..0dcce0e 100644 --- a/site-crawl-plugin/.gitignore +++ b/site-crawl-plugin/.gitignore @@ -1,3 +1,5 @@ node_modules/ dist/ .data/ + +web/dist/ diff --git a/site-crawl-plugin/bun.lock b/site-crawl-plugin/bun.lock index 228c928..65d2272 100644 --- a/site-crawl-plugin/bun.lock +++ b/site-crawl-plugin/bun.lock @@ -5,28 +5,254 @@ "": { "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", "cheerio": "1.1.2", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", "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=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], @@ -35,10 +261,20 @@ "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], + "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=="], + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + "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=="], @@ -49,14 +285,58 @@ "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], + "enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "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.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "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=="], + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], @@ -65,22 +345,72 @@ "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="], + "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=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "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": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="], "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=="], + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], "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=="], + "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], diff --git a/site-crawl-plugin/package.json b/site-crawl-plugin/package.json index 89672ef..0c2965f 100644 --- a/site-crawl-plugin/package.json +++ b/site-crawl-plugin/package.json @@ -7,19 +7,37 @@ "scripts": { "test": "bun test src", "check": "tsc --noEmit", - "build": "bun build src/index.ts --compile --outfile dist/site-crawl", - "dev": "bun src/dev.ts" + "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": { "@temps-sdk/plugin": "0.1.0-beta.1", "cheerio": "1.1.2", "ipaddr.js": "2.2.0", - "robots-parser": "3.0.1" + "robots-parser": "3.0.1", + "react": "19.3.0", + "react-dom": "19.3.0", + "lucide-react": "1.45.0", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "tailwind-merge": "3.6.0", + "@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" }, "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": { @@ -27,5 +45,8 @@ "title": "Site Crawl", "category": "SEO", "entrypoint": "src/index.ts" + }, + "overrides": { + "esbuild": "0.28.1" } } diff --git a/site-crawl-plugin/src/assets.ts b/site-crawl-plugin/src/assets.ts index 9f6150b..47361b0 100644 --- a/site-crawl-plugin/src/assets.ts +++ b/site-crawl-plugin/src/assets.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: 2024-2026 Temps Contributors // SPDX-License-Identifier: MIT OR Apache-2.0 -import html from "../web/index.html" with { type: "text" }; -import css from "../web/style.css" with { type: "text" }; -import js from "../web/app.js" with { type: "text" }; +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", diff --git a/site-crawl-plugin/src/crawler.test.ts b/site-crawl-plugin/src/crawler.test.ts index 39d48b7..bd5b22a 100644 --- a/site-crawl-plugin/src/crawler.test.ts +++ b/site-crawl-plugin/src/crawler.test.ts @@ -241,3 +241,39 @@ test("SEO detects absent and malformed metadata without claiming a ranking score ]), ); }); + +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 index 13c5b98..54ac229 100644 --- a/site-crawl-plugin/src/crawler.ts +++ b/site-crawl-plugin/src/crawler.ts @@ -286,14 +286,22 @@ export async function crawl( } 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 === "robots_blocked" - ? "Confirm this exclusion is intentional. Blocked pages were not inspected." - : "Check the URL and server, then run another crawl.", - code === "robots_blocked" ? "info" : "error", + code === "body_limit" + ? "The response was too large to inspect completely; 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" + ? "warning" + : code === "robots_blocked" + ? "info" + : "error", ), ); } diff --git a/site-crawl-plugin/src/http.ts b/site-crawl-plugin/src/http.ts index d1bcc65..3d2f1dd 100644 --- a/site-crawl-plugin/src/http.ts +++ b/site-crawl-plugin/src/http.ts @@ -6,7 +6,7 @@ import https from "node:https"; import ipaddr from "ipaddr.js"; import { CrawlError } from "./types"; export const USER_AGENT = "TempsSiteCrawl"; -export const MAX_BODY = 1024 * 1024; +export const MAX_BODY = 8 * 1024 * 1024; export interface HttpResult { status: number; headers: Record; @@ -116,7 +116,8 @@ export const fetchPublic: FetchPage = async (url, signal) => { response.destroy( new CrawlError( "body_limit", - "Response exceeds the 1 MiB crawl limit.", + "Inspection incomplete: response exceeds the 8 MiB crawl limit.", + status, ), ); return; diff --git a/site-crawl-plugin/src/runtime.test.ts b/site-crawl-plugin/src/runtime.test.ts index c865111..ff17269 100644 --- a/site-crawl-plugin/src/runtime.test.ts +++ b/site-crawl-plugin/src/runtime.test.ts @@ -17,6 +17,11 @@ test("compiled plugin handshake, authentication, report API and embedded UI", as 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" }, @@ -125,7 +130,7 @@ test("compiled plugin handshake, authentication, report API and embedded UI", as ).toBe(200); const ui = await get("/ui/", { headers: headers("admin") }); expect(ui.status).toBe(200); - expect(await ui.text()).toContain("Find the dead ends."); + expect(await ui.text()).toContain("Site Crawl"); const created = await get("/api/reports", { method: "POST", headers: headers("admin"), diff --git a/site-crawl-plugin/src/types.ts b/site-crawl-plugin/src/types.ts index a35ce93..b47fa27 100644 --- a/site-crawl-plugin/src/types.ts +++ b/site-crawl-plugin/src/types.ts @@ -37,6 +37,7 @@ export class CrawlError extends Error { constructor( public code: string, message: string, + public status?: number, ) { super(message); this.name = "CrawlError"; diff --git a/site-crawl-plugin/tsconfig.json b/site-crawl-plugin/tsconfig.json index ba26b8d..06be346 100644 --- a/site-crawl-plugin/tsconfig.json +++ b/site-crawl-plugin/tsconfig.json @@ -8,7 +8,14 @@ "resolveJsonModule": true, "types": ["bun"], "skipLibCheck": true, - "allowImportingTsExtensions": true + "allowImportingTsExtensions": true, + "jsx": "react-jsx" }, - "include": ["src/**/*.ts", "scripts/**/*.ts"] + "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/app.js b/site-crawl-plugin/web/app.js deleted file mode 100644 index d124489..0000000 --- a/site-crawl-plugin/web/app.js +++ /dev/null @@ -1,331 +0,0 @@ -// SPDX-FileCopyrightText: 2024-2026 Temps Contributors -// SPDX-License-Identifier: MIT OR Apache-2.0 -const $ = (id) => document.getElementById(id); -const base = location.pathname.includes("/ui") - ? location.pathname.split("/ui")[0] - : ""; -let selected = null, - filter = "all", - current = null, - timer = null; -const node = (tag, text, className) => { - const el = document.createElement(tag); - if (text !== undefined) el.textContent = text; - if (className) el.className = className; - return el; -}; -function showError(error) { - $("error").hidden = !error; - $("error").textContent = error?.message ?? ""; -} -async function api(path, options) { - const response = await fetch(`${base}/api${path}`, { - ...options, - headers: { "Content-Type": "application/json" }, - }); - const body = await response.json(); - if (!response.ok) throw new Error(body.error || "Request failed."); - return body; -} -function button(label, action, className) { - const el = node("button", label, className); - el.type = "button"; - el.onclick = () => Promise.resolve(action()).catch(showError); - return el; -} -async function refresh() { - const reports = await api("/reports"); - $("history").replaceChildren(); - $("start").disabled = reports.some((report) => report.state === "running"); - if (!reports.length) - $("history").append( - node("p", "Your first report will appear here.", "hint"), - ); - for (const report of reports) { - const item = button( - "", - () => select(report.id), - `history-item${selected === report.id ? " active" : ""}`, - ); - item.append( - node("strong", new URL(report.url).hostname), - node( - "small", - `${report.state} · ${report.checked} URLs · ${new Date(report.startedAt).toLocaleDateString()}`, - ), - ); - $("history").append(item); - } - if (!selected && reports.length) await select(reports[0].id); -} -async function select(id) { - selected = id; - current = await api(`/reports/${id}`); - render(); - await refresh(); - schedule(); -} -function schedule() { - clearTimeout(timer); - if (current?.state === "running") - timer = setTimeout(() => select(selected).catch(showError), 1500); -} -function render() { - const report = current; - if (!report) return; - const container = $("report"); - container.replaceChildren(); - const heading = node("div", undefined, "report-heading"), - title = node("div"); - title.append( - node("h2", report.url), - node( - "p", - `${report.state.toUpperCase()} · ${new Date(report.startedAt).toLocaleString()}`, - "report-state", - ), - ); - const actions = node("div", undefined, "actions"); - const download = node("a", "Export JSON"); - download.href = `${base}/api/reports/${report.id}/export`; - download.download = ""; - actions.append(download); - if (report.state === "running") - actions.append( - button("Cancel", async () => { - await api(`/reports/${report.id}/cancel`, { method: "POST" }); - await select(report.id); - }), - ); - else - actions.append( - button("Delete", async () => { - if (!confirm("Delete this saved crawl report?")) return; - await api(`/reports/${report.id}`, { method: "DELETE" }); - selected = null; - current = null; - container.replaceChildren( - node( - "p", - "Report deleted. Start a crawl or select another report.", - "hint", - ), - ); - await refresh(); - }), - ); - heading.append(title, actions); - container.append(heading); - const stats = node("div", undefined, "stats"); - for (const [value, label] of [ - [report.pages.length, "URLs checked"], - [ - report.pages.filter((p) => p.issues.some((i) => i.severity === "error")) - .length, - "Routes with errors", - ], - [ - report.pages.reduce( - (sum, p) => - sum + p.issues.filter((i) => i.severity === "warning").length, - 0, - ), - "SEO warnings", - ], - ]) { - const stat = node("div", undefined, "stat"); - stat.append(node("strong", String(value)), node("span", label)); - stats.append(stat); - } - container.append(stats); - if (report.trigger) - container.append( - node( - "p", - `Deployment #${report.trigger.deploymentId} · ${report.trigger.environmentName} · automatic crawl`, - "notice", - ), - ); - if (report.error) container.append(node("p", report.error, "notice")); - for (const text of report.notices) - container.append(node("p", text, "notice")); - if (report.state === "running") - container.append( - node( - "p", - `Crawling… ${report.discovered} URLs discovered. You can leave this page; the crawl continues.`, - "notice", - ), - ); - const filters = node("div", undefined, "filters"); - for (const [value, label] of [ - ["all", "All URLs"], - ["error", "Broken routes"], - ["warning", "SEO warnings"], - ]) { - const el = button(label, () => { - filter = value; - render(); - }); - el.setAttribute("aria-pressed", String(filter === value)); - filters.append(el); - } - container.append(filters); - const pages = report.pages.filter( - (page) => - filter === "all" || - page.issues.some((issue) => issue.severity === filter), - ); - if (!pages.length) - container.append( - node( - "p", - report.state === "running" - ? "Waiting for matching results…" - : "No matching URLs in this report.", - "hint", - ), - ); - for (const page of pages) { - const details = node("details", undefined, "page"), - summary = node("summary"); - const severity = page.issues.some((i) => i.severity === "error") - ? "error" - : page.issues.some((i) => i.severity === "warning") - ? "warning" - : ""; - summary.append( - node( - "span", - page.status === null ? "—" : String(page.status), - `badge ${severity}`, - ), - node("span", page.url, "page-url"), - node("span", String(page.issues.length), "badge"), - ); - details.append(summary); - const contents = node("div", undefined, "details"); - if (page.title) contents.append(node("p", page.title, "hint")); - if (page.finalUrl !== page.url) - contents.append(node("p", `Final URL: ${page.finalUrl}`, "hint")); - if (!page.issues.length) - contents.append(node("p", "No issues found by these checks.", "hint")); - for (const item of page.issues) { - const el = node("div", undefined, "issue"); - el.append( - node("strong", `${item.severity.toUpperCase()} · ${item.message}`), - node("p", item.fix), - ); - contents.append(el); - } - const sources = node("div", undefined, "sources"); - sources.append(node("h3", "Discovered from")); - for (const source of page.sources) sources.append(node("div", source)); - contents.append(sources); - details.append(contents); - container.append(details); - } -} -$("crawl-form").onsubmit = async (event) => { - event.preventDefault(); - showError(null); - $("start").disabled = true; - try { - const { id } = await api("/reports", { - method: "POST", - body: JSON.stringify({ - url: $("url").value, - maxPages: Number($("limit").value), - }), - }); - await select(id); - } catch (error) { - showError(error); - $("start").disabled = false; - } -}; -$("refresh").onclick = () => - (selected ? select(selected) : refresh()).catch(showError); -let automationState = null; -let automationDirty = false; -$("automation-form").oninput = () => { - automationDirty = true; -}; -async function loadAutomation(force = false) { - const state = await api("/automation"); - automationState = state; - $("automation-status").textContent = state.access.configured - ? state.settings.enabled - ? "Listening for successful deployments." - : "Automatic crawling is paused." - : state.access.reason; - if (state.notice) $("automation-status").textContent += ` ${state.notice}`; - $("automation-setup").hidden = state.access.configured; - $("queue-state").textContent = `${state.queued} queued`; - $("clear-queue").hidden = state.queued === 0; - if (automationDirty && !force) return; - $("auto-enabled").checked = state.settings.enabled; - $("auto-production").checked = state.settings.productionOnly; - $("auto-limit").value = state.settings.maxPages; - $("auto-projects").replaceChildren(); - if (!state.projects.length) - $("auto-projects").append( - node( - "p", - "Project controls appear as deployment events arrive. All projects are included by default.", - "hint", - ), - ); - for (const project of state.projects) { - const label = node("label", undefined, "check-label"); - const check = node("input"); - check.type = "checkbox"; - check.checked = !state.settings.excludedProjects.includes(project.id); - check.dataset.projectId = String(project.id); - check.onchange = () => { - automationDirty = true; - }; - label.append(check, node("span", new URL(project.url).hostname)); - $("auto-projects").append(label); - } - automationDirty = false; -} -$("automation-form").onsubmit = async (event) => { - event.preventDefault(); - showError(null); - try { - const excluded = new Set(automationState?.settings.excludedProjects ?? []); - for (const check of document.querySelectorAll("[data-project-id]")) { - const id = Number(check.dataset.projectId); - if (check.checked) excluded.delete(id); - else excluded.add(id); - } - await api("/automation", { - method: "PUT", - body: JSON.stringify({ - enabled: $("auto-enabled").checked, - productionOnly: $("auto-production").checked, - maxPages: Number($("auto-limit").value), - excludedProjects: [...excluded], - }), - }); - await loadAutomation(true); - } catch (error) { - showError(error); - } -}; -$("clear-queue").onclick = async () => { - try { - await api("/automation/queue", { method: "DELETE" }); - await loadAutomation(); - } catch (error) { - showError(error); - } -}; -Promise.all([refresh(), loadAutomation()]).catch(showError); -setInterval(() => { - if (!document.hidden) { - loadAutomation().catch(showError); - refresh().catch(showError); - } -}, 15000); diff --git a/site-crawl-plugin/web/index.html b/site-crawl-plugin/web/index.html index b703f61..dd11784 100644 --- a/site-crawl-plugin/web/index.html +++ b/site-crawl-plugin/web/index.html @@ -1,126 +1,14 @@ - + - - + + Site Crawl · Temps - -
-
-
TEMPS / SITE HEALTH
-

Find the dead ends.

-

- Crawl your deployed site. Fix broken routes and make every page easier - to discover. -

-
-
-
- -
-

- Public URLs only · Same-origin links · Respects robots.txt · Server - HTML, without JavaScript rendering -

-
-
-
-

Crawl after deployments

- -
-

- Checking deployment event access… -

- -
- - - - -
-
- Project controls -
-
-

- One crawl at a time. Deployment crawls wait five seconds before - starting. Reports identify the deployment that triggered them. -

- -
- -
- -
-
- -

A route map with a fix list.

-

- Enter your site URL to find HTTP errors, redirect loops, missing - metadata, and indexing conflicts. -

-

- Reports show which pages link to each URL, so you know where to - make the change. -

-
-
-
-
- Checks provide evidence, not a search ranking or a security guarantee. - No AI provider is required. -
-
- +
+ 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 index 1b37cd5..f5837de 100644 --- a/site-crawl-plugin/web/style.css +++ b/site-crawl-plugin/web/style.css @@ -1,473 +1,56 @@ /* SPDX-FileCopyrightText: 2024-2026 Temps Contributors */ /* SPDX-License-Identifier: MIT OR Apache-2.0 */ -:root { - color-scheme: light dark; - --bg: #fafaf8; - --panel: #fff; - --ink: #242722; - --muted: #686e65; - --line: #dedfd8; - --accent: #245b39; - --soft: #edf3ec; - --bad: #a72b24; - --warn: #85520f; - font-family: - ui-sans-serif, - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - sans-serif; - font-size: 14px; -} -* { - box-sizing: border-box; -} -body { - margin: 0; - background: var(--bg); - color: var(--ink); -} -main { - max-width: 1380px; - margin: auto; - padding: 40px 32px; -} -header { - margin-bottom: 28px; -} -.eyebrow { - font-size: 10px; - font-weight: 700; - letter-spacing: 0.18em; - color: var(--accent); -} -h1 { - font-size: 36px; - letter-spacing: -1.3px; - font-weight: 600; - margin: 12px 0; -} -h2 { - font-size: 16px; - margin: 0; -} -.lead { - color: var(--muted); - line-height: 1.6; - margin: 0; -} -.panel { - border: 1px solid var(--line); - background: var(--panel); - padding: 20px; - border-radius: 10px; -} -form { - display: flex; - gap: 14px; - align-items: end; -} -label { - display: flex; - flex-direction: column; - gap: 8px; - font-size: 12px; - font-weight: 600; -} -.url-field { - flex: 1; -} -input, -select, -button { - font: inherit; -} -input, -select { - background: var(--panel); - border: 1px solid var(--line); - border-radius: 6px; - padding: 11px; - color: var(--ink); - min-width: 0; -} -input:focus, -select:focus, -button:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} -button { - cursor: pointer; - border: 1px solid var(--line); - border-radius: 6px; - background: var(--panel); - color: var(--ink); - padding: 10px 14px; -} -button:disabled { - opacity: 0.55; - cursor: not-allowed; -} -#start { - background: var(--accent); - color: white; - border-color: var(--accent); - min-width: 150px; - display: flex; - justify-content: space-between; - gap: 15px; -} -.hint, -footer { - color: var(--muted); - font-size: 12px; - line-height: 1.6; -} -.hint { - margin: 12px 0 0; -} -.workspace { - display: grid; - grid-template-columns: 235px minmax(0, 1fr); - gap: 28px; - margin-top: 32px; -} -aside { - border-right: 1px solid var(--line); - padding-right: 22px; -} -.section-heading, -.report-heading { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; -} -.text-button { - border: 0; - background: none; - padding: 5px; - color: var(--muted); - font-size: 12px; -} -#history { - margin-top: 16px; -} -.history-item { - display: block; - text-align: left; - width: 100%; - margin-bottom: 8px; - padding: 12px; - background: transparent; - border-color: transparent; -} -.history-item.active { - background: var(--soft); - border-color: var(--line); -} -.history-item strong { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 12px; -} -.history-item small { - display: block; - margin-top: 6px; - color: var(--muted); - font-size: 11px; -} -.empty { - padding: 50px 25px; - max-width: 550px; - margin: auto; - text-align: center; -} -.empty-icon { - font-size: 35px; - color: var(--accent); - margin-bottom: 20px; -} -.empty p { - color: var(--muted); - line-height: 1.7; -} -.report-heading { - align-items: start; -} -.report-heading h2 { - overflow-wrap: anywhere; - line-height: 1.5; -} -.actions { - display: flex; - gap: 8px; - flex-shrink: 0; -} -.stats { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 12px; - margin: 20px 0; -} -.stat { - background: var(--panel); - border: 1px solid var(--line); - border-radius: 8px; - padding: 16px; -} -.stat strong { - display: block; - font-size: 27px; - font-weight: 550; -} -.stat span { - color: var(--muted); - font-size: 12px; -} -.filters { - display: flex; - gap: 8px; - margin: 20px 0; -} -.filters button[aria-pressed="true"] { - background: var(--ink); - color: var(--panel); - border-color: var(--ink); -} -.notice, -#error { - padding: 12px 14px; - border: 1px solid var(--line); - border-radius: 6px; - line-height: 1.6; - font-size: 12px; - margin: 12px 0; -} -.notice { - background: var(--soft); -} -#error { - border-color: var(--bad); - color: var(--bad); -} -.page { - border: 1px solid var(--line); - background: var(--panel); - border-radius: 8px; - margin-bottom: 10px; - overflow: hidden; -} -.page summary { - display: flex; - gap: 12px; - align-items: center; - cursor: pointer; - padding: 15px; - list-style: none; -} -.page summary::after { - content: "+"; - margin-left: auto; -} -.page[open] summary::after { - content: "−"; -} -.page-url { - overflow-wrap: anywhere; - min-width: 0; - font-size: 12px; - font-family: ui-monospace, SFMono-Regular, monospace; -} -.badge { - padding: 4px 7px; - background: var(--soft); - border-radius: 4px; - font-size: 11px; - white-space: nowrap; -} -.badge.error { - color: var(--bad); - background: #fbedeb; -} -.badge.warning { - color: var(--warn); - background: #fff2dc; -} -.details { - border-top: 1px solid var(--line); - padding: 16px; -} -.issue { - margin: 0 0 16px; -} -.issue strong { - display: block; - font-size: 12px; - line-height: 1.6; -} -.issue p { - font-size: 12px; - line-height: 1.6; - color: var(--muted); - margin: 3px 0; -} -.sources { - border-top: 1px solid var(--line); - padding-top: 12px; -} -.sources h3 { - font-size: 11px; - margin: 0 0 8px; - text-transform: uppercase; - letter-spacing: 0.06em; -} -.sources div { - overflow-wrap: anywhere; - font-size: 11px; - line-height: 1.7; - color: var(--muted); -} -.report-state { - color: var(--muted); - font-size: 12px; - margin: 5px 0; -} -footer { - border-top: 1px solid var(--line); - margin-top: 42px; - padding-top: 16px; -} -a { - color: var(--accent); -} -@media (max-width: 760px) { - main { - padding: 24px 16px; - } - h1 { - font-size: 29px; - } - form { - flex-wrap: wrap; - } - .url-field { - flex-basis: 100%; - } - form > label:not(.url-field) { - flex: 1; - } - #start { - flex: 1; - } - .workspace { - grid-template-columns: 1fr; - gap: 20px; - } - aside { - border-right: 0; - padding: 0; - border-bottom: 1px solid var(--line); - } - #history { - display: flex; - overflow-x: auto; - } - .history-item { - min-width: 185px; - max-width: 185px; - } - .report-heading { - flex-direction: column; - } - .stat { - padding: 12px; - } - .stat strong { - font-size: 23px; - } - .empty { - padding: 25px 0; - } - .filters { - flex-wrap: wrap; - } - .page summary { - padding: 12px; - gap: 8px; - } -} -@media (prefers-color-scheme: dark) { - :root { - --bg: #151815; - --panel: #1c201c; - --ink: #e4e8e1; - --muted: #a4ada0; - --line: #333b31; - --accent: #6fb18a; - --soft: #242f26; - } - .badge.error { - background: #392622; - color: #ffaba0; - } - .badge.warning { - background: #372f20; - color: #e7bd7b; - } - #start { - color: #102016; - } -} - -.automation-panel { - margin-top: 16px; -} -#automation-form { - margin-top: 16px; - flex-wrap: wrap; - align-items: center; -} -.check-label { - flex-direction: row; - align-items: center; - gap: 8px; - font-weight: 400; -} -.check-label input { - accent-color: var(--accent); -} -#auto-limit { - width: 90px; -} -.project-controls { - margin-top: 16px; - font-size: 12px; -} -.project-controls summary { - cursor: pointer; -} -#auto-projects { - display: grid; - gap: 10px; - margin-top: 12px; -} -#automation-setup { - display: inline-block; - margin-top: 8px; - font-size: 12px; -} -#automation-setup[hidden] { - display: none; -} -@media (max-width: 760px) { - #automation-form { - align-items: stretch; - } - #automation-form .check-label { - flex-basis: 100%; +@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 } From 83ccfeabd110246627b314bc3dbead8fa9ec1187 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Thu, 17 Sep 2026 21:24:23 +0200 Subject: [PATCH 3/3] perf(site-crawl): tokenize HTML and sitemaps without a DOM Signed-off-by: David Viejo --- README.md | 6 + site-crawl-plugin/bun.lock | 40 +------ site-crawl-plugin/package.json | 22 ++-- site-crawl-plugin/src/crawler.ts | 48 ++++---- site-crawl-plugin/src/seo.test.ts | 104 +++++++++++++++++ site-crawl-plugin/src/seo.ts | 178 ++++++++++++++++++++++++------ site-crawl-plugin/src/sitemap.ts | 73 ++++++++++++ 7 files changed, 365 insertions(+), 106 deletions(-) create mode 100644 site-crawl-plugin/src/seo.test.ts create mode 100644 site-crawl-plugin/src/sitemap.ts diff --git a/README.md b/README.md index f66008f..82ca3a5 100644 --- a/README.md +++ b/README.md @@ -137,3 +137,9 @@ Live permission discovery is checked before each queued crawl. Revoking Events r 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/bun.lock b/site-crawl-plugin/bun.lock index 65d2272..cb98779 100644 --- a/site-crawl-plugin/bun.lock +++ b/site-crawl-plugin/bun.lock @@ -12,9 +12,9 @@ "@radix-ui/react-tabs": "1.1.21", "@tanstack/react-query": "5.102.8", "@temps-sdk/plugin": "0.1.0-beta.1", - "cheerio": "1.1.2", "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", @@ -253,22 +253,12 @@ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], - "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], - "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], - "cheerio": ["cheerio@1.1.2", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.0.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.12.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg=="], - - "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], - "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=="], - "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], - - "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -283,11 +273,9 @@ "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=="], - "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], - "enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="], - "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "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=="], @@ -299,9 +287,7 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], - - "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "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=="], @@ -337,14 +323,6 @@ "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], - "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], - - "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], - - "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], - - "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], @@ -365,8 +343,6 @@ "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=="], - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "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=="], @@ -383,8 +359,6 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="], - "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=="], @@ -393,10 +367,6 @@ "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=="], - "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], - - "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], - "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=="], @@ -411,8 +381,6 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - - "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "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 index 0c2965f..4cf4c95 100644 --- a/site-crawl-plugin/package.json +++ b/site-crawl-plugin/package.json @@ -12,22 +12,22 @@ "build:ui": "vite build" }, "dependencies": { - "@temps-sdk/plugin": "0.1.0-beta.1", - "cheerio": "1.1.2", - "ipaddr.js": "2.2.0", - "robots-parser": "3.0.1", - "react": "19.3.0", - "react-dom": "19.3.0", - "lucide-react": "1.45.0", - "class-variance-authority": "0.7.1", - "clsx": "2.1.1", - "tailwind-merge": "3.6.0", "@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" + "@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", diff --git a/site-crawl-plugin/src/crawler.ts b/site-crawl-plugin/src/crawler.ts index 54ac229..182538a 100644 --- a/site-crawl-plugin/src/crawler.ts +++ b/site-crawl-plugin/src/crawler.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024-2026 Temps Contributors // SPDX-License-Identifier: MIT OR Apache-2.0 import robotsParser from "robots-parser"; -import { load } from "cheerio"; +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"; @@ -169,31 +169,23 @@ export async function crawl( ); continue; } - const xml = load(sitemap.body, { xml: true }); - if (xml("sitemapindex").length) - xml("sitemap > loc") - .slice(0, 5) - .each((_, el) => { - if (sitemapQueue.length < 5) - sitemapQueue.push(xml(el).text().trim()); - }); - else if (xml("urlset").length) { - xml("url > loc") - .slice(0, report.maxPages * 4) - .each((_, el) => { - const value = xml(el).text().trim(); - try { - const target = normalize(value, mapUrl.href); - if (target.origin === origin) { - sitemapPages.add(target.href); - enqueue(target.href, mapUrl.href); - } - } catch { - /* malformed sitemap URL */ + const xml = parseSitemap(sitemap.body, report.maxPages * 4); + if (xml.kind === "sitemapindex") { + for (const value of xml.locations) + if (sitemapQueue.length < 5) sitemapQueue.push(value); + } else if (xml.kind === "urlset") { + for (const value of xml.locations) { + try { + const target = normalize(value, mapUrl.href); + if (target.origin === origin) { + sitemapPages.add(target.href); + enqueue(target.href, mapUrl.href); } - }); - if (xml("url > loc").length > report.maxPages * 4) - report.limited = true; + } catch { + /* malformed sitemap URL */ + } + } + if (xml.limited) report.limited = true; } else notice( `Sitemap did not contain a urlset or sitemapindex: ${mapUrl.href}`, @@ -292,12 +284,12 @@ export async function crawl( issue( code, message(error), - code === "body_limit" - ? "The response was too large to inspect completely; this is not evidence of a broken route. Reduce the HTML payload or inspect this page separately." + 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 === "body_limit" || code === "markup_depth" ? "warning" : code === "robots_blocked" ? "info" 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 index 5da7402..04af9b0 100644 --- a/site-crawl-plugin/src/seo.ts +++ b/site-crawl-plugin/src/seo.ts @@ -1,10 +1,10 @@ // SPDX-FileCopyrightText: 2024-2026 Temps Contributors // SPDX-License-Identifier: MIT OR Apache-2.0 -import { load } from "cheerio"; +import { Parser } from "htmlparser2"; import { normalize } from "./http"; -import type { Issue } from "./types"; +import { CrawlError, type Issue } from "./types"; export function analyzeHtml(html: string, url: string, xRobots = "") { - const $ = load(html); + const fields = extractHtml(html); const issues: Issue[] = []; const add = ( code: string, @@ -12,13 +12,8 @@ export function analyzeHtml(html: string, url: string, xRobots = "") { message: string, fix: string, ) => issues.push({ code, severity, message, fix }); - const title = $("head title").first().text().trim().slice(0, 1000); - const description = - $('head meta[name="description" i]') - .first() - .attr("content") - ?.trim() - .slice(0, 2000) ?? ""; + const title = fields.title.trim(); + const description = fields.description.trim(); if (!title) add( "missing_title", @@ -33,14 +28,14 @@ export function analyzeHtml(html: string, url: string, xRobots = "") { "No meta description.", "Describe this page in a meta description. Search engines may choose a different snippet.", ); - if ($("h1").length === 0) + if (!fields.hasH1) add( "missing_h1", "info", "No primary heading.", "Add a clear heading that describes this page.", ); - if (!$("html").attr("lang")?.trim()) + if (!fields.language.trim()) add( "missing_language", "info", @@ -48,10 +43,8 @@ export function analyzeHtml(html: string, url: string, xRobots = "") { "Set the appropriate lang attribute on .", ); const robots = [ - $('head meta[name="robots" i]') - .map((_, el) => $(el).attr("content") ?? "") - .get() - .join(","), + fields.noindex ? "noindex" : "", + fields.nofollow ? "nofollow" : "", xRobots, ] .join(",") @@ -63,16 +56,16 @@ export function analyzeHtml(html: string, url: string, xRobots = "") { "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.", ); - const canonicals = $('head link[rel~="canonical" i]'); + let canonical: string | null = null; - if (canonicals.length > 1) + if (fields.canonicalCount > 1) add( "multiple_canonicals", "warning", "Multiple canonical URLs are declared.", "Keep one consistent canonical URL for this page.", ); - const raw = canonicals.first().attr("href"); + const raw = fields.canonical; if (raw) { try { canonical = normalize(raw, url).href; @@ -100,7 +93,7 @@ export function analyzeHtml(html: string, url: string, xRobots = "") { ); let base = url; try { - const rawBase = $("base[href]").first().attr("href"); + const rawBase = fields.base; if (rawBase) base = normalize(rawBase, url).href; } catch { /* invalid base falls back to the document */ @@ -108,17 +101,15 @@ export function analyzeHtml(html: string, url: string, xRobots = "") { const links: string[] = []; const nofollow = /\b(nofollow|none)\b/.test(robots); if (!nofollow) - $("a[href]") - .slice(0, 2000) - .each((_, el) => { - if (/\bnofollow\b/i.test($(el).attr("rel") ?? "")) return; - try { - links.push(normalize($(el).attr("href") ?? "", base).href); - } catch { - /* non-HTTP/oversized links are not crawl targets */ - } - }); - if ($("a[href]").length > 2000) + 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", @@ -127,3 +118,128 @@ export function analyzeHtml(html: string, url: string, xRobots = "") { ); return { title, description, canonical, links: [...new Set(links)], issues }; } + +/** Tokenizer callbacks retain only bounded SEO fields, never a document tree. */ +function extractHtml(html: string) { + const fields = { + title: "", + description: "", + hasH1: false, + language: "", + noindex: false, + nofollow: false, + canonicalCount: 0, + canonical: undefined as string | undefined, + base: undefined as string | undefined, + links: [] as { href: string; rel: string }[], + linkCount: 0, + }; + let inHead = true, + titleOpen = false, + titleSeen = false, + descriptionSeen = false, + languageSeen = false, + baseSeen = false; + let ignored = 0; + let depth = 0; + const suppressed = new Set([ + "script", + "style", + "noscript", + "svg", + "math", + "template", + ]); + const headTags = new Set([ + "html", + "head", + "title", + "base", + "link", + "meta", + "script", + "style", + "noscript", + "template", + ]); + const parser = new Parser( + { + onopentag(name, attrs) { + if (++depth > 128) + throw new CrawlError( + "markup_depth", + "Inspection incomplete: HTML nesting exceeds 128 levels.", + ); + if (suppressed.has(name)) { + ignored++; + return; + } + if (ignored) return; + // HTML permits an omitted . Its first body element ends that head. + if (name === "body" || !headTags.has(name)) inHead = false; + if (name === "html" && !languageSeen) { + fields.language = (attrs.lang ?? "").slice(0, 100); + languageSeen = true; + } + if (name === "title" && inHead && !titleSeen) { + titleSeen = true; + titleOpen = true; + } + if (name === "h1") fields.hasH1 = true; + if (name === "meta" && inHead) { + const key = attrs.name?.toLowerCase(); + const content = attrs.content ?? ""; + if (key === "description" && !descriptionSeen) { + fields.description = content.trim().slice(0, 2000); + descriptionSeen = true; + } + if (key === "robots") { + fields.noindex ||= /\b(noindex|none)\b/i.test(content); + fields.nofollow ||= /\b(nofollow|none)\b/i.test(content); + } + } + if ( + name === "link" && + inHead && + attrs.rel?.toLowerCase().split(/\s+/).includes("canonical") + ) { + if (fields.canonicalCount === 0) + fields.canonical = attrs.href?.slice(0, 2049); + fields.canonicalCount++; + } + if (name === "base" && attrs.href !== undefined && !baseSeen) { + fields.base = attrs.href.slice(0, 2049); + baseSeen = true; + } + if (name === "a" && attrs.href !== undefined) { + fields.linkCount++; + if (fields.linkCount <= 2000) + fields.links.push({ + href: attrs.href.slice(0, 2049), + rel: /\bnofollow\b/i.test(attrs.rel ?? "") ? "nofollow" : "", + }); + } + }, + ontext(text) { + if (titleOpen && !ignored && fields.title.length < 1000) + fields.title += (fields.title ? text : text.trimStart()).slice( + 0, + 1000 - fields.title.length, + ); + }, + onclosetag(name) { + depth = Math.max(0, depth - 1); + if (suppressed.has(name) && ignored > 0) { + ignored--; + return; + } + if (ignored) return; + if (name === "title") titleOpen = false; + if (name === "head") inHead = false; + }, + }, + { decodeEntities: true }, + ); + parser.end(html); + return fields; +} diff --git a/site-crawl-plugin/src/sitemap.ts b/site-crawl-plugin/src/sitemap.ts new file mode 100644 index 0000000..9720b4e --- /dev/null +++ b/site-crawl-plugin/src/sitemap.ts @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2024-2026 Temps Contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 +import { Parser } from "htmlparser2"; +import { CrawlError } from "./types"; +/** XML tokenizer: retain only direct loc children and bounded URL strings. */ +export function parseSitemap( + xml: string, + maxUrls: number, +): { + kind: "urlset" | "sitemapindex" | null; + locations: string[]; + limited: boolean; +} { + let kind: "urlset" | "sitemapindex" | null = null; + const locations: string[] = []; + const stack: string[] = []; + let prefix = ""; + let text: string | null = null, + count = 0; + const parser = new Parser( + { + onopentag(name) { + if (stack.length >= 64) + throw new CrawlError( + "markup_depth", + "Sitemap nesting exceeds the 64-level inspection limit.", + ); + const local = name.split(":").pop()!; + stack.push(name); + if (stack.length === 1) + prefix = name.slice(0, name.length - local.length); + if ( + stack.length === 1 && + (local === "urlset" || local === "sitemapindex") + ) + kind = local; + if ( + stack.length === 3 && + name === `${prefix}loc` && + stack[1] === `${prefix}${kind === "urlset" ? "url" : "sitemap"}` + ) + text = ""; + }, + ontext(value) { + if (text !== null && text.length < 2049) + text += (text ? value : value.trimStart()).slice( + 0, + 2049 - text.length, + ); + }, + onclosetag() { + if ( + stack.length === 3 && + stack[2] === `${prefix}loc` && + text !== null + ) { + count++; + if (locations.length < (kind === "sitemapindex" ? 5 : maxUrls)) + locations.push(text.trim()); + text = null; + } + stack.pop(); + }, + }, + { xmlMode: true, decodeEntities: true }, + ); + parser.end(xml); + return { + kind, + locations, + limited: count > (kind === "sitemapindex" ? 5 : maxUrls), + }; +}