diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 46e0635..04d5355 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -36,6 +36,10 @@ jobs: - name: Checkout # actions/checkout v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + # Full history so sitemap `lastmod` can use each source file's real + # last-commit date (see src/src/lib/discovery/last-modified.ts). + fetch-depth: 0 - name: Install Bun # oven-sh/setup-bun v2.2.0 @@ -55,6 +59,22 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Capture the currently deployed discovery manifest + # Captured before the new site is deployed so the post-deploy IndexNow + # step can diff "what changed". Published as /_discovery/previous.json + # (public, non-sensitive) so no cross-job artifact is needed. + env: + SITE_URL: ${{ vars.SITE_URL }} + run: | + ORIGIN="${SITE_URL:-https://purview.dev}" + mkdir -p .discovery-capture + if curl -fsSL "$ORIGIN/discover.json" -o .discovery-capture/previous.json; then + echo "Captured the previous discovery manifest." + else + echo '{"schemaVersion":0,"resources":[]}' > .discovery-capture/previous.json + echo "No previous discovery manifest (first deployment)." + fi + - name: Run validation # `just validate` runs a live data sync on fresh checkouts (no cache / # docs mirror yet), so pass a token to avoid unauthenticated rate limits. @@ -92,11 +112,31 @@ jobs: echo "site-url=$SITE" >> "$GITHUB_OUTPUT" - name: Build site with live data + # The build never receives the IndexNow key: the key verification file is + # written from the secret by a separate step below (see docs/discovery.md). env: PAGES_BASE: ${{ steps.pages-config.outputs.pages-base }} SITE_URL: ${{ steps.pages-config.outputs.site-url }} run: bun run build + - name: Write the IndexNow key verification file + # The key is supplied only by the INDEXNOW_KEY repository secret and is + # never part of the build or its logs. This writes /.txt, the file + # IndexNow fetches to prove ownership of the domain. + env: + INDEXNOW_KEY: ${{ secrets.INDEXNOW_KEY }} + run: | + if [ -z "${INDEXNOW_KEY}" ]; then + echo "::warning::INDEXNOW_KEY is not configured; skipping the key verification file." + exit 0 + fi + bun run discovery:key + + - name: Publish the previous discovery manifest for change detection + run: | + mkdir -p src/dist/_discovery + cp .discovery-capture/previous.json src/dist/_discovery/previous.json + - name: Configure Pages # actions/configure-pages v6.0.0 uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d @@ -119,3 +159,39 @@ jobs: id: deployment # actions/deploy-pages v5.0.1 uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 + + indexnow: + # Runs only after a successful production deployment. A failed deploy must + # never tell search engines that new URLs are live. The site deployment + # stands on its own; a notification failure is reported here, not by + # rolling the deployment back. + needs: deploy + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - name: Checkout + # actions/checkout v7.0.1 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + + - name: Install Bun + # oven-sh/setup-bun v2.2.0 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: 1.4.2 + + - name: Notify IndexNow about changed URLs + # Submits only URLs added or modified since the previous deployment. + # `discovery:indexnow` has no external dependencies, so no `bun install` + # is needed. The IndexNow key is never printed. + env: + SITE_URL: ${{ vars.SITE_URL }} + INDEXNOW_KEY: ${{ secrets.INDEXNOW_KEY }} + run: | + if [ -z "${INDEXNOW_KEY}" ]; then + echo "::warning::INDEXNOW_KEY is not configured; skipping the IndexNow notification." + exit 0 + fi + ORIGIN="${SITE_URL:-https://purview.dev}" + bun run discovery:indexnow \ + --current "$ORIGIN/discover.json" \ + --previous "$ORIGIN/_discovery/previous.json" diff --git a/README.md b/README.md index 9bc9e6f..539448a 100644 --- a/README.md +++ b/README.md @@ -425,6 +425,28 @@ The release workflow is intentionally scoped to root `package.json` changes so content refreshes do not get blocked by an unchanged version or an existing `v{version}` tag. +## Search & AI discovery + +The site is discoverable by search engines and AI tooling through a single, +generated discovery layer: a partitioned sitemap (`/sitemap-index.xml` → +`/sitemaps/{pages,projects,llms}.xml`), `robots.txt`, root and per-project +`llms.txt` / `llms-full.txt`, and a Purview-specific `/discover.json`. Everything +is derived from the project catalogue, the docs manifest and the release cache — +there is no hand-maintained URL list. After a successful deployment, IndexNow +notifies participating engines about changed URLs only. + +```shell +bun run discovery:validate # validate the built discovery artifacts +bun run discovery:manifest # regenerate artifacts from the current caches +bun run discovery:key # write /.txt from the secret +bun run discovery:indexnow --dry-run # show what would be submitted (no network, no key) +``` + +One-time operator setup (Google Search Console domain property, Bing Webmaster +Tools, and the `INDEXNOW_KEY` secret) plus the full operational checklist live in +[docs/discovery.md](docs/discovery.md); see also +[docs/decisions/0007-search-and-discovery.md](docs/decisions/0007-search-and-discovery.md). + ## Dependency maintenance Dependabot (`.github/dependabot.yml`) keeps Bun dependencies and GitHub Actions diff --git a/bun.lock b/bun.lock index f001c1e..5eb7990 100644 --- a/bun.lock +++ b/bun.lock @@ -14,19 +14,19 @@ }, "devDependencies": { "@astrojs/check": "0.9.10", - "@astrojs/markdown-remark": "^7.0.1", + "@astrojs/markdown-remark": "^7.3.1", "@astrojs/preact": "6.0.5", "@astrojs/sitemap": "3.7.4", - "@astrojs/starlight": "0.42.3", + "@astrojs/starlight": "0.42.5", "@astrojs/starlight-tailwind": "5.0.0", "@shikijs/themes": "4.4.3", "@tailwindcss/vite": "4.3.3", "@types/bun": "1.4.2", "@types/semver": "^7.8.0", - "astro": "7.3.4", + "astro": "7.3.5", "fast-glob": "3.3.3", "lefthook": "2.1.14", - "mermaid": "^11.16.1", + "mermaid": "^11.17.2", "oxfmt": "0.68.0", "oxlint": "1.83.0", "semver": "7.8.5", @@ -44,33 +44,33 @@ "packages": { "@antfu/install-pkg": ["@antfu/install-pkg@2.1.0", "", { "dependencies": { "package-manager-detector": "^1.8.0", "tinyexec": "^1.3.1" } }, "sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw=="], - "@astrojs/astro2tsx": ["@astrojs/astro2tsx@0.1.1", "", { "dependencies": { "@emnapi/core": "1.11.3", "@napi-rs/wasm-runtime": "1.2.4" } }, "sha512-VNhvoKzs0FXF3iq+2QVIjTqA6awKDKpd2U7khYg3AhcxjM1KMINEma4IeqQnMqJUakw0MNQygntLvLmSPS5pLA=="], + "@astrojs/astro2tsx": ["@astrojs/astro2tsx@0.1.2", "", { "dependencies": { "@emnapi/core": "1.11.3", "@napi-rs/wasm-runtime": "1.2.4" } }, "sha512-Uj/M9zbrhu5cIZEvWAGTbQJRKSxXWJ/E59BZwt7F+chrKhbfttW+9rkS/l+1QxoJP1KU3UnpM62M7yszfAI0WA=="], "@astrojs/check": ["@astrojs/check@0.9.10", "", { "dependencies": { "@astrojs/language-server": "^2.16.7", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^18.0.0" }, "peerDependencies": { "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "astro-check": "./bin/astro-check.js" } }, "sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ=="], - "@astrojs/compiler-binding": ["@astrojs/compiler-binding@0.4.1", "", { "optionalDependencies": { "@astrojs/compiler-binding-android-arm64": "0.4.1", "@astrojs/compiler-binding-darwin-arm64": "0.4.1", "@astrojs/compiler-binding-darwin-x64": "0.4.1", "@astrojs/compiler-binding-linux-arm64-gnu": "0.4.1", "@astrojs/compiler-binding-linux-arm64-musl": "0.4.1", "@astrojs/compiler-binding-linux-x64-gnu": "0.4.1", "@astrojs/compiler-binding-linux-x64-musl": "0.4.1", "@astrojs/compiler-binding-wasm32-wasi": "0.4.1", "@astrojs/compiler-binding-win32-arm64-msvc": "0.4.1", "@astrojs/compiler-binding-win32-x64-msvc": "0.4.1" } }, "sha512-ZYVDW1P58OXyxqZbOMp+/2U9KA7y2esIx9r4pWdpZ/l6fCZhaw4ul+D7EgpRVxYLkyTWqzANbTTUFGPFP8zDHA=="], + "@astrojs/compiler-binding": ["@astrojs/compiler-binding@0.5.1", "", { "optionalDependencies": { "@astrojs/compiler-binding-android-arm64": "0.5.1", "@astrojs/compiler-binding-darwin-arm64": "0.5.1", "@astrojs/compiler-binding-darwin-x64": "0.5.1", "@astrojs/compiler-binding-linux-arm64-gnu": "0.5.1", "@astrojs/compiler-binding-linux-arm64-musl": "0.5.1", "@astrojs/compiler-binding-linux-x64-gnu": "0.5.1", "@astrojs/compiler-binding-linux-x64-musl": "0.5.1", "@astrojs/compiler-binding-wasm32-wasi": "0.5.1", "@astrojs/compiler-binding-win32-arm64-msvc": "0.5.1", "@astrojs/compiler-binding-win32-x64-msvc": "0.5.1" } }, "sha512-oczyolLUl9mNrtuBuNjuvn5thpHrFLXAHAWGTRg0JDHQ8cGVsVeFekOu2uSoQGgainJIi+A3/9m0DyYwz3Nlnw=="], - "@astrojs/compiler-binding-android-arm64": ["@astrojs/compiler-binding-android-arm64@0.4.1", "", { "os": "android", "cpu": "arm64" }, "sha512-Wtx2DZORuNTLASLLZ8hlrjFaJe1LUloatcCn9EPq7knBhRpjMGXeJANIJMeBZG4igvz7x+3nyAJM2iwCSW9xog=="], + "@astrojs/compiler-binding-android-arm64": ["@astrojs/compiler-binding-android-arm64@0.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-nMb2nMIJtcWLNH+fV75Enmi+jp4tIOYOfsv4XtpgdOWkqc9P4uSSdK5u1xb340AzrXo5V18SxLl/nc3vcqtamQ=="], - "@astrojs/compiler-binding-darwin-arm64": ["@astrojs/compiler-binding-darwin-arm64@0.4.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7iwXcU+hB60Y1SvOfnWHNioT72gDsDQKaDipXh2Onwp/TvQMozhbvhy13wJR8Af940a0h6PbddCeJFpj791UDA=="], + "@astrojs/compiler-binding-darwin-arm64": ["@astrojs/compiler-binding-darwin-arm64@0.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5k4+nZ/WRt+dcVow4z7rBuI7iv9A58h/uQ91YJe1PrTivbcCkq3u2Ynx/AZJwII4CsZawBCeGpojkFjutZBhBQ=="], - "@astrojs/compiler-binding-darwin-x64": ["@astrojs/compiler-binding-darwin-x64@0.4.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-pifjICos49RXhSVKSDu5gDB0/8j0O7DmxSp1DaWW2PyLG5ekwSkcrfOZto5JqhdI7QYMZB3XOiXpsdOXpS4npA=="], + "@astrojs/compiler-binding-darwin-x64": ["@astrojs/compiler-binding-darwin-x64@0.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-0Buw+YST9Ff2EqQyhRMQujuFq/dijLw3zXWhsvI0dJ2pGrT38iVoxTEca2eBX/PfhlwkVWSRJEKO5gMqKHyeHg=="], - "@astrojs/compiler-binding-linux-arm64-gnu": ["@astrojs/compiler-binding-linux-arm64-gnu@0.4.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-hsZMlNWc6LqWHh9LlSwTq1/XCY7pxA5rkkKIdZRV8mgbOz9OwLvjEoe5Jry13eIZjMUQO8XnQyzk8o/Pjw2YFA=="], + "@astrojs/compiler-binding-linux-arm64-gnu": ["@astrojs/compiler-binding-linux-arm64-gnu@0.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-x5LRpyCoMKuu3INr18cn42YhwSOaRpp9b5Ny3eJJ/sWVeejC16Zeshu1JlvlSmEZACLeWxU5JM0RX6T6nI+YyA=="], - "@astrojs/compiler-binding-linux-arm64-musl": ["@astrojs/compiler-binding-linux-arm64-musl@0.4.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-a3EW27f+Z9uf57vYmpLgUriA1y9wDhKz57PT8D8XlFZt2HZEWhUxKuxI1jvKVI9p8okYee11WXEm/FHY1G9yoQ=="], + "@astrojs/compiler-binding-linux-arm64-musl": ["@astrojs/compiler-binding-linux-arm64-musl@0.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mL7tQEuDk3rGfaKmrO63nw6WBy1TPVioQGmVyjcAr+C+a/k3SVMYBytNSMqH9mZPT/Swj30JaZ2N842Z1NnOEQ=="], - "@astrojs/compiler-binding-linux-x64-gnu": ["@astrojs/compiler-binding-linux-x64-gnu@0.4.1", "", { "os": "linux", "cpu": "x64" }, "sha512-XruPTKB/SS4DFzeAjQ1ZyM0ieHDjapHkOSnnzRApZ4FXY2YAI7UY3mETjfJiIS/acyZyrnPH5jObv1gATTdUJQ=="], + "@astrojs/compiler-binding-linux-x64-gnu": ["@astrojs/compiler-binding-linux-x64-gnu@0.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-uLGc9jtxwiDe4cQyhNPQim4ECiB/YzbWlXssfLAv8r374kArfTwBHjwR2+j+XVyF71yCx5mF9YNRclRqdNBgeg=="], - "@astrojs/compiler-binding-linux-x64-musl": ["@astrojs/compiler-binding-linux-x64-musl@0.4.1", "", { "os": "linux", "cpu": "x64" }, "sha512-E8j7lGanqNpudtkUbsFaOLJhmwFe4Ecqux7K9hkj4wULIPNZRM2bYZNcMSj8r1oWvaUiwZl8LaaNwv3+14jqhw=="], + "@astrojs/compiler-binding-linux-x64-musl": ["@astrojs/compiler-binding-linux-x64-musl@0.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-G0cywinaQUc8v1MrytvYKUX7svTDMmYtOCZfxjwKPbJi59zIqM+Ll0rWgO0GQWLZH3XoPYo19ZwlP3Xt9ep9YA=="], - "@astrojs/compiler-binding-wasm32-wasi": ["@astrojs/compiler-binding-wasm32-wasi@0.4.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.2.4" }, "cpu": "none" }, "sha512-vNLxIv41ZE+nmPFtw24cRW5ZXG+ZPcAGtgigtjCP8lvcxKI7u2GrXoMsCgEH5fMw8WCEJpwLvsVBg15rJKAkyA=="], + "@astrojs/compiler-binding-wasm32-wasi": ["@astrojs/compiler-binding-wasm32-wasi@0.5.1", "", { "dependencies": { "@emnapi/core": "1.11.3", "@emnapi/runtime": "1.11.3", "@napi-rs/wasm-runtime": "^1.2.4" }, "cpu": "none" }, "sha512-yMhLL8asmLydoAdnEZxAw6eLfNDs3YJl6jqn0pjPt6zrb2U5qIDLnii4/twThpIFODKkQ6YDpBRTOz/vSfZCxw=="], - "@astrojs/compiler-binding-win32-arm64-msvc": ["@astrojs/compiler-binding-win32-arm64-msvc@0.4.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-/xTDgRsFDoTh0/+KFXJ74DY4Jqn8CoSHwa8UP5RAblHfnRHkenwfSl4zYDET61O9vXKLk09UzHjjq09yP20/AQ=="], + "@astrojs/compiler-binding-win32-arm64-msvc": ["@astrojs/compiler-binding-win32-arm64-msvc@0.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aClIG6poFt3Wf/bZykrEAX2nAnKvBHW6RXiKUa69QjBtWH0/FOlOAjnFcxRNlhej2AMWNYFx0o+LGjwMcH8P7Q=="], - "@astrojs/compiler-binding-win32-x64-msvc": ["@astrojs/compiler-binding-win32-x64-msvc@0.4.1", "", { "os": "win32", "cpu": "x64" }, "sha512-0x5J6iHZO0Fjo/CVzoIbXKzWnGESftDbEUQ/oXQ61HhUAFoR7+T1jfToZSNAS+gYyBXHSTpMWYC7lzarbhClcg=="], + "@astrojs/compiler-binding-win32-x64-msvc": ["@astrojs/compiler-binding-win32-x64-msvc@0.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-/E6uBCagY70YpWqssBC+/FuniNSloeQPRgj7l3LDWiqjxIyTLnIgX1sTt18FU7jrigetpSnC9cHAM6b6IxSgPw=="], - "@astrojs/compiler-rs": ["@astrojs/compiler-rs@0.4.1", "", { "dependencies": { "@astrojs/compiler-binding": "0.4.1" } }, "sha512-//NmAuRhy7eU9iP0ceI5Pymy55M+nUZ//kS6XeOyhBWdDib2KbJOyFaVJ52k7sDkJwTKVox7oPu3SpYiVnGm5A=="], + "@astrojs/compiler-rs": ["@astrojs/compiler-rs@0.5.1", "", { "dependencies": { "@astrojs/compiler-binding": "0.5.1" } }, "sha512-1k1acnX7DepIiea9U9/Y+d1hdAFfylu1ZQQkkYgRSwBOBAsXInolyZuh/2tax+83vu/A+hOYP6P3+265RvMTvw=="], "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.11.0", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.3.0", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-3rzxJ+xbo0+8YyqOzLziIN32wmsHdCjEVz2sGOpRxJ+Ben/KiLph4ItxBy1abEL+E8fkRzqjg0rfXmaHJGw9JA=="], @@ -88,7 +88,7 @@ "@astrojs/sitemap": ["@astrojs/sitemap@3.7.4", "", { "dependencies": { "sitemap": "^9.0.0", "zod": "^4.3.6" } }, "sha512-LbKNC24bdUWcQf/pThB6qLlSqHojxGjZDURIzFocY8rlWnAn2t74nnhnK6S5x0NHriHoAduLEpVjRykmeGiVvA=="], - "@astrojs/starlight": ["@astrojs/starlight@0.42.3", "", { "dependencies": { "@astrojs/markdown-satteri": "^0.4.0", "@astrojs/mdx": "^8.0.0", "@astrojs/sitemap": "^3.7.3", "@pagefind/default-ui": "^1.3.0", "@types/hast": "^3.0.5", "@types/js-yaml": "^4.0.9", "@types/mdast": "^4.0.4", "astro-expressive-code": "^0.44.0", "bcp-47": "^2.1.0", "hast-util-format": "^1.1.0", "hast-util-select": "^6.0.4", "hast-util-to-html": "^9.0.5", "hast-util-to-string": "^3.0.1", "hastscript": "^9.0.1", "i18next": "^26.0.7", "js-yaml": "^4.1.1", "klona": "^2.0.6", "magic-string": "^1.2.3", "mdast-util-directive": "^3.1.0", "mdast-util-to-markdown": "^2.1.2", "mdast-util-to-string": "^4.0.0", "pagefind": "^1.5.2", "remark-directive": "^4.0.0", "satteri": "^0.10.3", "ultrahtml": "^1.6.0", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@astrojs/markdown-remark": "^7.3.0", "astro": "^7.2.10" }, "optionalPeers": ["@astrojs/markdown-remark"] }, "sha512-eX0KI2jeNWkI42pJNA2YpStfdNZ/NRBp6H9/Ui/zmu7qJ66yHV4qdgW8SfsCNt2QMUMgvm8auUNOsE4aD68RUQ=="], + "@astrojs/starlight": ["@astrojs/starlight@0.42.5", "", { "dependencies": { "@astrojs/markdown-satteri": "^0.4.0", "@astrojs/mdx": "^8.0.0", "@astrojs/sitemap": "^3.7.3", "@pagefind/default-ui": "^1.3.0", "@types/hast": "^3.0.5", "@types/js-yaml": "^4.0.9", "@types/mdast": "^4.0.4", "astro-expressive-code": "^0.44.0", "bcp-47": "^2.1.0", "hast-util-format": "^1.1.0", "hast-util-select": "^6.0.4", "hast-util-to-html": "^9.0.5", "hast-util-to-string": "^3.0.1", "hastscript": "^9.0.1", "i18next": "^26.0.7", "js-yaml": "^4.1.1", "klona": "^2.0.6", "magic-string": "^1.2.3", "mdast-util-directive": "^3.1.0", "mdast-util-to-markdown": "^2.1.2", "mdast-util-to-string": "^4.0.0", "pagefind": "^1.5.2", "remark-directive": "^4.0.0", "satteri": "^0.10.3", "ultrahtml": "^1.6.0", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@astrojs/markdown-remark": "^7.3.0", "astro": "^7.2.10" }, "optionalPeers": ["@astrojs/markdown-remark"] }, "sha512-S6hjUexk9rrKtjJF3WR8T9dM1woNR7X2XU8e3wj4OpRT4X15AXk/mzPjWe/Qd5+P8whHj2+ves+k06qirBzimg=="], "@astrojs/starlight-tailwind": ["@astrojs/starlight-tailwind@5.0.0", "", { "peerDependencies": { "@astrojs/starlight": ">=0.38.0", "tailwindcss": "^4.0.0" } }, "sha512-VivF+bWg++4ma/ffr5sgHsd/ONtGdVJIKAaRZ6jmL4yqxy7bviu59MGNi5aW3nd8psP9i/aivBTrpwGxRM1XyA=="], @@ -330,7 +330,7 @@ "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], - "@oxc-project/types": ["@oxc-project/types@0.151.0", "", {}, "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA=="], + "@oxc-project/types": ["@oxc-project/types@0.152.0", "", {}, "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.68.0", "", { "os": "android", "cpu": "arm" }, "sha512-dhfYPbzv/h9JgHjNkl2R6sOjUfxDyLGOZVb3g8/ScaTNwwJcYgmHh8kcYFDUhinuy1QAoANCWUvw1jlk+z6gAg=="], @@ -426,7 +426,7 @@ "@preact/preset-vite": ["@preact/preset-vite@2.10.6", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@prefresh/vite": "^2.4.11", "@rollup/pluginutils": "^5.0.0", "babel-plugin-transform-hook-names": "^1.0.2", "debug": "^4.4.3", "magic-string": "^0.30.21", "picocolors": "^1.1.1", "vite-prerender-plugin": "^0.5.8", "zimmerframe": "^1.1.4" }, "peerDependencies": { "@babel/core": "7.x", "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" } }, "sha512-ZZ5RIT2ZVXg8UxRA8VZpoT8CdpDG7RFIqOT4bELqNBp85+HKvQBbgmh3gsoW1UOQXx26TLe+ZRNKI7q281Kckw=="], - "@preact/signals": ["@preact/signals@2.11.2", "", { "dependencies": { "@preact/signals-core": "^1.14.4" }, "peerDependencies": { "preact": ">= 10.25.0 || >=11.0.0-0" } }, "sha512-rVTRTt/T0HIRgbugwS5FigbfF/kfdEFYFtiqxa+lbpqTajepqnR0firuAS2iNdHyejWB7Yc9p9QePkVNBtTAwg=="], + "@preact/signals": ["@preact/signals@2.11.3", "", { "dependencies": { "@preact/signals-core": "^1.14.4" }, "peerDependencies": { "preact": ">= 10.25.0 || >=11.0.0-0" } }, "sha512-mttwFM98G/JiriTgMwU+UWygL6sfVmYEiNcPSKkUnv4PxPpZhmP2ugGC4qi360+ZIHyQ8NY05bfJT3b4RUIGlw=="], "@preact/signals-core": ["@preact/signals-core@1.14.4", "", {}, "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA=="], @@ -440,49 +440,49 @@ "@purview-dev/website": ["@purview-dev/website@workspace:src"], - "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.10", "", { "os": "android", "cpu": "arm" }, "sha512-bp9svZb+QurZeh+8H4BhrZkifEB0YBNvTVzNSJnJQkj4NrRwmQoDUCGP0vSN7PbvLeM7l1tK6GXL8mrTiH2myg=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.12", "", { "os": "android", "cpu": "arm" }, "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.10", "", { "os": "android", "cpu": "arm64" }, "sha512-wm6Dld3RXUAZ/gRWKyUy+4W1B5CB5UeFaOzsSWJWEdxZXHH8rCYiZ5dGe6oJmhsunAPWzL7FZV+VtvmN5Ye2eA=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.12", "", { "os": "android", "cpu": "arm64" }, "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UbEfXq/AqGNgRTV3ik+X/iR6mUxu2QdYAadwRxJWquUGnW6gDqdP1FtLtFXRow7RJx0ssRwi80XAPr4r+4DtsA=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-7f5h17q5KZVx/ji1vb8OTq31ch1O2I7K8NPIr44GkyWTApXMIsmhWqZfgpOH10xeauqghDAvGlZktasCkcF6Eg=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ynOk/eEYhC6ZB2xCGvKrEOwE58oBy9LnrAqtkrDF9Fz1VTaNdGZTsV0VarJdhPwb+sOJTGjCLwcuyRJZ1dnMcQ=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.10", "", { "os": "linux", "cpu": "arm" }, "sha512-ERrAs185meZZhGan7a4l3RiiJK1ArSDlHdST++uvSxe+FDbR4TwUPahT/cbZJvaG6fIpDpF78surN+tX708Y4Q=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.12", "", { "os": "linux", "cpu": "arm" }, "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-KN7OHKD0J3jy1UzBwZWPxpwhODf9IARUIJcrH+yLYKOcmegZ8luEUM38lDP1bDVj40yP6PsSzCqOJF76vljFnQ=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-8l9wP8O+wa8zD6iw6egSfzVtu7oZVfH3hlUsMM4MwbLMhxleqeoXbZzjddyK3YyNlwLhqznq3tF7PkNJ8T/V2w=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-SeXNKeQzA5kLhz/J0CH6ZP0/HJ3v1xm/0YbiYpE0kK7emfRC2OIGGIaE14xzkISEGv2aYuUSpiLiU5Gbq+OI0A=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-mtht0nR+y8/hart4175Ll15w7lY8dg7CtQ+j2FDNTsDRspOWTK/2V3l0aj9sIj7XmvqxT8Yli/wq22e7feTTWg=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.10", "", { "os": "linux", "cpu": "x64" }, "sha512-FSM94nGd55NYo48usCyM/nHfUKRnqc9+b0vJNuKV0oCCpIp/OGims7rO1Nv/DkFkt0S/s2rxsJ2kkS8J3HcpeA=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.10", "", { "os": "linux", "cpu": "x64" }, "sha512-C3YxNB16myRLs7o+B+6PnQ6jBsdIS4+AE4Ah8glVGhDpEv9AOvxhZ/1duAb4B0UGczEK/lBbccksd8VI+p6zfw=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.10", "", { "os": "none", "cpu": "arm64" }, "sha512-571TlE/F1eeTjjdjYAMMMPs1Mfv3MtX6s3+ZKVU6HiUjZ5Njc6c/qzNy/8K3zALTZnaw3JQVYrHxvNfjm43KAg=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-QXW+ZWaiqs2c7Fi++D/SsW07LTPcUrncxcskJGfGNBoaLik1IU6fJymz4HsqwEO0u5Iq11yTO0B/mc4cPk7jrQ=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.10", "", { "os": "win32", "cpu": "x64" }, "sha512-5FQFGgah17YeMtG1Yd5a+rMxQpTksyNXxRtKz06FVTaQw3RKYUJQbUoKk0/5jrXBpDo+7makNP7UHA2LQyH64A=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], - "@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="], + "@shikijs/core": ["@shikijs/core@4.5.0", "", { "dependencies": { "@shikijs/primitive": "4.5.0", "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw=="], - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw=="], - "@shikijs/langs": ["@shikijs/langs@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A=="], + "@shikijs/langs": ["@shikijs/langs@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0" } }, "sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA=="], - "@shikijs/primitive": ["@shikijs/primitive@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ=="], + "@shikijs/primitive": ["@shikijs/primitive@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg=="], "@shikijs/themes": ["@shikijs/themes@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw=="], @@ -586,7 +586,7 @@ "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], - "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + "@types/d3-zoom": ["@types/d3-zoom@3.0.9", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA=="], "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], @@ -610,7 +610,7 @@ "@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="], - "@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], + "@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="], "@types/picomatch": ["@types/picomatch@4.0.3", "", {}, "sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ=="], @@ -656,7 +656,7 @@ "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], - "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + "ansi-regex": ["ansi-regex@6.4.0", "", {}, "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw=="], "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -672,7 +672,7 @@ "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], - "astro": ["astro@7.3.4", "", { "dependencies": { "@astrojs/compiler-rs": "^0.4.0", "@astrojs/internal-helpers": "0.11.0", "@astrojs/markdown-satteri": "0.4.2", "@astrojs/telemetry": "3.3.3", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", "am-i-vibing": "^0.4.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^2.0.1", "devalue": "^5.8.1", "diff": "^9.0.0", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.28.0", "find-proc": "0.2.0", "flattie": "^1.1.1", "fontace": "~0.4.1", "get-tsconfig": "5.0.0-beta.4", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.3.2", "jsonc-parser": "^3.3.1", "magic-string": "^1.0.0", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^1.0.1", "obug": "^3.0.0", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.4", "shiki": "^4.0.2", "smol-toml": "^1.8.0", "svgo": "^4.1.0", "tinyclip": "^1.0.1", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "ultrahtml": "^1.6.0", "unifont": "~0.7.5", "unstorage": "^1.17.5", "verkit": "^0.4.0", "vite": "^8.0.13", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.5.4" }, "optionalDependencies": { "sharp": "^0.35.4" }, "peerDependencies": { "@astrojs/markdown-remark": "^7.3.0" }, "optionalPeers": ["@astrojs/markdown-remark"], "bin": { "astro": "./bin/astro.mjs" } }, "sha512-9iZqfREj15mh5xYnKhjP8arO4F3Vcddri1rbfT3FseNElWHU+pl9vJY5ypKT1FhB/zyuGM/B9naSjeVQl9b5vw=="], + "astro": ["astro@7.3.5", "", { "dependencies": { "@astrojs/compiler-rs": "^0.5.0", "@astrojs/internal-helpers": "0.11.0", "@astrojs/markdown-satteri": "0.4.2", "@astrojs/telemetry": "3.3.3", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", "am-i-vibing": "^0.4.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^2.0.1", "devalue": "^5.8.1", "diff": "^9.0.0", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.28.0", "find-proc": "0.2.0", "flattie": "^1.1.1", "fontace": "~0.4.1", "get-tsconfig": "5.0.0-beta.4", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.3.2", "jsonc-parser": "^3.3.1", "magic-string": "^1.0.0", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^1.0.1", "obug": "^3.0.0", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.4", "shiki": "^4.0.2", "smol-toml": "^1.8.0", "svgo": "^4.1.0", "tinyclip": "^1.0.1", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "ultrahtml": "^1.6.0", "unifont": "~0.7.5", "unstorage": "^1.17.5", "verkit": "^0.4.0", "vite": "^8.0.13", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.5.4" }, "optionalDependencies": { "sharp": "^0.35.4" }, "peerDependencies": { "@astrojs/markdown-remark": "^7.3.0" }, "optionalPeers": ["@astrojs/markdown-remark"], "bin": { "astro": "./bin/astro.mjs" } }, "sha512-1p/ernaq/RuESbM5NMjWzbO6SiebMGrGcYlGjMfBhzzgM+n/X7AUmVyw8BqW2QYCdivpsHwJBcD1fqmlTZjZHg=="], "astro-expressive-code": ["astro-expressive-code@0.44.2", "", { "dependencies": { "rehype-expressive-code": "^0.44.2", "url-extras": "^0.1.0" }, "peerDependencies": { "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0" } }, "sha512-avop7nZcRwC7quvIxVguhwEZ9Tp6IoTzKUCLwP30Fb7+er+pWQgcysP01dcuzRIJo81kLGg30q/YAI2Yb1Sdzg=="], @@ -682,7 +682,7 @@ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.11.25", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.27", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ=="], "bcp-47": ["bcp-47@2.1.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-KLw+H/gd2p4zly1X7Yh/qziuyae5/w/QFnvTng9eZL5fvszL7Whl3MBoWF8yxL7ksUjBfOD+OxkytiqbBpG+Fw=="], @@ -692,11 +692,11 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "browserslist": ["browserslist@4.29.0", "", { "dependencies": { "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.427", "node-releases": "^2.0.55", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA=="], + "browserslist": ["browserslist@4.29.3", "", { "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001813", "electron-to-chromium": "^1.5.439", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA=="], "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], - "caniuse-lite": ["caniuse-lite@1.0.30001812", "", {}, "sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng=="], + "caniuse-lite": ["caniuse-lite@1.0.30001814", "", {}, "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -854,13 +854,13 @@ "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], - "electron-to-chromium": ["electron-to-chromium@1.5.438", "", {}, "sha512-AN9xMU1hJiT65LkCUPL1DZm5TCbOPb2Qsm5pYwFLEXp6/qj9SdT4yMiqs7Qqstwvkn1zF7l36SRGt+s6XcJ0FA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.444", "", {}, "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ=="], "emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - "enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="], + "enhanced-resolve": ["enhanced-resolve@5.26.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -1136,7 +1136,7 @@ "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], - "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw=="], "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], @@ -1154,7 +1154,7 @@ "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], - "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-character": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-wgyJtgUkUcdU7zci7uuwc/tzoAhm0TswWhaXuvnWolqug+8jY7bgNkBBL0dYBnHgc/tHL7lCzRdPsdUxUKKBqw=="], "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], @@ -1164,9 +1164,9 @@ "mermaid": ["mermaid@11.17.2", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.34.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.21", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "fastdom": "1.0.12", "katex": "^0.16.47", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg=="], - "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + "micromark": ["micromark@4.0.3", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-edit-map": "^1.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw=="], - "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.4", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.1.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-edit-map": "^1.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.3" } }, "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w=="], "micromark-extension-directive": ["micromark-extension-directive@4.0.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "parse-entities": "^4.0.0" } }, "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg=="], @@ -1200,7 +1200,7 @@ "micromark-factory-mdx-expression": ["micromark-factory-mdx-expression@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ=="], - "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + "micromark-factory-space": ["micromark-factory-space@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q=="], "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], @@ -1218,6 +1218,8 @@ "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + "micromark-util-edit-map": ["micromark-util-edit-map@1.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q=="], + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], "micromark-util-events-to-acorn": ["micromark-util-events-to-acorn@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg=="], @@ -1234,7 +1236,7 @@ "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], - "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "micromark-util-types": ["micromark-util-types@2.0.3", "", {}, "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], @@ -1314,7 +1316,7 @@ "preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="], - "preact-render-to-string": ["preact-render-to-string@6.7.0", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-Z4WR8fmLMRpdYqJ9i7vrlXSsSrxVJydwrkEXHapexfARbWfGb7vGcnvNQnIzN0cXciMVOlz/XLoiMCi9gUsy9Q=="], + "preact-render-to-string": ["preact-render-to-string@6.8.0", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-2qiMrReQz+MZ5+/bT27kbVnFE7QNCC2fku/Tnc2H5bFUS3I3ASbRy0q72XLZ+2p2KyDE5xMfvSEtkmVo6TRNyA=="], "prettier": ["prettier@3.9.9", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg=="], @@ -1390,7 +1392,7 @@ "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], - "rolldown": ["rolldown@1.2.10", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.10", "@rolldown/binding-android-arm64": "1.2.10", "@rolldown/binding-darwin-arm64": "1.2.10", "@rolldown/binding-darwin-x64": "1.2.10", "@rolldown/binding-freebsd-x64": "1.2.10", "@rolldown/binding-linux-arm-gnueabihf": "1.2.10", "@rolldown/binding-linux-arm64-gnu": "1.2.10", "@rolldown/binding-linux-arm64-musl": "1.2.10", "@rolldown/binding-linux-ppc64-gnu": "1.2.10", "@rolldown/binding-linux-s390x-gnu": "1.2.10", "@rolldown/binding-linux-x64-gnu": "1.2.10", "@rolldown/binding-linux-x64-musl": "1.2.10", "@rolldown/binding-openharmony-arm64": "1.2.10", "@rolldown/binding-win32-arm64-msvc": "1.2.10", "@rolldown/binding-win32-x64-msvc": "1.2.10" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-OxkA08pSryMK7B3XiFA09B4OJ1xJMPgIYCBMY2xchzpqgBGsV1o0DetPAE+Sl3N3L4oCPiEzmHVSOj7iR04Zog=="], + "rolldown": ["rolldown@1.2.12", "", { "dependencies": { "@oxc-project/types": "=0.152.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.12", "@rolldown/binding-android-arm64": "1.2.12", "@rolldown/binding-darwin-arm64": "1.2.12", "@rolldown/binding-darwin-x64": "1.2.12", "@rolldown/binding-freebsd-x64": "1.2.12", "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", "@rolldown/binding-linux-arm64-gnu": "1.2.12", "@rolldown/binding-linux-arm64-musl": "1.2.12", "@rolldown/binding-linux-ppc64-gnu": "1.2.12", "@rolldown/binding-linux-s390x-gnu": "1.2.12", "@rolldown/binding-linux-x64-gnu": "1.2.12", "@rolldown/binding-linux-x64-musl": "1.2.12", "@rolldown/binding-openharmony-arm64": "1.2.12", "@rolldown/binding-win32-arm64-msvc": "1.2.12", "@rolldown/binding-win32-x64-msvc": "1.2.12" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ=="], "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], @@ -1408,7 +1410,7 @@ "sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="], - "shiki": ["shiki@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="], + "shiki": ["shiki@4.5.0", "", { "dependencies": { "@shikijs/core": "4.5.0", "@shikijs/engine-javascript": "4.5.0", "@shikijs/engine-oniguruma": "4.5.0", "@shikijs/langs": "4.5.0", "@shikijs/themes": "4.5.0", "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ=="], "simple-code-frame": ["simple-code-frame@1.3.0", "", { "dependencies": { "kolorist": "^1.6.0" } }, "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w=="], @@ -1420,7 +1422,7 @@ "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], @@ -1434,7 +1436,7 @@ "strictdom": ["strictdom@1.0.1", "", {}, "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg=="], - "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + "string-width": ["string-width@8.3.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ=="], "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], @@ -1448,7 +1450,7 @@ "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], - "supports-hyperlinks": ["supports-hyperlinks@4.5.0", "", { "dependencies": { "has-flag": "^5.0.1", "supports-color": "^10.2.2" } }, "sha512-ZW2OvfeCXrNTbLakPUzjQG922EeGCOteFSVoek5DKStTh898wf7zgtuFlzQN8HfZCxC3Eh02yJVrRW51hADf+w=="], + "supports-hyperlinks": ["supports-hyperlinks@4.6.0", "", { "dependencies": { "has-flag": "^5.0.1", "supports-color": "^10.2.2" } }, "sha512-FL/q/YePS4tFFTNiBOR+zPk2ZrKheqNtmTJ/9OUZ4e1wKHJofDbGQSmSsxS0XNnabSkqexryCaXGvEKz1vf9UQ=="], "svgo": ["svgo@4.1.0", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^6.0.0", "css-tree": "^3.0.1", "css-what": "^7.0.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "1.6.1" }, "bin": { "svgo": "bin/svgo.js" } }, "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q=="], @@ -1540,9 +1542,9 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.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", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="], + "vite": ["vite@8.3.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.11", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.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", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w=="], - "vite-prerender-plugin": ["vite-prerender-plugin@0.5.13", "", { "dependencies": { "kolorist": "^1.8.0", "magic-string": "0.x >= 0.26.0", "node-html-parser": "^6.1.12", "simple-code-frame": "^1.3.0", "source-map": "^0.7.4", "stack-trace": "^1.0.0-pre2" }, "peerDependencies": { "vite": "5.x || 6.x || 7.x || 8.x" } }, "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g=="], + "vite-prerender-plugin": ["vite-prerender-plugin@0.5.14", "", { "dependencies": { "kolorist": "^1.8.0", "magic-string": "0.x >= 0.26.0", "node-html-parser": "^6.1.12", "simple-code-frame": "^1.3.0", "source-map": "^0.7.4", "stack-trace": "^1.0.0-pre2" }, "peerDependencies": { "vite": "5.x || 6.x || 7.x || 8.x" } }, "sha512-X2w9AS1OhwEUec9HSryGAmEnEQJGhW6eFoiCVJv8BfOTVpM8SOjk75cEw/IC28APDHZudeIRbP8Sp7wfABq+Aw=="], "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], @@ -1566,15 +1568,15 @@ "vscode-json-languageservice": ["vscode-json-languageservice@4.1.8", "", { "dependencies": { "jsonc-parser": "^3.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-nls": "^5.0.0", "vscode-uri": "^3.0.2" } }, "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg=="], - "vscode-jsonrpc": ["vscode-jsonrpc@9.0.2", "", {}, "sha512-SbQSV9yRemARxeXw6LU5sS6Zq0e9/DgCCX5yelH263ZQWukbTk8EF8fjTrr1dziasf4GwlJbvTwFnTrnQFWZXQ=="], + "vscode-jsonrpc": ["vscode-jsonrpc@9.0.3", "", {}, "sha512-qETYf9PCl1lVfwR+oBmMfc4vP2nqLNA35ABxorw3j06+xlKRgDVxbLgKm4Y/xPL0YWdeDjLoI4ImICIT3nB0zQ=="], "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], - "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.18.3", "", { "dependencies": { "vscode-jsonrpc": "9.0.2", "vscode-languageserver-types": "3.18.3" } }, "sha512-DF49+WeV5py4zO5hhobp60jjsDSK0lAqA0OuKBLBvp423HPWQcCbhZz3JgyfIewsEz2f8U+X75xNIFHdiXZm2w=="], + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.18.4", "", { "dependencies": { "vscode-jsonrpc": "9.0.3", "vscode-languageserver-types": "3.18.4" } }, "sha512-CCXbyr99dafRJzJH/0Ryk7ly5T8XPmSR0//OuF2w4m+9JiOin7zcAT5iET8GRE7Z9taFBejb3GbM0a0mbjdbrg=="], - "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.14", "", {}, "sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ=="], + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.15", "", {}, "sha512-Gsfr9S8LU3S6885cS+NaleasJ0n3V5y/SOTqHCifQiXWFw3G9w9/YItDfwHrlCz+dSZWWZ8ilYVMcJe7Sz7Sxw=="], - "vscode-languageserver-types": ["vscode-languageserver-types@3.18.3", "", {}, "sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw=="], + "vscode-languageserver-types": ["vscode-languageserver-types@3.18.4", "", {}, "sha512-nQsacoiijvl0fRf2q28uFDrAVwRhdCa3QveiF7/CX0r18mLR5FU/1WskSsCKOjmEbtteKUnY8x8Yzxop+k0zwg=="], "vscode-nls": ["vscode-nls@5.2.0", "", {}, "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng=="], @@ -1606,6 +1608,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@astrojs/compiler-binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -1622,6 +1626,16 @@ "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@shikijs/core/@shikijs/types": ["@shikijs/types@4.5.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw=="], + + "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@4.5.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw=="], + + "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@4.5.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw=="], + + "@shikijs/langs/@shikijs/types": ["@shikijs/types@4.5.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw=="], + + "@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.5.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw=="], + "@tailwindcss/node/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=="], "@tailwindcss/node/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -1642,6 +1656,8 @@ "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "astro/sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], @@ -1664,7 +1680,11 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "sitemap/@types/node": ["@types/node@24.13.6", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg=="], + "shiki/@shikijs/themes": ["@shikijs/themes@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0" } }, "sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q=="], + + "shiki/@shikijs/types": ["@shikijs/types@4.5.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw=="], + + "sitemap/@types/node": ["@types/node@24.19.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ=="], "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], @@ -1708,6 +1728,56 @@ "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "astro/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="], + + "astro/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="], + + "astro/sharp/@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="], + + "astro/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="], + + "astro/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="], + + "astro/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="], + + "astro/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="], + + "astro/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="], + + "astro/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="], + + "astro/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="], + + "astro/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="], + + "astro/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="], + + "astro/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="], + + "astro/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="], + + "astro/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="], + + "astro/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="], + + "astro/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="], + + "astro/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="], + + "astro/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="], + + "astro/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="], + + "astro/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="], + + "astro/sharp/@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="], + + "astro/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="], + + "astro/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="], + + "astro/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="], + "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], @@ -1718,12 +1788,20 @@ "node-html-parser/css-select/css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], - "sitemap/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "sitemap/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "unstorage/chokidar/readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], "vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], "vscode-languageserver/vscode-languageserver-protocol/vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + + "astro/sharp/@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], + + "astro/sharp/@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], + + "astro/sharp/@img/sharp-freebsd-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "astro/sharp/@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], } } diff --git a/docs/decisions/0006-local-path-check.md b/docs/decisions/0006-local-path-check.md new file mode 100644 index 0000000..17052d0 --- /dev/null +++ b/docs/decisions/0006-local-path-check.md @@ -0,0 +1,44 @@ +# ADR 0006 — The build-output safety scan checks machine paths, not drive letters + +- Status: accepted +- Date: 2026-10-03 + +## Context + +`just check-generated` and the built-output tests scan the published output for credentials and for +absolute paths that would identify the machine that produced the build. The original Windows rule was +`[A-Za-z]:\\[^\s"']+` — that is, any drive-letter path. + +Aggregated documentation is not authored for this site; it is mirrored from the product repositories, +and some of them legitimately show Windows paths. The Containers guides document the Windows-only WSL +Containers (WSLC) backend and its image store as `D:\wslc-images` (and `@"D:\wslc-images"` in a C# +sample). Those samples are correct content, but the rule above flagged them as leaked build paths, +failing `just validate` for a reason that has nothing to do with the build machine. + +## Decisions + +### 1. The Windows rule matches machine-identifying paths only + +A user profile (`C:\Users\\…`), `AppData`, a GitHub Actions workspace (`D:\a\\…`), or a CI +runner directory (`actions-runner`, `hostedtoolcache`, `runneradmin`). A bare drive-letter path is no +longer treated as a leak. + +### 2. Everything else is unchanged + +The macOS/Linux home-directory rules, the `.cache`/`node_modules` rules, and the secret patterns are +untouched. The check was narrowed, not disabled. + +### 3. One definition, two consumers + +The patterns live in `src/src/lib/build-paths.ts` and are imported by both `src/scripts/check-generated.ts` +and the dist test suite, so the script and the tests cannot drift apart. + +## Consequences + +- Documentation that shows an absolute path on a non-system drive (or a drive example such as + `D:\wslc-images`) builds without a false positive. +- A genuinely leaked path that is a bare drive root outside a known machine directory (for example + `E:\scratch\out`) would no longer be caught. This is accepted: the scan exists to catch the build + machine's own paths, and those always sit under a user profile, a runner workspace, `.cache`, or + `node_modules`. +- The security-relevant half of the scan (secret patterns) is unchanged. diff --git a/docs/decisions/0007-search-and-discovery.md b/docs/decisions/0007-search-and-discovery.md new file mode 100644 index 0000000..e0fcbe9 --- /dev/null +++ b/docs/decisions/0007-search-and-discovery.md @@ -0,0 +1,84 @@ +# ADR 0007 — Search and AI discovery + +- Status: accepted +- Date: 2026-03-10 + +## Context + +The site must be discoverable by search engines (Google, Bing, Qwant, Startpage +via its upstream indexes), by AI search crawlers (ChatGPT Search / `OAI-SearchBot`) +and by tooling that consumes `llms.txt`. It must also be able to notify IndexNow +participants after a deployment, and it must do all of this without a +hand-maintained list of URLs that can drift from the catalogue. + +## Decisions + +### 1. One discovery model drives everything + +A single `DiscoverableResource[]` (`src/src/lib/discovery/`) is derived from the +project catalogue (`loadProjects`), the aggregated documentation manifest +(`readDocsManifest`) and the release cache. The sitemap, `robots.txt`, +`discover.json`, the per-project `llms*.txt` bundles and the IndexNow submission +all read that one model, so they cannot disagree about what exists. + +### 2. The sitemap is generated by an Astro integration, not `@astrojs/sitemap` + +`@astrojs/sitemap` emits a single `sitemap-0.xml` and cannot express the +partitioned structure or content-derived `lastmod` values we need. The +`purview-discovery` integration (`src/config/discovery-integration.ts`) runs at +`astro:build:done`, receives Astro's complete route list (the same mechanism the +plugin uses, so no page is missed) and writes: + +- `/sitemap-index.xml` → `/sitemaps/pages.xml`, `/sitemaps/projects.xml`, + `/sitemaps/llms.xml`; +- `/robots.txt`; +- `/discover.json`; +- `/projects//llms.txt` and `/projects//llms-full.txt`. + +The IndexNow key verification file (`/.txt`) is written separately +by the deploy workflow from the `INDEXNOW_KEY` secret, so the build never sees +the key. + +### 3. `lastmod` is content-derived, never the build time + +A sitemap whose every URL claims to have changed on every deploy teaches +crawlers to ignore the signal. Each resource is dated from the source that +produced it: the last commit that touched the source file (one bulk `git log`, +indexed in memory), the project's latest release date, or the documentation +page's own `lastReviewed` date, with the release-cache retrieval time as a coarse +fallback. The deploy workflow checks out full history (`fetch-depth: 0`) so the +per-file dates are real. + +### 4. Per-project machine-readable content uses the llms.txt convention + +The emerging `llms.txt` convention is applied at the per-project scope: +`/projects//llms.txt` (a concise index) and `/projects//llms-full.txt` +(the full documentation corpus). This replaces the previous, non-standard +`/_llms-txt/.txt` paths. `llms.txt` is treated as a convention, not a +search-engine protocol; the canonical HTML pages remain the primary content. + +### 5. IndexNow notifies changed URLs only, after a successful deployment + +`discovery:indexnow` compares the previous deployment's public `discover.json` +(captured before the deploy and re-published as `/_discovery/previous.json`) with +the new one, and submits only added/modified URLs to the protocol-wide endpoint +`https://api.indexnow.org/indexnow`. It supports `--dry-run` (no network, no key) +and never logs the key. It runs in a separate job that `needs: deploy`, so a +failed deployment never announces new URLs, and an IndexNow outage never rolls +back a successful deployment. + +### 6. `discover.json` is Purview-specific + +`discover.json` is our own versioned discovery API, clearly documented as +non-standard. Internal deployment state (with per-URL hashes) is kept out of the +published output. + +## Consequences + +- Adding or removing a project changes the sitemap, `discover.json`, the LLM + bundles and the IndexNow diff automatically; there is no URL list to maintain. +- `just validate` / `bun run ci:build` fail on discovery drift via + `discovery:validate`. +- One-time operator steps remain: register the domain in Google Search Console + and Bing Webmaster Tools, and set the `INDEXNOW_KEY` secret. See + `docs/discovery.md`. \ No newline at end of file diff --git a/docs/discovery.md b/docs/discovery.md new file mode 100644 index 0000000..bebe611 --- /dev/null +++ b/docs/discovery.md @@ -0,0 +1,207 @@ +# Search & AI discovery + +This document explains how `purview.dev` is made discoverable — by search +engines, AI search crawlers, and tooling that reads machine-readable content — +and the one-time steps that remain manual. + +Everything below is generated at build time from the site's existing sources of +truth (the project catalogue, the aggregated documentation manifest and the +release cache). **There is no hand-maintained list of URLs.** + +## Three layers, deliberately kept apart + +| Layer | Examples | Status | +| --- | --- | --- | +| Established standards | sitemap XML, `robots.txt`, canonical URLs, Schema.org JSON-LD, IndexNow | Implemented as specified | +| Emerging convention | `llms.txt` / `llms-full.txt` (root and per project) | Useful, but not a search-engine protocol | +| Purview-specific | `discover.json`, the internal deployment state | Our own tooling, not an industry standard | + +## What is generated, and where + +| Artifact | Path | Produced by | +| --- | --- | --- | +| Sitemap index | `/sitemap-index.xml` | `purview-discovery` Astro integration | +| Child sitemaps | `/sitemaps/pages.xml`, `/sitemaps/projects.xml`, `/sitemaps/llms.xml` | integration | +| Crawler policy | `/robots.txt` | integration (`lib/discovery/robots.ts`) | +| Site map (JSON) | `/discover.json` | integration (`lib/discovery/manifest.ts`) | +| Root LLM content | `/llms.txt`, `/llms-small.txt`, `/llms-full.txt` | `starlight-llms-txt` | +| Per-project LLM content | `/projects//llms.txt`, `/projects//llms-full.txt` | integration (`lib/discovery/project-llms.ts`) | +| IndexNow key file | `/.txt` (written from the secret) | deploy workflow via `bun run discovery:key` | +| Internal deployment state | `src/.cache/discovery/state.json` (never published) | integration | + +The integration lives in `src/config/discovery-integration.ts`; the shared model +is in `src/src/lib/discovery/`. + +### Sitemap partitioning + +- **pages.xml** — hand-authored pages (`/`, `/about/`, `/docs/`, `/projects/`, + `/releases/`, `/use-cases/`). +- **projects.xml** — every project landing page (`/projects//`) and all of + its aggregated documentation (`/docs//…`), derived from the catalogue and + the docs manifest. +- **llms.xml** — the machine-readable resources (root and per project). + +Error pages (`404`, `500`), non-canonical aliases and non-indexable pages are +excluded. + +### Meaningful `lastmod` + +`lastmod` is **never** the build time. Each resource is dated from the source +that produced it (`src/src/lib/discovery/last-modified.ts`): + +1. the last commit that touched the source file — one bulk `git log` per build, + indexed in memory (a change to one project does not touch other pages); +2. the project's latest release date, for project pages; +3. the documentation page's own `lastReviewed` date, for docs pages; +4. the release-cache `retrievedAt` as a coarse fallback when `.git` is absent. + +The deploy workflow checks out full history (`fetch-depth: 0`) so (1) is +accurate in CI. + +## Commands + +```shell +bun run discovery:validate # validate the built artifacts in dist/ +bun run discovery:manifest # regenerate artifacts in dist/ from current caches +bun run discovery:key # write /.txt from the secret +bun run discovery:indexnow --dry-run # show what would be submitted (no network, no key) +bun run discovery:indexnow --dry-run --verbose # include the URL lists +``` + +`just` equivalents: `just discovery-validate`, `just discovery-manifest`, +`just discovery-key`, `just discovery-indexnow --dry-run`. + +`discovery:validate` runs as part of `just validate` and `bun run ci:build`, and +fails the build on: missing/malformed sitemaps, non-absolute or off-origin URLs, +duplicate canonicals, a `robots.txt` that does not advertise the sitemap, a +`discover.json` that references unknown projects, missing per-project LLM +resources, development URLs in production output, and a missing IndexNow key file +when the key is configured. + +## IndexNow + +IndexNow is the active post-deployment notification mechanism for Bing and other +participating engines. It notifies the protocol-wide endpoint +`https://api.indexnow.org/indexnow` — never a single engine. + +- **Key.** The key is a public verification value (8–128 chars of `[A-Za-z0-9-]`). + It is **supplied only by the GitHub repository secret `INDEXNOW_KEY`** and is + never part of the build. After the build, the deploy workflow runs + `bun run discovery:key`, which writes the required verification file to + `https://purview.dev/.txt` from the secret. The build environment and + build logs never see the key. +- **Changed URLs only.** `discovery:indexnow` compares the previous deployment's + `discover.json` with the new one and submits only **added** and **modified** + URLs. Deletions are reported but not submitted (IndexNow has no delete + semantics). The very first deployment submits everything, which is correct. +- **Dry run.** `--dry-run` performs no network call and needs no key. +- **Safety.** Every URL is checked against the configured canonical origin before + submission, and the key is never logged. + +### How change detection works on GitHub Pages + +The build job captures the currently deployed `discover.json` *before* building +and re-publishes it as `/_discovery/previous.json` inside the new deployment. +After a successful deploy, the `indexnow` job fetches that previous manifest and +the new `/discover.json`, diffs them, and submits the changes. No cross-job +artifact or persistent cache is required. + +### Failure semantics + +The `indexnow` job runs only after the `deploy` job succeeds (`needs: deploy`), +so a failed deployment never announces new URLs. A notification failure is +reported by the `indexnow` job but does **not** roll back or invalidate the +successful deployment: the site is live and correct either way. Retryable +responses (HTTP 429/5xx) are retried with backoff before a batch is reported as +failed. + +## One-time manual configuration + +These steps are performed once by an operator; routine updates are automatic. + +### Google + +1. Register `https://purview.dev/` as a **Domain property** in Google Search + Console, preferably using **DNS verification** (the domain is controlled + separately from the site). +2. Submit `https://purview.dev/sitemap-index.xml`. + +No Google Indexing API is used: it is not the mechanism for ordinary +documentation pages. Google updates through crawlable links, canonical URLs, the +sitemap and meaningful `lastmod`. + +### Bing + +1. Register/import `purview.dev` in **Bing Webmaster Tools**. +2. Verify the sitemap: `https://purview.dev/sitemap-index.xml`. +3. Set the `INDEXNOW_KEY` repository secret (see above). All routine updates are + then automated via IndexNow. + +### Qwant + +No routine submission is required. `QwantBot` is explicitly permitted in +`robots.txt` and discovers content through the sitemap and crawlable links. + +### Startpage + +No Purview-specific implementation. Startpage depends on upstream search +indexes, so its visibility follows the Google/Bing strategy. + +### OpenAI / ChatGPT + +`OAI-SearchBot` is explicitly permitted in `robots.txt`. HTML documentation and +the LLM resources are reachable through ordinary links. There is no supported +"LLM submission API", so none is invented; `llms.txt` is an additional +representation, not a guarantee of indexing. + +## Local development + +IndexNow never runs during normal local development: it requires an explicit +production command, and `--dry-run` needs no key and makes no network call. + +```shell +bun run build +bun run discovery:validate +bun run discovery:indexnow --dry-run +``` + +## Operational checklist + +Automated (nothing to do per deploy): + +- [x] Sitemap index + child sitemaps generated +- [x] `robots.txt` generated and references the sitemap +- [x] Root `llms.txt` / `llms-full.txt` generated +- [x] Per-project `llms.txt` / `llms-full.txt` generated +- [x] `discover.json` generated +- [x] IndexNow key file written from the secret by the deploy workflow +- [x] IndexNow notifies changed URLs after a successful deploy +- [x] `discovery:validate` enforced in CI + +One-time (operator): + +- [ ] Deploy the discovery implementation +- [ ] Verify `https://purview.dev/robots.txt` +- [ ] Verify `https://purview.dev/sitemap-index.xml` +- [ ] Verify `https://purview.dev/llms.txt` +- [ ] Verify `https://purview.dev/llms-full.txt` +- [ ] Verify `https://purview.dev/discover.json` +- [ ] Verify the IndexNow key endpoint `https://purview.dev/.txt` +- [ ] Register the Google Search Console Domain property (DNS verification) +- [ ] Submit the sitemap to Google +- [ ] Register/import the domain in Bing Webmaster Tools +- [ ] Submit the sitemap to Bing +- [ ] Set the `INDEXNOW_KEY` repository secret +- [ ] Verify the first IndexNow deployment +- [ ] Verify a representative project LLM resource + (`https://purview.dev/projects/zodsharp/llms.txt`) + +## Security + +- The IndexNow key is supplied only through the `INDEXNOW_KEY` secret and is + written into the built site by the deploy workflow (`bun run discovery:key`), + never by the build itself; it is never committed and never logged. +- `discovery:indexnow` refuses to submit any URL outside the configured canonical + origin. +- The build's secret/local-path scan (`check-generated`) also covers the + discovery artifacts. \ No newline at end of file diff --git a/justfile b/justfile index 44cbae3..95af0e0 100644 --- a/justfile +++ b/justfile @@ -87,6 +87,22 @@ check-generated: check-projects: bun run check:projects +# Validate the generated discovery artifacts (sitemaps, robots, llms, discover.json). +discovery-validate: + bun run discovery:validate + +# Regenerate discovery artifacts in dist/ from the current caches (no full build). +discovery-manifest: + bun run discovery:manifest + +# Notify IndexNow about changed URLs after a production deploy (pass --dry-run locally). +discovery-indexnow *ARGS: + bun run discovery:indexnow {{ARGS}} + +# Write the IndexNow key verification file from the INDEXNOW_KEY secret/config. +discovery-key: + bun run discovery:key + # Refresh release data from live GitHub/NuGet sources. fetch-releases: bun run fetch:releases @@ -100,5 +116,5 @@ clean: bun run clean # Authoritative validation: run everything needed to prove the site is safe to merge. -validate: format-check lint typecheck test check-assets check-projects build check-links check-generated test-dist +validate: format-check lint typecheck test check-assets check-projects build discovery-validate check-links check-generated test-dist @echo "Validation passed." diff --git a/package.json b/package.json index 7f43311..145200f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-dev", - "version": "0.3.5", + "version": "0.4.0", "private": true, "description": "Purview-Dev public website and unified documentation portal (workspace root).", "license": "MIT", @@ -14,10 +14,14 @@ "check:generated": "bun run --cwd src check:generated", "check:links": "bun run --cwd src check:links", "check:projects": "bun run --cwd src check:projects", - "ci:build": "bun run data:sync && bun run typecheck && bun run check:assets && bun run check:projects && bun run build && bun run check:links && bun run check:generated && bun run test:dist", + "ci:build": "bun run data:sync && bun run typecheck && bun run check:assets && bun run check:projects && bun run build && bun run discovery:validate && bun run check:links && bun run check:generated && bun run test:dist", "clean": "bun run --cwd src clean", "data:sync": "bun run --cwd src data:sync", "dev": "bun run --cwd src dev", + "discovery:indexnow": "bun run --cwd src discovery:indexnow", + "discovery:key": "bun run --cwd src discovery:key", + "discovery:manifest": "bun run --cwd src discovery:manifest", + "discovery:validate": "bun run --cwd src discovery:validate", "fetch:releases": "bun run --cwd src fetch:releases", "format": "bun run --cwd src format", "format:check": "bun run --cwd src format:check", diff --git a/src/.env.example b/src/.env.example index 3c91aa7..2c0a9a1 100644 --- a/src/.env.example +++ b/src/.env.example @@ -23,4 +23,12 @@ DATA_MODE=auto # When DATA_MODE=auto and live fetching fails, fall back to committed fixtures # instead of failing the build. Set to false to fail loudly instead. -DATA_FALLBACK_TO_FIXTURES=true \ No newline at end of file +DATA_FALLBACK_TO_FIXTURES=true + +# IndexNow key (the public verification value: 8-128 characters of [A-Za-z0-9-]). +# Supplied only by the GitHub repository secret INDEXNOW_KEY; the build never +# sees it. The deploy workflow writes the verification file (/.txt) from the +# secret with `bun run discovery:key`. Locally it is optional: `discovery:key` +# writes the file from this value, and `discovery:indexnow --dry-run` needs no +# key at all. See docs/discovery.md. +INDEXNOW_KEY= \ No newline at end of file diff --git a/src/astro.config.ts b/src/astro.config.ts index 75f2e1e..fdc6bfe 100644 --- a/src/astro.config.ts +++ b/src/astro.config.ts @@ -1,6 +1,7 @@ +import type { AstroIntegration } from 'astro'; + import { unified } from '@astrojs/markdown-remark'; import preact from '@astrojs/preact'; -import sitemap from '@astrojs/sitemap'; import starlight from '@astrojs/starlight'; import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'astro/config'; @@ -8,7 +9,9 @@ import starlightLinksValidator from 'starlight-links-validator'; import starlightLlmsTxt from 'starlight-llms-txt'; import starlightSidebarTopics from 'starlight-sidebar-topics'; +import discoveryIntegration from './config/discovery-integration'; import { remarkMermaid } from './config/remark-mermaid.mjs'; +import { projectLlmsUrl } from './src/lib/discovery/urls'; import { readDocsManifest } from './src/lib/docs/aggregate'; import { buildProjectItems } from './src/lib/docs/sidebar'; import { loadProjects } from './src/lib/manifest/load'; @@ -16,23 +19,26 @@ import { SITE, BRAND } from './src/lib/site'; import { displayStatus, type DisplayStatus } from './src/lib/status'; import { absoluteUrl } from './src/lib/urls'; +/** + * Starlight injects `@astrojs/sitemap` unless an integration with that exact + * name is already registered (see `@astrojs/starlight/dist/index.js`). We + * generate the sitemap ourselves — partitioned, with content-derived `lastmod` + * — so this no-op claims the name to stop Starlight's single-file sitemap from + * overwriting `/sitemap-index.xml`. `discoveryIntegration()` does the work. + */ +const suppressStarlightSitemap: AstroIntegration = { name: '@astrojs/sitemap', hooks: {} }; + const projects = loadProjects(); const docsManifest = readDocsManifest(); const docsProjects = projects.filter((project) => project.docs && project.status !== 'archived'); /** - * Per-project LLM bundles. `starlight-llms-txt` emits one file per `customSets` - * entry at `/_llms-txt/.txt`, where the slug is `github-slugger`'s slug of - * the set label. Every documented project's display name slugifies to exactly - * its project id, so `docs//**` maps to `/_llms-txt/.txt` - * and the UI links straight to that path. The build's link crawl (and the dist - * tests) fail if a future project name breaks that correspondence. + * Per-project machine-readable bundles are emitted by the discovery integration + * (see `config/discovery-integration.ts`) at `/projects//llms.txt` and + * `/projects//llms-full.txt`, following the llms.txt convention at the + * per-project scope. The `starlight-llms-txt` plugin below only produces the + * site-wide `/llms*.txt` files. */ -const docsSets = docsProjects.map((project) => ({ - label: project.name, - description: project.shortDescription, - paths: [`docs/${project.id}/**`], -})); /** * Sidebar badges for the two "not for production" states. The value comes from * the shared display status (ADR 0004), so the docs sidebar, the project page @@ -116,7 +122,8 @@ export default defineConfig({ }, integrations: [ preact(), - sitemap(), + suppressStarlightSitemap, + discoveryIntegration(), starlight({ title: SITE.fullName, description: SITE.description, @@ -202,11 +209,17 @@ export default defineConfig({ url: absoluteUrl('/releases/'), description: 'GitHub and NuGet release information.', }, + // Per-project machine-readable bundles, emitted by the discovery + // integration at /projects//llms.txt. + ...docsProjects.map((project) => ({ + label: project.name, + url: projectLlmsUrl(project.id), + description: project.shortDescription, + })), ], promote: ['index*', 'docs/*/index'], demote: [], exclude: ['dotnet-logging-source-generators/**'], - customSets: docsSets, minify: { note: true, tip: true, diff --git a/src/config/discovery-integration.ts b/src/config/discovery-integration.ts new file mode 100644 index 0000000..df98791 --- /dev/null +++ b/src/config/discovery-integration.ts @@ -0,0 +1,40 @@ +import type { AstroIntegration } from 'astro'; + +import { fileURLToPath } from 'node:url'; + +import { emitDiscoveryArtifacts } from '../src/lib/discovery/emit'; + +/** + * The discovery integration. + * + * Runs at `astro:build:done` — after every page has been written — and derives + * all discovery artifacts from the site's own sources of truth (the catalogue, + * the docs manifest, the release cache and the page list Astro just built). It + * replaces `@astrojs/sitemap` so the sitemap index can be partitioned + * (`pages.xml` / `projects.xml` / `llms.xml`) and carry meaningful `lastmod` + * values, and it emits everything from one `DiscoverableResource[]` model: + * + * - `/sitemap-index.xml` + `/sitemaps/{pages,projects,llms}.xml` + * - `/robots.txt` + * - `/discover.json` (Purview-specific; see `docs/discovery.md`) + * - `/projects//llms.txt` + `/projects//llms-full.txt` + * - `.cache/discovery/state.json` (internal, never published) + * + * The IndexNow key verification file (`/.txt`) is deliberately + * *not* written here: the build never sees the key. The deploy workflow supplies + * it from the `INDEXNOW_KEY` secret via `bun run discovery:key`. + */ +export default function discoveryIntegration(): AstroIntegration { + return { + name: 'purview-discovery', + hooks: { + 'astro:build:done': ({ dir, pages, logger }) => { + emitDiscoveryArtifacts({ + dist: fileURLToPath(dir), + pagePaths: pages.map((page) => page.pathname), + logger, + }); + }, + }, + }; +} diff --git a/src/package.json b/src/package.json index e0b810b..789665d 100644 --- a/src/package.json +++ b/src/package.json @@ -14,6 +14,10 @@ "clean": "bun run scripts/clean.ts", "data:sync": "bun run scripts/data-sync.ts", "dev": "astro dev", + "discovery:indexnow": "bun run scripts/discovery/indexnow.ts", + "discovery:key": "bun run scripts/discovery/indexnow-key.ts", + "discovery:manifest": "bun run scripts/discovery/manifest.ts", + "discovery:validate": "bun run scripts/discovery/validate.ts", "fetch:releases": "bun run scripts/fetch-releases.ts", "format": "oxfmt", "format:check": "oxfmt --check", @@ -31,19 +35,19 @@ }, "devDependencies": { "@astrojs/check": "0.9.10", - "@astrojs/markdown-remark": "^7.0.1", + "@astrojs/markdown-remark": "^7.3.1", "@astrojs/preact": "6.0.5", "@astrojs/sitemap": "3.7.4", - "@astrojs/starlight": "0.42.3", + "@astrojs/starlight": "0.42.5", "@astrojs/starlight-tailwind": "5.0.0", "@shikijs/themes": "4.4.3", "@tailwindcss/vite": "4.3.3", "@types/bun": "1.4.2", "@types/semver": "^7.8.0", - "astro": "7.3.4", + "astro": "7.3.5", "fast-glob": "3.3.3", "lefthook": "2.1.14", - "mermaid": "^11.16.1", + "mermaid": "^11.17.2", "oxfmt": "0.68.0", "oxlint": "1.83.0", "semver": "7.8.5", diff --git a/src/public/robots.txt b/src/public/robots.txt deleted file mode 100644 index dd6f753..0000000 --- a/src/public/robots.txt +++ /dev/null @@ -1,4 +0,0 @@ -User-agent: * -Allow: / - -Sitemap: https://purview.dev/sitemap-index.xml \ No newline at end of file diff --git a/src/scripts/check-generated.ts b/src/scripts/check-generated.ts index 90698e3..44805fd 100644 --- a/src/scripts/check-generated.ts +++ b/src/scripts/check-generated.ts @@ -3,6 +3,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { parse } from 'yaml'; +import { LOCAL_PATH_PATTERNS, SECRET_PATTERNS } from '../src/lib/build-paths'; import { DOCS_CACHE_DIR, DOCS_MANIFEST_SCHEMA, @@ -15,23 +16,6 @@ import { isReleaseCache, readReleaseCache } from '../src/lib/releases/cache'; const DIST = resolve('dist'); -const SECRET_PATTERNS = [ - /\bghp_[A-Za-z0-9]{36,}\b/, - /\bgho_[A-Za-z0-9]{36,}\b/, - /\bgithub_pat_[A-Za-z0-9_]{22,}\b/, - /\bAKIA[0-9A-Z]{16}\b/, - /-----BEGIN [A-Z ]*PRIVATE KEY-----/, - /\bNUGET__APIKEY\s*[:=]\s*\S+/i, -]; - -const LOCAL_PATH_PATTERNS = [ - /[A-Za-z]:\\[^\s"']+/, // Windows absolute paths - /\/Users\/[^\s"']+/, // macOS home paths (case-sensitive) - /\/home\/[A-Za-z0-9._-]+\//, // Linux home paths - /\.cache[/\\]/, - /node_modules[/\\]/, -]; - const errors: string[] = []; function fail(message: string): void { @@ -184,18 +168,19 @@ function requireDistFile(file: string): void { } /** - * Per-project `llms.txt` bundles are emitted by `starlight-llms-txt`'s - * `customSets` option, one file per documented project at - * `/_llms-txt/.txt`. The project pages, the project documentation - * overview, and the documentation portal all link to these paths, so a missing - * bundle is a broken link — and the link crawl only sees the pages that link it. + * Per-project `llms.txt` bundles are emitted by the discovery integration at + * `/projects//llms.txt` and `/projects//llms-full.txt`. + * The project pages, the project documentation overview, and the documentation + * portal all link to these paths, so a missing bundle is a broken link — and the + * link crawl only sees the pages that link it. */ function validateProjectLlmsBundles(): void { const projects = loadProjects().filter( (project) => project.docs && project.status !== 'archived', ); for (const project of projects) { - requireDistFile(`_llms-txt/${project.id}.txt`); + requireDistFile(`projects/${project.id}/llms.txt`); + requireDistFile(`projects/${project.id}/llms-full.txt`); } const entrypointPath = resolve(DIST, 'llms.txt'); @@ -204,7 +189,7 @@ function validateProjectLlmsBundles(): void { } const entrypoint = readFileSync(entrypointPath, 'utf8'); for (const project of projects) { - if (!entrypoint.includes(`/_llms-txt/${project.id}.txt`)) { + if (!entrypoint.includes(`/projects/${project.id}/llms.txt`)) { fail(`llms.txt does not link the per-project bundle for "${project.id}".`); } } @@ -250,6 +235,10 @@ async function run(): Promise { requireDistFile('llms-small.txt'); requireDistFile('llms-full.txt'); requireDistFile('sitemap-index.xml'); + requireDistFile('sitemaps/pages.xml'); + requireDistFile('sitemaps/projects.xml'); + requireDistFile('sitemaps/llms.xml'); + requireDistFile('discover.json'); requireDistFile('robots.txt'); validateProjectLlmsBundles(); diff --git a/src/scripts/discovery/indexnow-key.ts b/src/scripts/discovery/indexnow-key.ts new file mode 100644 index 0000000..6c65981 --- /dev/null +++ b/src/scripts/discovery/indexnow-key.ts @@ -0,0 +1,27 @@ +import { resolve } from 'node:path'; + +import { writeIndexNowKeyFile } from '../../src/lib/discovery/emit'; + +/** + * `bun run discovery:key` + * + * Write the IndexNow key verification file (`https://purview.dev/.txt`) + * into the built site from the `INDEXNOW_KEY` environment variable — in CI, the + * `INDEXNOW_KEY` repository secret. + * + * The build itself never sees the key: the deploy workflow runs the build + * without it, then runs this command from the secret. This keeps the key out of + * the build environment and out of any build logs, and makes the key file + * genuinely "supplied by the CI secret". See `docs/discovery.md`. + */ +const DIST = resolve('dist'); + +if (import.meta.main) { + const result = writeIndexNowKeyFile(DIST, process.env.INDEXNOW_KEY); + if (result.ok) { + console.log(`${result.message} (${result.file})`); + } else { + console.error(result.message); + process.exit(1); + } +} diff --git a/src/scripts/discovery/indexnow.ts b/src/scripts/discovery/indexnow.ts new file mode 100644 index 0000000..f8edacf --- /dev/null +++ b/src/scripts/discovery/indexnow.ts @@ -0,0 +1,166 @@ +import type { DiscoverableResource } from '../../src/lib/discovery/types'; + +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { + assertCanonicalOrigin, + buildIndexNowPayload, + changeCount, + chunk, + indexNowKeyLocation, + urlsToSubmit, + diffResources, +} from '../../src/lib/discovery/indexnow'; +import { submitIndexNowBatches } from '../../src/lib/discovery/indexnow-client'; +import { siteOrigin } from '../../src/lib/discovery/urls'; + +/** + * `bun run discovery:indexnow [--dry-run] [--verbose] [--current ] [--previous ]` + * + * Notify IndexNow participants (Bing and friends) about URLs that were added or + * materially changed since the previous deployment. Only *changed* URLs are + * submitted — never the whole site — and the IndexNow key is never logged. + * + * Runs only after a successful production deployment (the workflow's + * `indexnow` job `needs: deploy`). `--dry-run` performs no network call and + * needs no key, so it is safe in local development. + */ + +const DEFAULT_CURRENT = resolve('dist', 'discover.json'); + +interface ManifestShape { + resources?: DiscoverableResource[]; +} + +/** + * Read a discovery manifest from a local path or an `http(s)` URL. Returns null + * when the location is missing (e.g. the very first deployment has no previous + * manifest) or unreadable, so change detection treats everything as new. + */ +async function readResources(location: string): Promise { + let text: string | null = null; + if (/^https?:\/\//i.test(location)) { + try { + const response = await fetch(location, { + headers: { 'User-Agent': 'purview-dev-discovery' }, + }); + if (response.ok) { + text = await response.text(); + } + } catch { + text = null; + } + } else if (existsSync(location)) { + text = readFileSync(location, 'utf8'); + } + if (text === null) { + return null; + } + try { + const parsed = JSON.parse(text) as ManifestShape; + return Array.isArray(parsed.resources) ? parsed.resources : null; + } catch { + return null; + } +} + +function argValue(args: string[], flag: string): string | undefined { + const index = args.indexOf(flag); + return index >= 0 ? args[index + 1] : undefined; +} + +async function run(): Promise { + const args = process.argv.slice(2); + const dryRun = args.includes('--dry-run'); + const verbose = args.includes('--verbose'); + const currentFile = argValue(args, '--current') ?? DEFAULT_CURRENT; + const previousFile = argValue(args, '--previous'); + + const current = await readResources(currentFile); + if (!current) { + console.error(`No discovery manifest with resources found at ${currentFile}.`); + console.error('Build the site (or pass --current ) before notifying IndexNow.'); + return 1; + } + const previous = previousFile ? await readResources(previousFile) : null; + + const changes = diffResources(previous, current); + const urls = urlsToSubmit(changes); + + console.log('Discovery changes'); + console.log(''); + console.log(`Added: ${changes.added.length}`); + console.log(`Modified: ${changes.modified.length}`); + console.log(`Deleted: ${changes.deleted.length}`); + console.log(''); + + if (verbose) { + for (const [label, list] of [ + ['added', changes.added], + ['modified', changes.modified], + ['deleted', changes.deleted], + ] as const) { + if (list.length > 0) { + console.log(`${label}:`); + for (const url of list) { + console.log(` ${url}`); + } + } + } + console.log(''); + } + + if (urls.length === 0) { + console.log('Nothing to submit to IndexNow.'); + return 0; + } + + const origin = siteOrigin(); + // Refuse to submit anything outside the canonical origin, before any network. + assertCanonicalOrigin(urls, origin); + + if (dryRun) { + console.log(`Would submit ${urls.length} URLs to IndexNow.`); + return 0; + } + + const key = process.env.INDEXNOW_KEY?.trim(); + if (!key) { + console.error('INDEXNOW_KEY is not set; refusing to submit. Use --dry-run to inspect locally.'); + return 1; + } + + const host = new URL(origin).host; + const keyLocation = indexNowKeyLocation(origin, key); + const batches = chunk(urls).map((batch) => buildIndexNowPayload(host, key, keyLocation, batch)); + console.log(`Submitting ${urls.length} URLs in ${batches.length} batch(es) to IndexNow.`); + + let failures = 0; + const responses = await submitIndexNowBatches(batches, { + onResult: (index, response) => { + if (response.ok) { + console.log(` batch ${index + 1}/${batches.length}: HTTP ${response.status}`); + } else { + failures += 1; + console.error( + ` batch ${index + 1}/${batches.length}: HTTP ${response.status}` + + `${response.retryable ? ' (retryable, exhausted)' : ''}`, + ); + } + }, + }); + + const total = responses.length; + if (failures > 0) { + console.error(`IndexNow submission finished with ${failures}/${total} failed batch(es).`); + console.error('The site deployment itself succeeded; this notification step is the failure.'); + return 1; + } + console.log(`IndexNow accepted all ${total} batch(es) (${changeCount(changes)} changes).`); + return 0; +} + +if (import.meta.main) { + process.exit(await run()); +} diff --git a/src/scripts/discovery/manifest.ts b/src/scripts/discovery/manifest.ts new file mode 100644 index 0000000..81817d0 --- /dev/null +++ b/src/scripts/discovery/manifest.ts @@ -0,0 +1,49 @@ +import { glob } from 'fast-glob'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { emitDiscoveryArtifacts } from '../../src/lib/discovery/emit'; +import { normalizePagePath } from '../../src/lib/discovery/resources'; + +/** + * `bun run discovery:manifest` + * + * Regenerate the discovery artifacts (`sitemap-index.xml`, `sitemaps/*.xml`, + * `robots.txt`, `discover.json`, per-project `llms*.txt`) in an existing `dist/` + * from the current catalogue/docs/release caches. The Astro integration writes + * the same files during a build; this command exists for local inspection and + * for re-emitting after a data sync without a full rebuild. + * + * The `dist/` page list is reconstructed by scanning for directory-format + * `index.html` files (the same shape Astro emits). `fast-glob` is a CommonJS + * dependency, so this helper deliberately lives in the CLI rather than in the + * integration (which Vite loads). + */ +const DIST = resolve('dist'); + +async function collectPagePathsFromDist(dist: string): Promise { + const files = await glob('**/index.html', { cwd: dist, onlyFiles: true }); + const paths = files.map((file) => normalizePagePath(file.replace(/index\.html$/, ''))); + if (!paths.includes('')) { + paths.push(''); + } + return paths; +} + +if (import.meta.main) { + if (!existsSync(DIST)) { + console.error('dist/ does not exist. Run `bun run build` first.'); + process.exit(1); + } + const pagePaths = await collectPagePathsFromDist(DIST); + const { resources, partition } = emitDiscoveryArtifacts({ + dist: DIST, + pagePaths, + logger: { info: console.log, warn: console.warn }, + }); + console.log( + `Discovery manifest written: ${resources.length} resources ` + + `(${partition.pages.length} pages, ${partition.projects.length} project/documentation, ` + + `${partition.llms.length} llms).`, + ); +} diff --git a/src/scripts/discovery/validate.ts b/src/scripts/discovery/validate.ts new file mode 100644 index 0000000..bd5ff52 --- /dev/null +++ b/src/scripts/discovery/validate.ts @@ -0,0 +1,229 @@ +import type { DiscoverableResource } from '../../src/lib/discovery/types'; + +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { documentedProjects, loadDiscoveryContext } from '../../src/lib/discovery/resources'; +import { DISCOVERY_MANIFEST_SCHEMA_VERSION } from '../../src/lib/discovery/types'; +import { + DISCOVER_JSON_PATH, + ROBOTS_PATH, + SITEMAP_INDEX_PATH, + isCanonicalUrl, + projectLlmsFullUrl, + projectLlmsUrl, + projectPageUrl, + siteOrigin, + sitemapIndexUrl, +} from '../../src/lib/discovery/urls'; +import { loadProjects } from '../../src/lib/manifest/load'; + +/** + * `bun run discovery:validate` + * + * Post-build validation of the generated discovery artifacts. It fails CI for + * genuine discovery errors (missing files, malformed XML, non-absolute or + * off-origin URLs, duplicate canonicals, a robots.txt that does not advertise + * the sitemap, a discover.json that references projects that do not exist, or + * development URLs leaking into production output). + */ + +const DEV_HOST_PATTERN = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?/i; +const LOC_PATTERN = /([^<]+)<\/loc>/g; +const LASTMOD_PATTERN = /([^<]+)<\/lastmod>/g; + +function matches(content: string, pattern: RegExp): string[] { + return [...content.matchAll(pattern)].map((match) => match[1] ?? '').filter(Boolean); +} + +export interface DiscoveryValidationResult { + errors: string[]; +} + +export function validateDiscovery(dist = resolve('dist')): DiscoveryValidationResult { + const errors: string[] = []; + const fail = (message: string): void => { + errors.push(message); + }; + const origin = siteOrigin(); + + const readDist = (path: string): string | null => { + const file = resolve(dist, path.replace(/^\//, '')); + if (!existsSync(file) || !statSync(file).isFile()) { + return null; + } + return readFileSync(file, 'utf8'); + }; + const readDistByUrl = (url: string): string | null => + url.startsWith(origin) ? readDist(url.slice(origin.length)) : null; + const scanForDevUrls = (label: string, content: string): void => { + if (DEV_HOST_PATTERN.test(content)) { + fail(`${label} contains a development URL (localhost/loopback).`); + } + }; + + // --- sitemap index + children ------------------------------------------- + const indexXml = readDist(SITEMAP_INDEX_PATH); + if (!indexXml) { + fail(`Missing sitemap index: ${SITEMAP_INDEX_PATH}`); + } + const childLocs = indexXml ? matches(indexXml, LOC_PATTERN) : []; + if (indexXml && !/ document.'); + } + if (indexXml && childLocs.length < 3) { + fail(`sitemap-index.xml lists ${childLocs.length} child sitemap(s); expected at least 3.`); + } + + const seenCanonical = new Map(); + for (const child of childLocs) { + if (!isCanonicalUrl(child, origin)) { + fail(`Sitemap child is not on ${origin}: ${child}`); + continue; + } + const childXml = readDistByUrl(child); + if (!childXml) { + fail(`Sitemap index references a missing child sitemap: ${child}`); + continue; + } + if (!/ document: ${child}`); + } + scanForDevUrls(child, childXml); + + const urls = matches(childXml, LOC_PATTERN); + if (urls.length === 0) { + fail(`Child sitemap has no entries: ${child}`); + } + for (const url of urls) { + if (!isCanonicalUrl(url, origin)) { + fail(`Sitemap URL is not absolute or off-origin (${origin}): ${url}`); + } + const previous = seenCanonical.get(url); + if (previous) { + fail(`Duplicate canonical URL in sitemaps: ${url} (in ${previous} and ${child})`); + } else { + seenCanonical.set(url, child); + } + } + for (const lastmod of matches(childXml, LASTMOD_PATTERN)) { + if (Number.isNaN(Date.parse(lastmod))) { + fail(`Invalid value "${lastmod}" in ${child}`); + } + } + } + + // --- robots.txt ---------------------------------------------------------- + const robots = readDist(ROBOTS_PATH); + if (!robots) { + fail(`Missing robots.txt: ${ROBOTS_PATH}`); + } else { + scanForDevUrls('robots.txt', robots); + if (!robots.includes(`Sitemap: ${sitemapIndexUrl()}`)) { + fail(`robots.txt does not reference the production sitemap: ${sitemapIndexUrl()}`); + } + if (!/User-agent:\s*\*/i.test(robots)) { + fail('robots.txt does not contain a "User-agent: *" section.'); + } + if (!/User-agent:\s*OAI-SearchBot/i.test(robots)) { + fail('robots.txt does not explicitly permit OAI-SearchBot.'); + } + if (!/User-agent:\s*QwantBot/i.test(robots)) { + fail('robots.txt does not explicitly permit QwantBot.'); + } + } + + // --- root llms files ----------------------------------------------------- + // Note: the LLM bundles are documentation content and may legitimately show + // `localhost` examples, so the development-URL scan is applied only to the + // machine-readable discovery artifacts (sitemaps, robots, discover.json). + for (const path of ['/llms.txt', '/llms-full.txt']) { + const content = readDist(path); + if (content === null) { + fail(`Missing ${path}`); + } else if (content.trim().length === 0) { + fail(`${path} is empty.`); + } + } + + // --- discover.json ------------------------------------------------------- + const discover = readDist(DISCOVER_JSON_PATH); + if (!discover) { + fail(`Missing discover.json: ${DISCOVER_JSON_PATH}`); + } else { + scanForDevUrls('discover.json', discover); + try { + const manifest = JSON.parse(discover) as { + schemaVersion?: number; + resources?: DiscoverableResource[]; + projects?: { + slug?: string; + url?: string; + llms?: { summary?: string; full?: string } | null; + }[]; + }; + if (manifest.schemaVersion !== DISCOVERY_MANIFEST_SCHEMA_VERSION) { + fail('discover.json has an unexpected schemaVersion.'); + } + if (!Array.isArray(manifest.resources)) { + fail('discover.json is missing the resources array.'); + } + const projectIds = new Set(loadProjects().map((project) => project.id)); + for (const project of manifest.projects ?? []) { + const slug = project.slug ?? ''; + if (!projectIds.has(slug)) { + fail(`discover.json references an unknown project: ${slug}`); + continue; + } + if (project.url && project.url !== projectPageUrl(slug)) { + fail(`discover.json project URL does not match the canonical page: ${project.url}`); + } + for (const url of [project.llms?.summary, project.llms?.full]) { + if (url && !isCanonicalUrl(url, origin)) { + fail(`discover.json project LLM URL is off-origin: ${url}`); + } + } + } + } catch { + fail('discover.json is not valid JSON.'); + } + } + + // --- per-project llms resources ----------------------------------------- + for (const project of documentedProjects(loadDiscoveryContext())) { + for (const url of [projectLlmsUrl(project.id), projectLlmsFullUrl(project.id)]) { + const content = readDistByUrl(url); + if (content === null) { + fail(`Missing per-project LLM resource: ${url}`); + } else if (content.trim().length === 0) { + fail(`Per-project LLM resource is empty: ${url}`); + } + } + } + + // --- IndexNow key file (only when configured) --------------------------- + const indexNowKey = process.env.INDEXNOW_KEY?.trim(); + if (indexNowKey && /^[A-Za-z0-9-]{8,128}$/.test(indexNowKey)) { + const content = readDist(`/${indexNowKey}.txt`); + if (content === null) { + fail('INDEXNOW_KEY is configured but the key verification file is missing from the build.'); + } else if (content.trim() !== indexNowKey) { + fail('The IndexNow key verification file does not contain the configured key.'); + } + } + + return { errors }; +} + +if (import.meta.main) { + console.log('Validating discovery artifacts...'); + const { errors } = validateDiscovery(); + if (errors.length > 0) { + console.error(`Discovery validation failed with ${errors.length} issue(s):`); + for (const error of errors) { + console.error(` - ${error}`); + } + process.exit(1); + } + console.log('Discovery validation passed.'); +} diff --git a/src/src/components/GitHubIcon.astro b/src/src/components/GitHubIcon.astro new file mode 100644 index 0000000..ff477ae --- /dev/null +++ b/src/src/components/GitHubIcon.astro @@ -0,0 +1,27 @@ +--- +/** + * The GitHub mark, used to make a repository link unmistakably a GitHub link. + * Shared by the project page and the aggregated documentation page title so the + * two surfaces render the same mark. + */ +interface Props { + /** Icon size in pixels (applied to both width and height). */ + size?: number; + class?: string; +} + +const { size = 16, class: className } = Astro.props; +--- + + \ No newline at end of file diff --git a/src/src/components/StarOnGitHub.astro b/src/src/components/StarOnGitHub.astro new file mode 100644 index 0000000..3d20e54 --- /dev/null +++ b/src/src/components/StarOnGitHub.astro @@ -0,0 +1,46 @@ +--- +/** + * "Star on GitHub" call to action. + * + * Every project page and every aggregated documentation page links back to the + * repository that owns it; this is the shared control that invites a star. It + * is deliberately small (an icon, a label, and the live count when the release + * cache knows it) so it sits in an action row or a documentation meta line + * without dominating either. + */ +interface Props { + /** Repository in `owner/name` form. */ + repository: string; + /** Live star count from the release cache, when known. */ + stars?: number | null; + /** `button` matches the page action row; `inline` sits in a meta line. */ + variant?: 'button' | 'inline'; + class?: string; +} + +const { repository, stars = null, variant = 'button', class: className } = Astro.props; +const href = `https://github.com/${repository}`; +const showStars = typeof stars === 'number' && stars > 0; +--- + + + + Star on GitHub + {showStars && {stars}} + \ No newline at end of file diff --git a/src/src/components/starlight/Head.astro b/src/src/components/starlight/Head.astro index f75982a..286f368 100644 --- a/src/src/components/starlight/Head.astro +++ b/src/src/components/starlight/Head.astro @@ -1,8 +1,55 @@ --- import DefaultHead from '@astrojs/starlight/components/Head.astro'; +import { projectLlmsFullUrl } from '~/lib/discovery/urls'; +import { SITE } from '~/lib/site'; +import { absoluteUrl } from '~/lib/urls'; + +// Documentation pages are aggregated from the product repositories. Expose the +// per-project machine-readable bundle as an alternate representation and a +// `TechArticle` JSON-LD block. Only accurate values are emitted; the canonical +// HTML page remains the primary content. +const data = Astro.locals.starlightRoute?.entry?.data; +const sourceProject = typeof data?.sourceProject === 'string' ? data.sourceProject : undefined; +const hasMachineReadable = sourceProject !== undefined && data?.status !== 'archived'; +const alternateLinks = + hasMachineReadable && sourceProject ? + [ + { + href: projectLlmsFullUrl(sourceProject), + type: 'text/markdown', + title: `${data?.title ?? sourceProject} — machine-readable documentation`, + }, + ] + : []; + +const canonical = absoluteUrl(Astro.url.pathname === '' ? '/' : Astro.url.pathname); +const structuredData = + data ? + { + '@context': 'https://schema.org', + '@type': 'TechArticle', + headline: data.title, + ...(typeof data.description === 'string' ? { description: data.description } : {}), + url: canonical, + ...(typeof data.lastReviewed === 'string' ? { dateModified: data.lastReviewed } : {}), + inLanguage: 'en', + isPartOf: { '@type': 'WebSite', name: SITE.fullName, url: SITE.url }, + publisher: { '@type': 'Organization', name: SITE.orgName, url: SITE.url }, + } + : null; --- +{ + alternateLinks.map((link) => ( + + )) +} +{ + structuredData && ( + diff --git a/src/src/components/starlight/PageTitle.astro b/src/src/components/starlight/PageTitle.astro index 40cbfd0..9995311 100644 --- a/src/src/components/starlight/PageTitle.astro +++ b/src/src/components/starlight/PageTitle.astro @@ -1,7 +1,10 @@ --- +import GitHubIcon from '~/components/GitHubIcon.astro'; +import StarOnGitHub from '~/components/StarOnGitHub.astro'; import StatusBadge from '~/components/StatusBadge.astro'; import { isStale, STALE_AFTER_DAYS } from '~/lib/docs/staleness'; import { LLMS_LINK_ATTRS } from '~/lib/llms'; +import { getReleaseIndex } from '~/lib/releases/runtime'; import { displayStatus } from '~/lib/status'; import { withBase } from '~/lib/urls'; @@ -15,16 +18,19 @@ const experimental = data?.experimental === true; const status = data?.status ? displayStatus({ status: data.status, experimental }) : undefined; // Project overview pages surface the per-project `llms.txt` bundle emitted by -// the `customSets` option in `astro.config.ts`. Starlight resolves an index -// page to its directory id (`docs/`), while the content collection — -// and therefore the plugin's bundle paths — use `docs//index`, so both -// forms are accepted here. Other aggregated pages keep the heading lean and only -// link the global files from the site footer. +// the discovery integration at /projects//llms.txt. Other aggregated pages +// keep the heading lean and only link the global files from the site footer. const entryId = typeof entry?.id === 'string' ? entry.id : undefined; const isProjectOverview = entryId !== undefined && /^docs\/[^/]+(\/index)?$/.test(entryId); const sourceProject = typeof data?.sourceProject === 'string' ? data.sourceProject : undefined; +const sourceRepo = typeof data?.sourceRepo === 'string' ? data.sourceRepo : undefined; +// Live star count for the owning repository, when the release cache knows it. +const starCount = + sourceRepo === undefined ? + null + : (getReleaseIndex().data.repos[sourceRepo]?.stargazersCount ?? null); const llmsHref = - isProjectOverview && sourceProject ? withBase(`/_llms-txt/${sourceProject}.txt`) : null; + isProjectOverview && sourceProject ? withBase(`/projects/${sourceProject}/llms.txt`) : null; ---

{data?.title}

@@ -59,19 +65,27 @@ const llmsHref = )} { - data.sourceRepo && ( + sourceRepo && ( - {data.sourceRepo} + + {sourceRepo} ) } + { + sourceRepo && ( + + + + ) + } { tags.length > 0 && ( diff --git a/src/src/layouts/SiteLayout.astro b/src/src/layouts/SiteLayout.astro index 3a03b5d..a23b40b 100644 --- a/src/src/layouts/SiteLayout.astro +++ b/src/src/layouts/SiteLayout.astro @@ -13,6 +13,12 @@ interface Props { image?: string; /** Optional JSON-LD structured data object. */ structuredData?: Record; + /** + * Machine-readable representations of this page (e.g. the per-project + * `llms-full.txt` bundle), rendered as ``. These are a + * hint for tooling; every page also exposes a normal crawlable ``. + */ + alternateLinks?: { href: string; type: string; title?: string }[]; } const { @@ -20,6 +26,7 @@ const { description = SITE.description, image, structuredData, + alternateLinks = [], } = Astro.props; const canonical = absoluteUrl(Astro.url.pathname === '' ? '/' : Astro.url.pathname); const ogImage = absoluteUrl(image ?? '/og/default.png'); @@ -48,6 +55,11 @@ const ogImage = absoluteUrl(image ?? '/og/default.png'); + { + alternateLinks.map((alternate) => ( + + )) + } diff --git a/src/src/lib/build-paths.ts b/src/src/lib/build-paths.ts new file mode 100644 index 0000000..28af3ce --- /dev/null +++ b/src/src/lib/build-paths.ts @@ -0,0 +1,48 @@ +/** + * Patterns for the post-build safety scan: credentials that must never reach the + * published output, and build-machine paths that must not leak into it. + * + * `src/scripts/check-generated.ts` and the built-output test suite both import + * these, so the script and the tests cannot drift apart. See ADR 0006 for why + * the Windows rule is narrower than "any `X:\…`". + */ + +/** Credentials that must never appear in the built output. */ +export const SECRET_PATTERNS: readonly RegExp[] = [ + /\bghp_[A-Za-z0-9]{36,}\b/, + /\bgho_[A-Za-z0-9]{36,}\b/, + /\bgithub_pat_[A-Za-z0-9_]{22,}\b/, + /\bAKIA[0-9A-Z]{16}\b/, + /-----BEGIN [A-Z ]*PRIVATE KEY-----/, + /\bNUGET__APIKEY\s*[:=]\s*\S+/i, +]; + +/** + * Absolute paths that identify the machine that produced the build. + * + * A bare `X:\…` is deliberately **not** listed: documentation legitimately + * shows drive-letter examples (the Windows/WSL Containers guides use + * `D:\wslc-images`), so flagging them is a false positive rather than a leak + * (ADR 0006). The Windows rule therefore only matches a user profile, + * `AppData`, or a CI runner workspace. + */ +export const LOCAL_PATH_PATTERNS: readonly RegExp[] = [ + /[A-Za-z]:\\Users\\[^\\\s"']+/i, // C:\Users\\… + /[A-Za-z]:\\[^\s"']*\\AppData\\/i, // …\AppData\… + /[A-Za-z]:\\a\\[^\\\s"']+\\/i, // D:\a\\\… (GitHub Actions) + /[A-Za-z]:\\(?:actions-runner|hostedtoolcache|runneradmin)\\/i, // CI runner dirs + /\/Users\/[^\s"']+/, // macOS home paths (case-sensitive) + /\/home\/[A-Za-z0-9._-]+\//, // Linux home paths + /\.cache[/\\]/, + /node_modules[/\\]/, +]; + +/** The first pattern that matches `content`, or null when none does. */ +export function firstMatchingPattern(patterns: readonly RegExp[], content: string): RegExp | null { + for (const pattern of patterns) { + if (pattern.test(content)) { + return pattern; + } + } + return null; +} diff --git a/src/src/lib/discovery/emit.ts b/src/src/lib/discovery/emit.ts new file mode 100644 index 0000000..7554442 --- /dev/null +++ b/src/src/lib/discovery/emit.ts @@ -0,0 +1,164 @@ +import type { DiscoveryContext, SitemapPartition } from './resources'; +import type { DiscoverableResource } from './types'; + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + +import { DOCS_OUTPUT_DIR } from '../docs/aggregate'; +import { + buildDeploymentState, + buildDiscoveryManifest, + buildManifestProjects, + serializeJson, +} from './manifest'; +import { renderProjectLlms, renderProjectLlmsFull } from './project-llms'; +import { + buildResources, + documentedProjects, + loadDiscoveryContext, + normalizePagePath, + partitionResources, +} from './resources'; +import { renderRobotsTxt } from './robots'; +import { latestLastmod, renderSitemapIndex, renderUrlset } from './sitemap'; +import { SITEMAP_INDEX_PATH, sitemapLlmsUrl, sitemapPagesUrl, sitemapProjectsUrl } from './urls'; + +/** + * Emit every discovery artifact from the discovery model. Shared by the Astro + * integration (which passes Astro's real page list) and the `discovery:manifest` + * CLI (which reconstructs the page list from an existing `dist/`), so the two + * can never diverge. + */ + +export interface DiscoveryLogger { + info(message: string): void; + warn(message: string): void; +} + +export interface EmitOptions { + /** Absolute path to the build output directory. */ + dist: string; + /** Astro pathnames (e.g. `''`, `about/`, `docs/zodsharp/core-concepts/`). */ + pagePaths: readonly string[]; + logger?: DiscoveryLogger; + context?: DiscoveryContext; +} + +export interface EmitResult { + resources: DiscoverableResource[]; + partition: SitemapPartition; +} + +const silentLogger: DiscoveryLogger = { info: () => {}, warn: () => {} }; + +export function emitDiscoveryArtifacts(options: EmitOptions): EmitResult { + const logger = options.logger ?? silentLogger; + const ctx = options.context ?? loadDiscoveryContext(); + const generatedAt = new Date().toISOString(); + + const pagePaths = [...options.pagePaths]; + if (!pagePaths.some((path) => normalizePagePath(path) === '')) { + pagePaths.push(''); + } + + const resources = buildResources(pagePaths, ctx); + const partition = partitionResources(resources); + + const write = (relative: string, content: string): void => { + const target = join(options.dist, relative); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, content, 'utf8'); + }; + + write('sitemaps/pages.xml', renderUrlset(partition.pages)); + write('sitemaps/projects.xml', renderUrlset(partition.projects)); + write('sitemaps/llms.xml', renderUrlset(partition.llms)); + write( + SITEMAP_INDEX_PATH.replace(/^\//, ''), + renderSitemapIndex([ + { loc: sitemapPagesUrl(), lastmod: latestLastmod(partition.pages) }, + { loc: sitemapProjectsUrl(), lastmod: latestLastmod(partition.projects) }, + { loc: sitemapLlmsUrl(), lastmod: latestLastmod(partition.llms) }, + ]), + ); + + write('robots.txt', renderRobotsTxt()); + + const manifest = buildDiscoveryManifest({ + resources, + projects: buildManifestProjects(ctx.projects, ctx.releaseData, ctx.dataSource), + generatedAt, + }); + write('discover.json', serializeJson(manifest)); + + for (const project of documentedProjects(ctx)) { + const manifestEntry = ctx.docsManifest?.projects.find( + (entry) => entry.projectId === project.id, + ); + const projectPages = (manifestEntry?.pages ?? []).map((page) => ({ + slug: page.slug, + title: page.title, + })); + const readPage = (slug: string): string | null => { + const file = resolve(DOCS_OUTPUT_DIR, 'docs', project.id, `${slug}.md`); + return existsSync(file) ? readFileSync(file, 'utf8') : null; + }; + write(`projects/${project.id}/llms.txt`, renderProjectLlms(project, projectPages)); + write( + `projects/${project.id}/llms-full.txt`, + renderProjectLlmsFull(project, projectPages, readPage), + ); + } + + const stateFile = resolve('.cache', 'discovery', 'state.json'); + mkdirSync(dirname(stateFile), { recursive: true }); + writeFileSync(stateFile, serializeJson(buildDeploymentState(resources, generatedAt)), 'utf8'); + + logger.info( + `Discovery: ${resources.length} resources — ${partition.pages.length} pages, ` + + `${partition.projects.length} project/documentation, ${partition.llms.length} llms.`, + ); + + return { resources, partition }; +} + +/** Whether a string is a valid IndexNow key (protocol: 8-128 chars of [A-Za-z0-9-]). */ +export function isValidIndexNowKey(key: string): boolean { + return /^[A-Za-z0-9-]{8,128}$/.test(key); +} + +export interface IndexNowKeyResult { + ok: boolean; + /** Written file path, when a file was written. */ + file?: string; + message: string; +} + +/** + * Write the IndexNow key verification file (`/.txt`) into the built site. + * + * The key is a CI secret and is deliberately **not** part of the build: the + * build never sees it. The deploy workflow runs `bun run discovery:key` after + * the build, supplying `INDEXNOW_KEY` from the repository secret, and this + * writes the file IndexNow fetches to prove ownership of the domain. + */ +export function writeIndexNowKeyFile(dist: string, rawKey: string | undefined): IndexNowKeyResult { + const key = rawKey?.trim(); + if (!key) { + return { ok: false, message: 'INDEXNOW_KEY is not set; no key verification file was written.' }; + } + if (!isValidIndexNowKey(key)) { + return { + ok: false, + message: + 'INDEXNOW_KEY is not a valid IndexNow key (expected 8-128 characters of [A-Za-z0-9-]).', + }; + } + if (!existsSync(dist)) { + return { ok: false, message: `Build output directory does not exist: ${dist}` }; + } + const file = join(dist, `${key}.txt`); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, `${key}\n`, 'utf8'); + return { ok: true, file, message: 'Wrote the IndexNow key verification file.' }; +} diff --git a/src/src/lib/discovery/indexnow-client.ts b/src/src/lib/discovery/indexnow-client.ts new file mode 100644 index 0000000..63ca9a0 --- /dev/null +++ b/src/src/lib/discovery/indexnow-client.ts @@ -0,0 +1,75 @@ +import type { IndexNowPayload } from './types'; + +import { INDEXNOW_ENDPOINT } from './indexnow'; + +/** + * The single network call in the discovery subsystem. Kept apart from the pure + * `indexnow.ts` logic so tests never hit the internet and so the retry policy is + * easy to reason about. Retryable responses are 429 (rate limited) and 5xx; a + * 4xx other than 429 is a configuration error and is not retried. + */ + +export interface IndexNowResponse { + status: number; + ok: boolean; + /** Whether a later attempt could plausibly succeed. */ + retryable: boolean; + /** Response body, truncated; never contains the key (the key is not echoed). */ + body: string; +} + +export type FetchLike = (input: string, init?: RequestInit) => Promise; + +export async function submitIndexNow( + payload: IndexNowPayload, + options: { endpoint?: string; fetchImpl?: FetchLike } = {}, +): Promise { + const endpoint = options.endpoint ?? INDEXNOW_ENDPOINT; + const fetchImpl = options.fetchImpl ?? fetch; + const response = await fetchImpl(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify(payload), + }); + const body = await response.text().catch(() => ''); + return { + status: response.status, + ok: response.ok, + retryable: response.status === 429 || response.status >= 500, + body: body.slice(0, 500), + }; +} + +/** POST the batches with bounded exponential backoff on retryable failures. */ +export async function submitIndexNowBatches( + batches: readonly IndexNowPayload[], + options: { + endpoint?: string; + fetchImpl?: FetchLike; + attempts?: number; + delayMs?: number; + onResult?: (batch: number, response: IndexNowResponse) => void; + } = {}, +): Promise { + const attempts = options.attempts ?? 3; + const delayMs = options.delayMs ?? 2_000; + const results: IndexNowResponse[] = []; + + for (const [index, batch] of batches.entries()) { + let last: IndexNowResponse | null = null; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + last = await submitIndexNow(batch, options); + if (last.ok || !last.retryable) { + break; + } + if (attempt < attempts) { + await new Promise((resolve) => setTimeout(resolve, delayMs * attempt)); + } + } + if (last) { + results.push(last); + options.onResult?.(index, last); + } + } + return results; +} diff --git a/src/src/lib/discovery/indexnow.ts b/src/src/lib/discovery/indexnow.ts new file mode 100644 index 0000000..1d52af5 --- /dev/null +++ b/src/src/lib/discovery/indexnow.ts @@ -0,0 +1,130 @@ +import type { + DeploymentState, + DiscoverableResource, + DiscoveryChangeSet, + IndexNowPayload, +} from './types'; + +/** + * IndexNow payload construction and change detection. Pure functions only — + * the network call lives in `indexnow-client.ts` — so the batching, diffing and + * redaction rules can be tested without touching the internet. + */ + +/** Protocol-wide endpoint; not coupled to any single search engine. */ +export const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/indexnow'; + +/** The protocol's per-request URL limit; larger change sets are batched. */ +export const INDEXNOW_MAX_URLS = 10_000; + +/** + * Compare the previous deployment's resources with the current ones. A URL that + * is new is *added*; a URL whose `lastModified` moved is *modified*; a URL that + * disappeared is *deleted*. This is why meaningful `lastmod` values matter: they + * are also the notification signal. + */ +export function diffResources( + previous: readonly DiscoverableResource[] | null, + current: readonly DiscoverableResource[], +): DiscoveryChangeSet { + const before = new Map((previous ?? []).map((resource) => [resource.url, resource])); + const after = new Map(current.map((resource) => [resource.url, resource])); + + const added: string[] = []; + const modified: string[] = []; + for (const [url, resource] of after) { + const old = before.get(url); + if (!old) { + added.push(url); + } else if (old.lastModified !== resource.lastModified) { + modified.push(url); + } + } + const deleted = [...before.keys()].filter((url) => !after.has(url)); + + return { added: added.toSorted(), modified: modified.toSorted(), deleted: deleted.toSorted() }; +} + +/** Compare two internal deployment-state bodies by content hash. */ +export function diffDeploymentStates( + previous: DeploymentState | null, + current: DeploymentState, +): DiscoveryChangeSet { + const before = previous?.resources ?? {}; + const after = current.resources; + const added: string[] = []; + const modified: string[] = []; + for (const [url, entry] of Object.entries(after)) { + const old = before[url]; + if (!old) { + added.push(url); + } else if (old.hash !== entry.hash) { + modified.push(url); + } + } + const deleted = Object.keys(before).filter((url) => !(url in after)); + return { added: added.toSorted(), modified: modified.toSorted(), deleted: deleted.toSorted() }; +} + +/** + * URLs worth notifying engines about: newly created and materially modified + * pages. Deletions are not submitted — IndexNow has no delete semantics — but + * they are reported so the operator can see them. + */ +export function urlsToSubmit(changeSet: DiscoveryChangeSet): string[] { + return [...changeSet.added, ...changeSet.modified].toSorted(); +} + +/** Total number of changes, for the dry-run summary. */ +export function changeCount(changeSet: DiscoveryChangeSet): number { + return changeSet.added.length + changeSet.modified.length + changeSet.deleted.length; +} + +/** Split a list into protocol-sized batches. */ +export function chunk(items: readonly T[], size: number = INDEXNOW_MAX_URLS): T[][] { + if (size < 1) { + throw new Error('chunk size must be at least 1'); + } + const batches: T[][] = []; + for (let index = 0; index < items.length; index += size) { + batches.push(items.slice(index, index + size)); + } + return batches; +} + +/** The public location of the IndexNow key verification file. */ +export function indexNowKeyLocation(origin: string, key: string): string { + return `${origin.replace(/\/+$/, '')}/${key}.txt`; +} + +/** + * Guard against submitting arbitrary URLs: every URL must belong to the + * configured canonical origin before anything is sent over the network. + */ +export function assertCanonicalOrigin(urls: readonly string[], origin: string): void { + const normalized = origin.replace(/\/+$/, ''); + const offenders = urls.filter((url) => !(url === normalized || url.startsWith(`${normalized}/`))); + if (offenders.length > 0) { + throw new Error( + `Refusing to submit URLs outside ${normalized} (${offenders.length} offending, e.g. ${offenders[0]})`, + ); + } +} + +/** Build the request body for one batch. */ +export function buildIndexNowPayload( + host: string, + key: string, + keyLocation: string, + urls: readonly string[], +): IndexNowPayload { + return { host, key, keyLocation, urlList: [...urls] }; +} + +/** Replace a secret in arbitrary text (log lines, error bodies) before printing. */ +export function redactSecret(text: string, secret: string | undefined): string { + if (!secret) { + return text; + } + return text.split(secret).join('[redacted]'); +} diff --git a/src/src/lib/discovery/last-modified.ts b/src/src/lib/discovery/last-modified.ts new file mode 100644 index 0000000..9d436d2 --- /dev/null +++ b/src/src/lib/discovery/last-modified.ts @@ -0,0 +1,119 @@ +import { execFileSync } from 'node:child_process'; + +import { findWorkspaceRoot } from '../site-version'; + +/** + * Content-derived modification dates for sitemap `lastmod`. + * + * A sitemap whose every `lastmod` is the build time is worse than no `lastmod`: + * it tells Google that all 150+ URLs changed on every deploy, which trains the + * crawler to ignore the signal. Instead each resource is dated from the source + * that produced it: + * + * 1. the last commit that touched the source file (one bulk `git log`, indexed + * in memory, so a whole build runs Git exactly once); + * 2. the project's latest release date (from the release cache); + * 3. the aggregated documentation's own review date (`lastReviewed`); + * 4. the release-cache `retrievedAt` as a coarse fallback when `.git` is absent + * (a shallow CI clone) or the file is untracked. + * + * See `docs/discovery.md` for the strategy and its trade-offs. + */ + +/** Repo-relative file path -> last committer date (ISO 8601). */ +export type FileDateIndex = Map; + +const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/; + +let gitIndexCache: FileDateIndex | null | undefined; + +/** + * Parse `git log --name-only --pretty=format:%cI` output into a path->date map. + * The first commit that touched a path wins, because `git log` walks newest + * first, so the earliest entry is the most recent change. + */ +export function parseGitLogDates(output: string): FileDateIndex { + const index: FileDateIndex = new Map(); + let commit: string | null = null; + for (const rawLine of output.split('\n')) { + const line = rawLine.trim(); + if (line === '') { + continue; + } + if (ISO_DATETIME.test(line)) { + commit = line; + continue; + } + if (commit !== null && !index.has(line)) { + index.set(line, commit); + } + } + return index; +} + +/** + * Build the path->date index from a single `git log` invocation. Cached for the + * process lifetime; returns null when Git or the repository is unavailable. + */ +export function readGitFileDates(root?: string): FileDateIndex | null { + if (gitIndexCache !== undefined) { + return gitIndexCache; + } + let repoRoot = root; + if (!repoRoot) { + try { + repoRoot = findWorkspaceRoot(); + } catch { + gitIndexCache = null; + return null; + } + } + try { + const output = execFileSync( + 'git', + ['log', '--no-merges', '--name-only', '--pretty=format:%cI'], + { + cwd: repoRoot, + encoding: 'utf8', + maxBuffer: 128 * 1024 * 1024, + }, + ); + gitIndexCache = parseGitLogDates(output); + return gitIndexCache; + } catch { + gitIndexCache = null; + return null; + } +} + +/** The most recent of a set of timestamps, ignoring nulls and unparseable values. */ +export function latestTimestamp( + values: readonly (string | null | undefined)[], +): string | undefined { + let best: string | undefined; + let bestTime = Number.NEGATIVE_INFINITY; + for (const value of values) { + if (!value) { + continue; + } + const time = Date.parse(value); + if (Number.isNaN(time)) { + continue; + } + if (time > bestTime) { + bestTime = time; + best = value; + } + } + return best; +} + +/** The first parseable timestamp, in priority order. */ +export function firstTimestamp(values: readonly (string | null | undefined)[]): string | undefined { + for (const value of values) { + if (value && !Number.isNaN(Date.parse(value))) { + return value; + } + } + return undefined; +} diff --git a/src/src/lib/discovery/manifest.ts b/src/src/lib/discovery/manifest.ts new file mode 100644 index 0000000..f966b1b --- /dev/null +++ b/src/src/lib/discovery/manifest.ts @@ -0,0 +1,134 @@ +import type { ResolvedProject } from '../manifest/load'; +import type { DataSource } from '../releases/transform'; +import type { ReleaseCacheData } from '../releases/types'; +import type { + DeploymentState, + DiscoverableResource, + DiscoveryManifest, + DiscoveryManifestProject, +} from './types'; + +import { createHash } from 'node:crypto'; + +import { projectReleaseSummary, projectVersionRollup } from '../releases/transform'; +import { SITE } from '../site'; +import { nugetPackageUrl } from '../urls'; +import { DEPLOYMENT_STATE_SCHEMA_VERSION, DISCOVERY_MANIFEST_SCHEMA_VERSION } from './types'; +import { + canonical, + docsUrl, + projectLlmsFullUrl, + projectLlmsUrl, + projectPageUrl, + rootLlmsFullUrl, + rootLlmsSmallUrl, + rootLlmsUrl, + sitemapIndexUrl, + sitemapLlmsUrl, + sitemapPagesUrl, + sitemapProjectsUrl, +} from './urls'; + +/** + * The public `discover.json` and the internal deployment-state file. + * + * `discover.json` is a **Purview-specific** discovery API — a stable, versioned + * map of the site for tooling that would rather read one JSON document than + * crawl. It is deliberately *not* presented as a standard. The deployment state + * carries a hash per URL for diagnostics and is never published. + */ + +/** Build the project list for the discovery manifest. */ +export function buildManifestProjects( + projects: readonly ResolvedProject[], + releaseData: ReleaseCacheData, + dataSource: DataSource, +): DiscoveryManifestProject[] { + return projects + .toSorted((a, b) => a.order - b.order || a.name.localeCompare(b.name)) + .map((project) => { + const summary = projectReleaseSummary(project, releaseData, dataSource); + const rollup = projectVersionRollup(project, summary); + const primary = project.packages.find((pkg) => pkg.primary) ?? project.packages[0]; + const hasDocs = project.docs !== undefined && project.status !== 'archived'; + return { + name: project.name, + slug: project.id, + url: projectPageUrl(project.id), + description: project.shortDescription, + repository: project.repository, + repositoryUrl: project.sourceUrl, + docs: project.docs ? docsUrl(project.id) : null, + category: project.category, + status: project.status, + experimental: project.experimental, + package: primary ? { id: primary.id, url: nugetPackageUrl(primary.id) } : null, + latestVersion: rollup.prereleaseVersion ?? rollup.stableVersion, + llms: hasDocs + ? { summary: projectLlmsUrl(project.id), full: projectLlmsFullUrl(project.id) } + : null, + }; + }); +} + +export function buildDiscoveryManifest(options: { + resources: readonly DiscoverableResource[]; + projects: readonly DiscoveryManifestProject[]; + generatedAt: string; +}): DiscoveryManifest { + const { resources, projects, generatedAt } = options; + return { + schemaVersion: DISCOVERY_MANIFEST_SCHEMA_VERSION, + name: SITE.orgName, + url: canonical('/'), + generatedAt, + llms: { summary: rootLlmsUrl(), small: rootLlmsSmallUrl(), full: rootLlmsFullUrl() }, + sitemaps: { + index: sitemapIndexUrl(), + pages: sitemapPagesUrl(), + projects: sitemapProjectsUrl(), + llms: sitemapLlmsUrl(), + }, + counts: { + resources: resources.length, + projects: projects.length, + documentation: resources.filter((resource) => resource.kind === 'documentation').length, + }, + resources: [...resources], + projects: [...projects], + }; +} + +/** A stable short hash of a resource's identity for change detection/diagnostics. */ +export function hashResource(resource: DiscoverableResource): string { + return createHash('sha256') + .update( + `${resource.url}\n${resource.kind}\n${resource.lastModified ?? ''}\n${resource.indexable}`, + ) + .digest('hex') + .slice(0, 16); +} + +/** Build the internal (never published) deployment state. */ +export function buildDeploymentState( + resources: readonly DiscoverableResource[], + generatedAt: string, +): DeploymentState { + const state: DeploymentState = { + schemaVersion: DEPLOYMENT_STATE_SCHEMA_VERSION, + generatedAt, + resources: {}, + }; + for (const resource of resources) { + state.resources[resource.url] = { + lastModified: resource.lastModified, + hash: hashResource(resource), + }; + } + return state; +} + +/** Deterministic JSON serialisation with a trailing newline. */ +export function serializeJson(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} diff --git a/src/src/lib/discovery/project-llms.ts b/src/src/lib/discovery/project-llms.ts new file mode 100644 index 0000000..2bc495b --- /dev/null +++ b/src/src/lib/discovery/project-llms.ts @@ -0,0 +1,97 @@ +import type { ResolvedProject } from '../manifest/load'; + +import { nugetPackageUrl } from '../urls'; +import { docsUrl, projectLlmsFullUrl, projectPageUrl } from './urls'; + +/** + * Per-project machine-readable resources, following the emerging `llms.txt` + * convention at the natural project scope: + * + * - `/projects//llms.txt` — a concise index: what the project is and + * where its repository, package and documentation live. + * - `/projects//llms-full.txt` — the project's aggregated documentation + * as one Markdown corpus. + * + * These are *additional representations* of the canonical HTML documentation, + * not a search-engine protocol, and they are generated only for projects that + * actually have aggregated documentation. + */ + +export interface ProjectLlmsPage { + slug: string; + title: string; +} + +/** Strip the leading YAML frontmatter block from generated Markdown. */ +export function stripFrontmatter(markdown: string): string { + return markdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trim(); +} + +function resourceLines(project: ResolvedProject): string[] { + const primary = project.packages.find((pkg) => pkg.primary) ?? project.packages[0]; + const lines = [ + `- Repository: ${project.sourceUrl}`, + `- Project page: ${projectPageUrl(project.id)}`, + `- Documentation: ${docsUrl(project.id)}`, + `- Full machine-readable content: ${projectLlmsFullUrl(project.id)}`, + ]; + if (primary) { + lines.splice(1, 0, `- Package: ${nugetPackageUrl(primary.id)}`); + } + return lines; +} + +/** The concise `/projects//llms.txt` entry point. */ +export function renderProjectLlms( + project: ResolvedProject, + pages: readonly ProjectLlmsPage[], +): string { + const lines: string[] = [ + `# ${project.name}`, + '', + `> ${project.shortDescription}`, + '', + project.description, + '', + '## Resources', + '', + ...resourceLines(project), + ]; + + if (pages.length > 0) { + lines.push('', '## Documentation pages', ''); + for (const page of pages) { + lines.push(`- ${page.title}: ${docsUrl(project.id, page.slug)}`); + } + } + + lines.push(''); + return lines.join('\n'); +} + +/** The full `/projects//llms-full.txt` corpus, built from the docs mirror. */ +export function renderProjectLlmsFull( + project: ResolvedProject, + pages: readonly ProjectLlmsPage[], + readPage: (slug: string) => string | null, +): string { + const header: string[] = [ + `# ${project.name}`, + '', + `> ${project.shortDescription}`, + '', + project.description, + '', + ...resourceLines(project), + '', + ]; + + const body = pages + .map((page) => readPage(page.slug)) + .filter((content): content is string => Boolean(content)) + .map((content) => stripFrontmatter(content)) + .filter((content) => content.length > 0) + .join('\n\n---\n\n'); + + return `${[...header, body, ''].join('\n')}`; +} diff --git a/src/src/lib/discovery/resources.ts b/src/src/lib/discovery/resources.ts new file mode 100644 index 0000000..c8c3018 --- /dev/null +++ b/src/src/lib/discovery/resources.ts @@ -0,0 +1,316 @@ +import type { DocsManifest } from '../docs/aggregate'; +import type { ResolvedProject } from '../manifest/load'; +import type { DataSource } from '../releases/transform'; +import type { ReleaseCacheData } from '../releases/types'; +import type { DiscoverableKind, DiscoverableResource } from './types'; + +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { DOCS_OUTPUT_DIR, readDocsManifest } from '../docs/aggregate'; +import { loadProjects } from '../manifest/load'; +import { getReleaseIndex } from '../releases/runtime'; +import { + firstTimestamp, + latestTimestamp, + readGitFileDates, + type FileDateIndex, +} from './last-modified'; +import { + canonical, + projectLlmsFullUrl, + projectLlmsUrl, + rootLlmsFullUrl, + rootLlmsSmallUrl, + rootLlmsUrl, +} from './urls'; + +/** + * Building the `DiscoverableResource[]` from the site's existing sources of + * truth: the project catalogue (`loadProjects`), the aggregated documentation + * manifest (`readDocsManifest`) and the release cache (`getReleaseIndex`). No + * hand-maintained URL list exists anywhere. + */ + +/** + * Repo-relative source file for each hand-authored page, so its `lastmod` can be + * the commit that last touched it. Keys are normalised route paths (no leading + * or trailing slash). + */ +export const PAGE_SOURCES: Readonly> = { + '': 'src/src/pages/index.astro', + about: 'src/src/pages/about.astro', + docs: 'src/src/pages/docs/index.astro', + projects: 'src/src/pages/projects/index.astro', + releases: 'src/src/pages/releases/index.astro', + 'use-cases': 'src/src/pages/use-cases/index.astro', +}; + +/** Repo-relative catalogue file for a project id. */ +export function projectManifestFile(id: string): string { + return `src/src/data/projects/${id}.yml`; +} + +export interface DiscoveryContext { + projects: ResolvedProject[]; + docsManifest: DocsManifest | null; + releaseData: ReleaseCacheData; + /** Which source the release data came from (live/cache/fixture). */ + dataSource: DataSource; + /** Path -> last commit date; empty when Git is unavailable. */ + fileDates: FileDateIndex; + /** Coarse fallback date (release cache retrieval time). */ + fallbackDate: string | null; + /** `"/"` -> the documentation page's own `lastReviewed` date. */ + docsLastReviewed: Map; +} + +const LAST_REVIEWED = /^lastReviewed:\s*"?(\d{4}-\d{2}-\d{2})"?/m; + +/** + * Read each aggregated page's `lastReviewed` date from the generated docs + * mirror's front matter. The mirror is present whenever the site builds, so this + * gives documentation pages a real `lastmod` without changing the docs manifest + * schema (which would force a full re-sync of the gitignored mirror). + */ +export function readDocsLastReviewed(mirrorRoot = DOCS_OUTPUT_DIR): Map { + const result = new Map(); + const docsRoot = join(mirrorRoot, 'docs'); + if (!existsSync(docsRoot)) { + return result; + } + for (const projectId of readdirSync(docsRoot)) { + const projectDir = join(docsRoot, projectId); + let files: string[]; + try { + files = readdirSync(projectDir); + } catch { + continue; + } + for (const file of files) { + if (!file.endsWith('.md')) { + continue; + } + const content = readFileSync(join(projectDir, file), 'utf8'); + const match = LAST_REVIEWED.exec(content); + if (match?.[1]) { + result.set(`${projectId}/${file.replace(/\.md$/, '')}`, match[1]); + } + } + } + return result; +} + +/** Build a discovery context from the real catalogue, docs manifest and cache. */ +export function loadDiscoveryContext(): DiscoveryContext { + const docsManifest = readDocsManifest(); + const releaseIndex = getReleaseIndex(); + return { + projects: loadProjects(), + docsManifest, + releaseData: releaseIndex.data, + dataSource: releaseIndex.source, + fileDates: readGitFileDates() ?? new Map(), + fallbackDate: releaseIndex.data.retrievedAt || null, + docsLastReviewed: readDocsLastReviewed(), + }; +} + +/** Remove a leading/trailing slash and any base prefix from an Astro pathname. */ +export function normalizePagePath(pathname: string, base = '/'): string { + let path = pathname.trim(); + if (base && base !== '/') { + const prefix = base.replace(/\/+$/, ''); + if (path === prefix) { + path = ''; + } else if (path.startsWith(`${prefix}/`)) { + path = path.slice(prefix.length); + } + } + path = path.replace(/^\/+/, '').replace(/\/+$/, ''); + path = path.replace(/\/?index\.html$/, ''); + return path; +} + +/** Classify a normalised route path into a resource kind. */ +export function classifyPath( + normalized: string, + projectIds: ReadonlySet, +): { kind: DiscoverableKind; projectId?: string; docsSlug?: string } { + if (normalized === '') { + return { kind: 'page' }; + } + const parts = normalized.split('/').filter(Boolean); + const head = parts[0]; + if (head === 'projects' && parts.length === 2 && projectIds.has(parts[1] ?? '')) { + return { kind: 'project', projectId: parts[1] }; + } + if (head === 'docs' && parts.length >= 2 && projectIds.has(parts[1] ?? '')) { + const slug = parts.slice(2).join('/') || 'index'; + return { kind: 'documentation', projectId: parts[1], docsSlug: slug }; + } + return { kind: 'page' }; +} + +/** The most recent of a set of timestamps. */ +function preferTimestamp( + primary: readonly (string | null | undefined)[], + fallback: string | null, +): string | undefined { + return latestTimestamp(primary) ?? latestTimestamp([fallback]); +} + +/** Latest GitHub release date for a repository, when the cache knows it. */ +function latestReleaseDate(data: ReleaseCacheData, repository: string | undefined): string | null { + if (!repository) { + return null; + } + return ( + latestTimestamp((data.releases[repository] ?? []).map((release) => release.publishedAt)) ?? null + ); +} + +/** `lastmod` for a project's HTML page: manifest commit or latest release. */ +export function projectLastModified(id: string, ctx: DiscoveryContext): string | undefined { + const project = ctx.projects.find((candidate) => candidate.id === id); + return preferTimestamp( + [ + ctx.fileDates.get(projectManifestFile(id)), + latestReleaseDate(ctx.releaseData, project?.repository), + ], + ctx.fallbackDate, + ); +} + +/** `lastmod` for an aggregated documentation page: the review date wins. */ +export function documentationLastModified( + projectId: string, + slug: string, + ctx: DiscoveryContext, +): string | undefined { + return firstTimestamp([ + ctx.docsLastReviewed.get(`${projectId}/${slug}`), + ctx.fileDates.get(projectManifestFile(projectId)), + ctx.fallbackDate, + ]); +} + +/** `lastmod` for the machine-readable bundles, which follow the docs sync. */ +function llmsLastModified(ctx: DiscoveryContext): string | undefined { + return firstTimestamp([ctx.docsManifest?.syncedAt, ctx.fallbackDate]); +} + +/** Projects with aggregated documentation and a non-archived lifecycle. */ +export function documentedProjects(ctx: DiscoveryContext): ResolvedProject[] { + return ctx.projects.filter((project) => project.docs && project.status !== 'archived'); +} + +/** De-duplicate by URL, keeping the first occurrence, sorted by URL. */ +export function dedupeByUrl(resources: readonly DiscoverableResource[]): DiscoverableResource[] { + const byUrl = new Map(); + for (const resource of resources) { + if (!byUrl.has(resource.url)) { + byUrl.set(resource.url, resource); + } + } + return [...byUrl.values()].toSorted((a, b) => a.url.localeCompare(b.url)); +} + +/** + * Derive every discoverable resource from the page list Astro built plus the + * catalogue and docs/release metadata. Deterministic and de-duplicated by URL. + */ +export function buildResources( + pagePaths: readonly string[], + ctx: DiscoveryContext, +): DiscoverableResource[] { + const projectIds = new Set(ctx.projects.map((project) => project.id)); + const list: DiscoverableResource[] = []; + + for (const raw of pagePaths) { + const normalized = normalizePagePath(raw); + if (normalized === '404' || normalized === '500') { + continue; + } + const classified = classifyPath(normalized, projectIds); + let lastModified: string | undefined; + if (classified.kind === 'project' && classified.projectId) { + lastModified = projectLastModified(classified.projectId, ctx); + } else if (classified.kind === 'documentation' && classified.projectId) { + lastModified = documentationLastModified( + classified.projectId, + classified.docsSlug ?? 'index', + ctx, + ); + } else { + lastModified = firstTimestamp([ + ctx.fileDates.get(PAGE_SOURCES[normalized] ?? ''), + ctx.fallbackDate, + ]); + } + + list.push({ + url: canonical(normalized === '' ? '/' : `/${normalized}/`), + kind: classified.kind, + lastModified, + indexable: true, + ...(classified.projectId ? { projectId: classified.projectId } : {}), + }); + } + + const docsSyncedAt = llmsLastModified(ctx); + list.push({ url: rootLlmsUrl(), kind: 'llms', lastModified: docsSyncedAt, indexable: true }); + list.push({ + url: rootLlmsSmallUrl(), + kind: 'llms-small', + lastModified: docsSyncedAt, + indexable: true, + }); + list.push({ + url: rootLlmsFullUrl(), + kind: 'llms-full', + lastModified: docsSyncedAt, + indexable: true, + }); + + for (const project of documentedProjects(ctx)) { + const lastModified = documentationLastModified(project.id, 'index', ctx) ?? docsSyncedAt; + list.push({ + url: projectLlmsUrl(project.id), + kind: 'llms', + lastModified, + indexable: true, + projectId: project.id, + }); + list.push({ + url: projectLlmsFullUrl(project.id), + kind: 'llms-full', + lastModified, + indexable: true, + projectId: project.id, + }); + } + + return dedupeByUrl(list); +} + +export interface SitemapPartition { + pages: DiscoverableResource[]; + projects: DiscoverableResource[]; + llms: DiscoverableResource[]; +} + +/** Split indexable resources into the three logical sitemaps. */ +export function partitionResources(resources: readonly DiscoverableResource[]): SitemapPartition { + const indexable = resources.filter((resource) => resource.indexable); + return { + pages: indexable.filter((resource) => resource.kind === 'page' || resource.kind === 'other'), + projects: indexable.filter( + (resource) => resource.kind === 'project' || resource.kind === 'documentation', + ), + llms: indexable.filter( + (resource) => + resource.kind === 'llms' || resource.kind === 'llms-full' || resource.kind === 'llms-small', + ), + }; +} diff --git a/src/src/lib/discovery/robots.ts b/src/src/lib/discovery/robots.ts new file mode 100644 index 0000000..e3fc2d0 --- /dev/null +++ b/src/src/lib/discovery/robots.ts @@ -0,0 +1,41 @@ +import { sitemapIndexUrl } from './urls'; + +/** + * The generated `/robots.txt`. + * + * Policy, stated once and generated so it can never drift from the sitemap URL: + * + * - **Search indexing crawlers** (Googlebot, Bingbot, …) are welcome via the + * `User-agent: *` section. + * - **AI search crawlers** (`OAI-SearchBot`, which powers ChatGPT Search) are + * permitted explicitly, because a general `Allow: /` is not always read as an + * affirmative AI-crawler policy. + * - **Qwant** (`QwantBot`) is permitted explicitly for the same reason. + * - **Model-training crawlers** are neither permitted nor blocked beyond the + * general `Allow: /`: the site defines no training-specific policy, and + * inventing speculative `User-agent` names is deliberately avoided. + * + * The `Sitemap` directive points at the sitemap index, which is what every + * crawler above discovers the rest of the site through. + */ +export function renderRobotsTxt(): string { + return [ + '# Purview-Dev crawler policy.', + '# Search indexing and AI search crawlers are welcome. No model-training', + '# restrictions are defined; unknown crawlers fall under the "*" section.', + '', + 'User-agent: *', + 'Allow: /', + '', + '# AI search (ChatGPT Search). Permitted explicitly.', + 'User-agent: OAI-SearchBot', + 'Allow: /', + '', + '# Qwant search crawler. Permitted explicitly.', + 'User-agent: QwantBot', + 'Allow: /', + '', + `Sitemap: ${sitemapIndexUrl()}`, + '', + ].join('\n'); +} diff --git a/src/src/lib/discovery/sitemap.ts b/src/src/lib/discovery/sitemap.ts new file mode 100644 index 0000000..858fddc --- /dev/null +++ b/src/src/lib/discovery/sitemap.ts @@ -0,0 +1,59 @@ +import type { DiscoverableResource } from './types'; + +import { latestTimestamp } from './last-modified'; + +/** + * Sitemap XML generation. Pure string builders (no I/O) so the output can be + * asserted directly in tests. The sitemap index advertises three child sitemaps + * (`pages.xml`, `projects.xml`, `llms.xml`) plus the IndexNow key endpoint is + * handled elsewhere; the structure is decided in the Astro integration. + * + * `lastmod` is the strongest content-derived date available (see + * `last-modified.ts`); it is omitted rather than set to the build time when no + * provenance exists. + */ + +const SITEMAP_NS = 'http://www.sitemaps.org/schemas/sitemap/0.9'; + +export function escapeXml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** Render one `` document for a group of resources. */ +export function renderUrlset(resources: readonly DiscoverableResource[]): string { + const entries = resources + .map((resource) => { + const lastmod = resource.lastModified + ? `${escapeXml(resource.lastModified)}` + : ''; + return `${escapeXml(resource.url)}${lastmod}`; + }) + .join(''); + return `${entries}`; +} + +export interface SitemapIndexEntry { + loc: string; + lastmod?: string; +} + +/** Latest `lastmod` across a group of resources, for the index entry. */ +export function latestLastmod(resources: readonly DiscoverableResource[]): string | undefined { + return latestTimestamp(resources.map((resource) => resource.lastModified)); +} + +/** Render the `` document that lists the child sitemaps. */ +export function renderSitemapIndex(entries: readonly SitemapIndexEntry[]): string { + const body = entries + .map((entry) => { + const lastmod = entry.lastmod ? `${escapeXml(entry.lastmod)}` : ''; + return `${escapeXml(entry.loc)}${lastmod}`; + }) + .join(''); + return `${body}`; +} diff --git a/src/src/lib/discovery/types.ts b/src/src/lib/discovery/types.ts new file mode 100644 index 0000000..3dc98a8 --- /dev/null +++ b/src/src/lib/discovery/types.ts @@ -0,0 +1,116 @@ +/** + * The discovery model: the single description of everything the site wants + * crawlers and AI tooling to find. `sitemap.ts`, `manifest.ts` and + * `indexnow.ts` all consume the same `DiscoverableResource[]`, so the sitemap, + * the public `discover.json` and the post-deploy IndexNow submission can never + * disagree about what exists. + * + * Three layers of "standard" live here, and they are deliberately kept apart: + * + * - **Established**: sitemap XML, robots.txt, canonical URLs. Implemented as + * specified. + * - **Emerging convention**: `llms.txt` / `llms-full.txt` (root and per project). + * Useful, but not a search-engine protocol. + * - **Purview-specific**: `discover.json` and the internal deployment-state + * file. These are *our* machine-readable map, not anything the industry + * recognises — see `docs/discovery.md`. + */ + +/** Bumped whenever the public `discover.json` shape changes incompatibly. */ +export const DISCOVERY_MANIFEST_SCHEMA_VERSION = 1; + +/** Bumped whenever the internal deployment-state shape changes. */ +export const DEPLOYMENT_STATE_SCHEMA_VERSION = 1; + +/** + * What a resource is, which decides the sitemap it lands in and whether it is + * advertised as an HTML page or a machine-readable representation. + */ +export type DiscoverableKind = + | 'page' // hand-authored marketing/catalogue HTML (home, about, projects index, …) + | 'project' // /projects// HTML page generated from the catalogue + | 'documentation' // /docs//… aggregated documentation pages + | 'llms' // llms.txt (root or per project) + | 'llms-full' // llms-full.txt (root or per project) + | 'llms-small' // llms-small.txt (root only) + | 'other'; // anything else Astro emits that we still want discoverable + +/** One addressable thing on the site. */ +export interface DiscoverableResource { + /** Absolute canonical URL (site + base + path), e.g. `https://purview.dev/about/`. */ + url: string; + kind: DiscoverableKind; + /** + * Content-derived modification date in W3C datetime form (ISO 8601). Never + * the build time — see `last-modified.ts`. Omitted when no provenance exists. + */ + lastModified?: string; + /** False for pages robots/canonicals deliberately exclude from indexing. */ + indexable: boolean; + /** Owning project id when the resource is project-scoped. */ + projectId?: string; +} + +/** A project as exposed by the discovery manifest. */ +export interface DiscoveryManifestProject { + name: string; + slug: string; + url: string; + description: string; + repository: string; + repositoryUrl: string; + /** Canonical documentation entry point, or null when the project has none. */ + docs: string | null; + category: string; + status: string; + experimental: boolean; + /** Primary package information, when the project publishes one. */ + package: { id: string; url: string } | null; + /** Highest known version across the project's packages, when known. */ + latestVersion: string | null; + /** Per-project machine-readable resources, or null when the project has none. */ + llms: { summary: string; full: string } | null; +} + +/** + * The public `discover.json`: a stable, versioned map of the site. Purview- + * specific — **not** an industry standard. + */ +export interface DiscoveryManifest { + schemaVersion: typeof DISCOVERY_MANIFEST_SCHEMA_VERSION; + name: string; + url: string; + /** Manifest generation time (not a per-URL modification date). */ + generatedAt: string; + llms: { summary: string; small: string; full: string }; + sitemaps: { index: string; pages: string; projects: string; llms: string }; + counts: { resources: number; projects: number; documentation: number }; + /** Every discoverable resource, in canonical order. */ + resources: DiscoverableResource[]; + projects: DiscoveryManifestProject[]; +} + +/** + * Internal deployment state (never published): a hash per URL so a future + * change-detection scheme can compare content without re-downloading pages. + */ +export interface DeploymentState { + schemaVersion: typeof DEPLOYMENT_STATE_SCHEMA_VERSION; + generatedAt: string; + resources: Record; +} + +/** How a set of URLs changed between two deployments. */ +export interface DiscoveryChangeSet { + added: string[]; + modified: string[]; + deleted: string[]; +} + +/** The IndexNow batch body sent to `api.indexnow.org`. */ +export interface IndexNowPayload { + host: string; + key: string; + keyLocation: string; + urlList: string[]; +} diff --git a/src/src/lib/discovery/urls.ts b/src/src/lib/discovery/urls.ts new file mode 100644 index 0000000..ce953fc --- /dev/null +++ b/src/src/lib/discovery/urls.ts @@ -0,0 +1,91 @@ +import { SITE } from '../site'; +import { absoluteUrl, withBase } from '../urls'; + +/** + * Canonical URL construction for the discovery subsystem. + * + * Everything that ends up in a sitemap, a canonical tag, `discover.json`, an + * `llms.txt` reference or an IndexNow payload must be built here so there is + * exactly one definition of "the site's URLs". These helpers are thin wrappers + * over `../urls` (`absoluteUrl`/`withBase`), which already honour `SITE_URL` + * and `PAGES_BASE`; nothing else in the codebase should concatenate host and + * path by hand. + */ + +/** The production origin. All generated absolute URLs must share it. */ +export const PRODUCTION_ORIGIN = 'https://purview.dev'; + +/** The configured site origin (no trailing slash), e.g. `https://purview.dev`. */ +export function siteOrigin(): string { + return SITE.url.replace(/\/+$/, ''); +} + +/** Absolute canonical URL for a site path (base-aware). */ +export function canonical(path: string): string { + return absoluteUrl(path); +} + +/** Site-relative URL (base-aware) for use in `href` attributes. */ +export function href(path: string): string { + return withBase(path); +} + +export const SITEMAP_INDEX_PATH = '/sitemap-index.xml'; +export const SITEMAP_PAGES_PATH = '/sitemaps/pages.xml'; +export const SITEMAP_PROJECTS_PATH = '/sitemaps/projects.xml'; +export const SITEMAP_LLMS_PATH = '/sitemaps/llms.xml'; +export const DISCOVER_JSON_PATH = '/discover.json'; +export const ROBOTS_PATH = '/robots.txt'; + +export function sitemapIndexUrl(): string { + return canonical(SITEMAP_INDEX_PATH); +} +export function sitemapPagesUrl(): string { + return canonical(SITEMAP_PAGES_PATH); +} +export function sitemapProjectsUrl(): string { + return canonical(SITEMAP_PROJECTS_PATH); +} +export function sitemapLlmsUrl(): string { + return canonical(SITEMAP_LLMS_PATH); +} +export function discoverJsonUrl(): string { + return canonical(DISCOVER_JSON_PATH); +} +export function robotsUrl(): string { + return canonical(ROBOTS_PATH); +} + +export function rootLlmsUrl(): string { + return canonical('/llms.txt'); +} +export function rootLlmsSmallUrl(): string { + return canonical('/llms-small.txt'); +} +export function rootLlmsFullUrl(): string { + return canonical('/llms-full.txt'); +} + +export function projectPageUrl(id: string): string { + return canonical(`/projects/${id}/`); +} + +/** Documentation entry point (`/docs//`) or a specific page when `slug` is given. */ +export function docsUrl(projectId: string, slug?: string): string { + return slug && slug !== 'index' + ? canonical(`/docs/${projectId}/${slug}/`) + : canonical(`/docs/${projectId}/`); +} + +/** Per-project `llms.txt` (concise) and `llms-full.txt` (full corpus). */ +export function projectLlmsUrl(id: string): string { + return canonical(`/projects/${id}/llms.txt`); +} +export function projectLlmsFullUrl(id: string): string { + return canonical(`/projects/${id}/llms-full.txt`); +} + +/** Whether an absolute URL belongs to the configured canonical origin. */ +export function isCanonicalUrl(url: string, origin: string = siteOrigin()): boolean { + return url.startsWith(`${origin}/`) || url === origin; +} diff --git a/src/src/lib/docs/aggregate.ts b/src/src/lib/docs/aggregate.ts index dd0ceb2..5eb3e3c 100644 --- a/src/src/lib/docs/aggregate.ts +++ b/src/src/lib/docs/aggregate.ts @@ -17,6 +17,7 @@ import { renderFrontmatter, } from './frontmatter'; import { extractHeadingSlugs, rewriteDocMarkdown, resolveDoclinks, slugifyDocFile } from './links'; +import { stripMkdocsMarkup } from './mkdocs'; export const DOCS_OUTPUT_DIR = resolve('src/content/docs'); export const DOCS_CACHE_DIR = resolve('.cache/docs'); @@ -301,17 +302,21 @@ async function buildPage( slugAliases, }; - const converted = convertGithubAlerts(raw.content); + // Strip mkdocs-only markup (Material attr_list buttons, TechDocs/Jinja + // macros, admonitions) before anything else reads the source, so the title, + // description, and rendered body all agree. + const cleaned = stripMkdocsMarkup(raw.content); + const converted = convertGithubAlerts(cleaned); const rewritten = rewriteDocMarkdown(converted, linkContext); const content = normalizeDocumentHeadings(resolveDoclinks(rewritten, slug)); // The project's landing page uses the catalogue display name, not the source // README's first heading (which can repeat a "Purview.*" package name). - const title = slug === 'index' ? project.name : extractTitle(raw.content, sourceName(slug, raw)); + const title = slug === 'index' ? project.name : extractTitle(cleaned, sourceName(slug, raw)); const repo = getReleaseIndex().data.repos[project.repository]; const repoTags = repo?.topics ?? []; const repoDescription = repo?.description?.trim() || null; const description = - (slug === 'index' ? project.shortDescription : extractDescription(raw.content)) || + (slug === 'index' ? project.shortDescription : extractDescription(cleaned)) || repoDescription || project.shortDescription; const editSourcePath = raw.path.replace(/^\/+/, ''); @@ -422,7 +427,7 @@ async function collectGithubPathDocs( for (const raw of [...selectedRoot.regular, selectedRoot.index]) { const slug = raw === selectedRoot.index ? 'index' : slugifyDocFile(relativeToDocRoot(raw.path, docRoot)); - headingSlugs.set(slug, extractHeadingSlugs(raw.content)); + headingSlugs.set(slug, extractHeadingSlugs(stripMkdocsMarkup(raw.content))); } const pages: AggregatedPage[] = []; @@ -503,7 +508,7 @@ async function collectWikiDocs(project: ResolvedProject): Promise>(); for (const raw of [...selectedRoot.regular, selectedRoot.index]) { const slug = raw === selectedRoot.index ? 'index' : slugifyDocFile(raw.path); - headingSlugs.set(slug, extractHeadingSlugs(raw.content)); + headingSlugs.set(slug, extractHeadingSlugs(stripMkdocsMarkup(raw.content))); } const pages: AggregatedPage[] = []; @@ -558,7 +563,7 @@ async function collectReadmeDocs(project: ResolvedProject): Promise = { + note: 'note', + abstract: 'note', + summary: 'note', + tldr: 'note', + info: 'note', + todo: 'note', + example: 'note', + quote: 'note', + tip: 'tip', + hint: 'tip', + important: 'tip', + success: 'tip', + check: 'tip', + done: 'tip', + warning: 'caution', + caution: 'caution', + attention: 'caution', + question: 'caution', + help: 'caution', + faq: 'caution', + danger: 'danger', + error: 'danger', + failure: 'danger', + fail: 'danger', + missing: 'danger', + bug: 'danger', +}; + +/** `!!! note "Title"` / `???+ tip` — a mkdocs admonition opening line. */ +const ADMONITION_OPEN = /^(\s*)(!{3}|\?{3})\+?\s+([A-Za-z][\w-]*)(?:\s+"([^"]*)")?\s*$/; + +const FENCE_OPEN = /^\s*(```|~~~)/; +const SNIPPET_LINE = /^\s*--8<--/; + +/** + * Split a line into alternating plain-text and inline-code segments so + * transforms only ever touch prose. A backtick run that has no closing partner + * on the line is treated as code to the end of the line (a conservative choice: + * it never rewrites something that might be code). + */ +function splitInlineCode(line: string): { code: boolean; text: string }[] { + const parts: { code: boolean; text: string }[] = []; + let index = 0; + while (index < line.length) { + const start = line.indexOf('`', index); + if (start === -1) { + parts.push({ code: false, text: line.slice(index) }); + break; + } + let end = start; + while (end < line.length && line[end] === '`') { + end += 1; + } + const ticks = line.slice(start, end); + const close = line.indexOf(ticks, end); + if (close === -1) { + parts.push({ code: false, text: line.slice(index, start) }); + parts.push({ code: true, text: line.slice(start) }); + break; + } + parts.push({ code: false, text: line.slice(index, start) }); + parts.push({ code: true, text: line.slice(start, close + ticks.length) }); + index = close + ticks.length; + } + return parts; +} + +/** Strip mkdocs markup from a single run of non-code text. */ +function cleanSegment(text: string): string { + return ( + text + // TechDocs/Jinja statements (`{% … %}`) and variables (`{{ … }}`). + .replace(/\{%[^%]*%\}/g, '') + .replace(/\{\{[^{}]*\}\}/g, '') + // Inline snippet includes (`--8<-- "snippets/foo.md"`). + .replace(/--8<--\s*"[^"]*"/g, '') + // Material attr_list attached to a link or image: `[x](y){ .md-button }`. + .replace(/\]\(([^)]*)\)\s*\{[^}]*\}/g, ']($1)') + // Inline attr_list: `text {: .class }`. + .replace(/\s*\{:\s*[^}]*\}/g, '') + ); +} + +/** Clean one non-fence line, leaving inline code spans untouched. */ +function cleanLine(line: string): string { + if (SNIPPET_LINE.test(line)) { + return ''; + } + return splitInlineCode(line) + .map((part) => (part.code ? part.text : cleanSegment(part.text))) + .join(''); +} + +/** + * Convert mkdocs admonitions (`!!! note "Title"`) to Starlight asides. The + * indented body is dedented by the admonition's own indent plus the four spaces + * mkdocs uses for its content, preserving any deeper nesting. + */ +function convertAdmonitions(markdown: string): string { + const lines = markdown.split('\n'); + const output: string[] = []; + let fence: string | null = null; + let i = 0; + + while (i < lines.length) { + const line = lines[i] ?? ''; + const fenceMatch = FENCE_OPEN.exec(line); + const marker = fenceMatch?.[1]; + if (marker !== undefined) { + fence = fence === marker ? null : (fence ?? marker); + output.push(line); + i += 1; + continue; + } + if (fence) { + output.push(line); + i += 1; + continue; + } + + const match = ADMONITION_OPEN.exec(line); + if (!match) { + output.push(line); + i += 1; + continue; + } + + const markerIndent = (match[1] ?? '').length; + const type = ADMONITION_TYPES[(match[3] ?? '').toLowerCase()] ?? 'note'; + const title = match[4]; + const body: string[] = []; + let pendingBlank = 0; + i += 1; + + while (i < lines.length) { + const current = lines[i] ?? ''; + if (current.trim() === '') { + pendingBlank += 1; + i += 1; + continue; + } + const currentIndent = current.length - current.trimStart().length; + if (currentIndent <= markerIndent) { + break; + } + for (let blank = 0; blank < pendingBlank; blank += 1) { + body.push(''); + } + pendingBlank = 0; + body.push(current.slice(Math.min(currentIndent, markerIndent + 4))); + i += 1; + } + + const opener = title ? `:::${type}[${title}]` : `:::${type}`; + output.push(opener, ...body, ':::'); + } + + return output.join('\n'); +} + +/** Remove mkdocs-only markup from aggregated markdown. */ +export function stripMkdocsMarkup(markdown: string): string { + const lines = convertAdmonitions(markdown).split('\n'); + const output: string[] = []; + let fence: string | null = null; + + for (const line of lines) { + const fenceMatch = FENCE_OPEN.exec(line); + const marker = fenceMatch?.[1]; + if (marker !== undefined) { + fence = fence === marker ? null : (fence ?? marker); + output.push(line); + continue; + } + if (fence) { + output.push(line); + continue; + } + // A heading attr_list (`# Title { #id }`) or a standalone attr_list line is + // stripped from the line as a whole; `cleanLine` handles inline cases. + const heading = /^(#{1,6}\s+.*?)\s*\{[^}]*\}\s*$/.exec(line); + if (heading) { + output.push(heading[1] ?? ''); + continue; + } + if (/^\s*\{[^}]*\}\s*$/.test(line)) { + output.push(''); + continue; + } + output.push(cleanLine(line)); + } + + return output.join('\n'); +} diff --git a/src/src/pages/docs/index.astro b/src/src/pages/docs/index.astro index b475111..695cf90 100644 --- a/src/src/pages/docs/index.astro +++ b/src/src/pages/docs/index.astro @@ -96,7 +96,7 @@ const pageCount = docsManifest?.projects.reduce( projects.map((project) => (
  • diff --git a/src/src/pages/index.astro b/src/src/pages/index.astro index 6ec59ed..09b9d35 100644 --- a/src/src/pages/index.astro +++ b/src/src/pages/index.astro @@ -34,12 +34,23 @@ const versionRows = active.map((project) => const organizationStructuredData = { '@context': 'https://schema.org', - '@type': 'Organization', - name: SITE.orgName, - url: SITE.url, - logo: absoluteUrl('/branding/icon-light-512.png'), - description: SITE.description, - sameAs: [SITE.githubUrl, `${SITE.nugetUrl}/search?q=purview`], + '@graph': [ + { + '@type': 'Organization', + name: SITE.orgName, + url: SITE.url, + logo: absoluteUrl('/branding/icon-light-512.png'), + description: SITE.description, + sameAs: [SITE.githubUrl, `${SITE.nugetUrl}/search?q=purview`], + }, + { + '@type': 'WebSite', + name: SITE.fullName, + url: SITE.url, + description: SITE.description, + publisher: { '@type': 'Organization', name: SITE.orgName, url: SITE.url }, + }, + ], }; --- diff --git a/src/src/pages/projects/[project].astro b/src/src/pages/projects/[project].astro index c8b2f6e..8a32436 100644 --- a/src/src/pages/projects/[project].astro +++ b/src/src/pages/projects/[project].astro @@ -1,7 +1,9 @@ --- import SiteLayout from '~/layouts/SiteLayout.astro'; +import GitHubIcon from '~/components/GitHubIcon.astro'; import InstallPanel from '~/components/islands/InstallPanel.tsx'; import PackageVersions, { type PackageRow } from '~/components/islands/PackageVersions.tsx'; +import StarOnGitHub from '~/components/StarOnGitHub.astro'; import StatusBadge from '~/components/StatusBadge.astro'; import UseCaseCard from '~/components/UseCaseCard.astro'; import { buildBadge, nugetDownloadsBadge, nugetVersionBadge, actionsUrl } from '~/lib/badges'; @@ -14,7 +16,7 @@ import { getReleaseIndex } from '~/lib/releases/runtime'; import { SITE } from '~/lib/site'; import { displayStatus } from '~/lib/status'; -import { nugetPackageUrl, withBase } from '~/lib/urls'; +import { absoluteUrl, nugetPackageUrl, withBase } from '~/lib/urls'; export function getStaticPaths() { return loadProjects().map((project) => ({ @@ -33,6 +35,8 @@ const status = displayStatus(project); const releaseIndex = getReleaseIndex(); const releases = projectReleaseSummary(project, releaseIndex.data, releaseIndex.source); const { tags, repoDescription } = projectRepoEnrichment(project, releaseIndex.data); +/** Live star count for the repository, when the release cache knows it. */ +const starCount = releaseIndex.data.repos[project.repository]?.stargazersCount ?? null; const supplementalRepoDescription = ( repoDescription && @@ -50,9 +54,60 @@ const supersededBy = : undefined; const primaryPackage = project.packages.find((pkg) => pkg.primary) ?? project.packages[0]; const docsPath = project.docs ? withBase(`/docs/${project.id}/`) : null; -// Per-project machine-readable bundle emitted by `starlight-llms-txt`'s -// `customSets` option (one file per documented project at /_llms-txt/.txt). -const llmsPath = project.docs ? withBase(`/_llms-txt/${project.id}.txt`) : null; +// Per-project machine-readable bundles, emitted by the discovery integration at +// /projects//llms.txt and /projects//llms-full.txt (the llms.txt +// convention applied at the per-project scope). +const hasMachineReadable = project.docs !== undefined && status !== 'archived'; +const llmsPath = hasMachineReadable ? withBase(`/projects/${project.id}/llms.txt`) : null; +const alternateLinks = + hasMachineReadable ? + [ + { + href: absoluteUrl(`/projects/${project.id}/llms-full.txt`), + type: 'text/markdown', + title: `${project.name} — machine-readable documentation`, + }, + ] + : []; +// Structured data for an open-source library: name, description, repository, +// language, licence, package and current version. A BreadcrumbList mirrors the +// visible breadcrumb (Projects › ). Only accurate values are set. +const structuredData: Record = { + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'SoftwareSourceCode', + name: project.name, + description: project.shortDescription, + url: absoluteUrl(`/projects/${project.id}/`), + codeRepository: project.sourceUrl, + programmingLanguage: 'C#', + license: 'https://opensource.org/licenses/MIT', + ...(tags.length > 0 ? { keywords: tags.join(', ') } : {}), + ...(primaryPackage ? + { sameAs: nugetPackageUrl(primaryPackage.id), identifier: primaryPackage.id } + : {}), + ...(releases.latestRelease?.tagName ? { softwareVersion: releases.latestRelease.tagName } : {}), + }, + { + '@type': 'BreadcrumbList', + itemListElement: [ + { + '@type': 'ListItem', + position: 1, + name: 'Projects', + item: absoluteUrl('/projects/'), + }, + { + '@type': 'ListItem', + position: 2, + name: project.name, + item: absoluteUrl(`/projects/${project.id}/`), + }, + ], + }, + ], +}; const targetFrameworks = project.targetFrameworks ?? []; const installSections = status !== 'archived' ? @@ -85,7 +140,12 @@ const packageRows: PackageRow[] = releases.packages.map((pkg) => ({ })); --- - +
    diff --git a/src/tests/dist/built-output.test.ts b/src/tests/dist/built-output.test.ts index fa9bd0f..e1f8e4e 100644 --- a/src/tests/dist/built-output.test.ts +++ b/src/tests/dist/built-output.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import { LOCAL_PATH_PATTERNS, SECRET_PATTERNS } from '../../src/lib/build-paths'; import { loadProjects } from '../../src/lib/manifest/load'; const DIST = resolve('dist'); @@ -40,10 +41,12 @@ describe('built llms outputs', () => { requireBuilt('llms-small.txt'), requireBuilt('llms-full.txt'), ].join('\n'); - expect(content).not.toMatch(/\bghp_[A-Za-z0-9]{36,}\b/); - expect(content).not.toMatch(/\.cache[/\\]/); - expect(content).not.toMatch(/[A-Za-z]:\\/); - expect(content).not.toMatch(/node_modules[/\\]/); + for (const pattern of SECRET_PATTERNS) { + expect(content, `llms outputs match secret pattern ${pattern}`).not.toMatch(pattern); + } + for (const pattern of LOCAL_PATH_PATTERNS) { + expect(content, `llms outputs match local path pattern ${pattern}`).not.toMatch(pattern); + } }); }); @@ -52,34 +55,46 @@ describe('built per-project llms outputs', () => { (project) => project.docs && project.status !== 'archived', ); - // The plugin derives each bundle's slug from its `customSets` label - // (`github-slugger` of the project name). Asserting the file exists under the - // project id fails the build if a future display name stops slugifying to its - // id, which is the invariant the UI links rely on. - test('a scoped bundle is built for every documented project', () => { + // The discovery integration emits a concise and a full machine-readable bundle + // for every documented, non-archived project at the llms.txt convention path + // under /projects//. The UI links to those exact paths. + test('both bundles are built for every documented project', () => { expect(docsProjects.length).toBeGreaterThan(0); for (const project of docsProjects) { - const content = requireBuilt(`_llms-txt/${project.id}.txt`); - expect(content.trim().length, `${project.id} bundle is empty`).toBeGreaterThan(0); - expect(content, `${project.id} bundle lacks aggregated content`).toContain('Purview'); + const summary = requireBuilt(`projects/${project.id}/llms.txt`); + expect(summary.trim().length, `${project.id} llms.txt is empty`).toBeGreaterThan(0); + expect(summary, `${project.id} llms.txt lacks the project name`).toContain(project.name); + const full = requireBuilt(`projects/${project.id}/llms-full.txt`); + expect(full.trim().length, `${project.id} llms-full.txt is empty`).toBeGreaterThan(0); + expect(full, `${project.id} llms-full.txt lacks aggregated content`).toContain('Purview'); } }); - test('the llms.txt entrypoint links every scoped bundle', () => { + test('the llms.txt entrypoint links every project bundle', () => { const content = requireBuilt('llms.txt'); for (const project of docsProjects) { expect(content, `llms.txt does not link ${project.id}`).toContain( - `https://purview.dev/_llms-txt/${project.id}.txt`, + `https://purview.dev/projects/${project.id}/llms.txt`, ); } }); test('scoped bundles exclude secrets and local build paths', () => { for (const project of docsProjects) { - const content = requireBuilt(`_llms-txt/${project.id}.txt`); - expect(content).not.toMatch(/\bghp_[A-Za-z0-9]{36,}\b/); - expect(content).not.toMatch(/[A-Za-z]:\\/); - expect(content).not.toMatch(/node_modules[/\\]/); + const content = [ + requireBuilt(`projects/${project.id}/llms.txt`), + requireBuilt(`projects/${project.id}/llms-full.txt`), + ].join('\n'); + for (const pattern of SECRET_PATTERNS) { + expect(content, `${project.id} bundle matches secret pattern ${pattern}`).not.toMatch( + pattern, + ); + } + for (const pattern of LOCAL_PATH_PATTERNS) { + expect(content, `${project.id} bundle matches local path pattern ${pattern}`).not.toMatch( + pattern, + ); + } } }); }); @@ -102,6 +117,31 @@ describe('built project pages', () => { }); }); +describe('built GitHub links and star calls to action', () => { + test('project pages link to the repository and invite a star', () => { + const content = requireBuilt('projects/event-sourcing/index.html'); + expect(content).toContain('https://github.com/purview-dev/event-sourcing'); + expect(content).toContain('Star on GitHub'); + }); + + test('documentation pages link to the repository and invite a star', () => { + const content = requireBuilt('docs/event-sourcing/index.html'); + expect(content).toContain('https://github.com/purview-dev/event-sourcing'); + expect(content).toContain('Star on GitHub'); + }); +}); + +describe('built docs strip mkdocs markup', () => { + // `containers` still publishes a mkdocs Material site, whose attr_list + // buttons (`[x](y){ .md-button }`) rendered as literal text through the + // portal. `src/lib/docs/mkdocs.ts` strips them during aggregation. + test('aggregated pages render no mkdocs-only markup', () => { + const content = requireBuilt('docs/containers/index.html'); + expect(content).not.toContain('md-button'); + expect(content).toContain('/docs/containers/getting-started/'); + }); +}); + describe('built use cases', () => { test('project pages render a use-case section with concrete code', () => { const content = requireBuilt('projects/telemetry-sourcegenerator/index.html'); @@ -143,8 +183,7 @@ describe('built llms links', () => { test('every llms text link opens in a new tab like an external link', () => { const { glob } = require('fast-glob'); const files = glob.sync('**/*.html', { cwd: DIST }); - const anchor = - /]*href="[^"]*(?:llms(?:-small|-full)?\.txt|_llms-txt\/[^"]+\.txt)"[^>]*>/g; + const anchor = /]*href="[^"]*llms(?:-small|-full)?\.txt"[^>]*>/g; let checked = 0; for (const file of files) { @@ -162,14 +201,39 @@ describe('built llms links', () => { }); describe('built SEO outputs', () => { - test('sitemap index exists and references the canonical site', () => { + test('sitemap index references the partitioned child sitemaps', () => { const content = requireBuilt('sitemap-index.xml'); - expect(content).toContain('https://purview.dev/sitemap-0.xml'); + expect(content).toContain('https://purview.dev/sitemaps/pages.xml'); + expect(content).toContain('https://purview.dev/sitemaps/projects.xml'); + expect(content).toContain('https://purview.dev/sitemaps/llms.xml'); }); - test('robots.txt references the sitemap', () => { + test('robots.txt references the sitemap index', () => { const content = requireBuilt('robots.txt'); - expect(content).toContain('https://purview.dev/sitemap-index.xml'); + expect(content).toContain('Sitemap: https://purview.dev/sitemap-index.xml'); + }); + + test('discover.json is generated and references the canonical site', () => { + const manifest = JSON.parse(requireBuilt('discover.json')) as { + schemaVersion: number; + url: string; + sitemaps: { index: string }; + resources: unknown[]; + projects: { slug: string }[]; + }; + expect(manifest.schemaVersion).toBe(1); + expect(manifest.url).toBe('https://purview.dev/'); + expect(manifest.sitemaps.index).toBe('https://purview.dev/sitemap-index.xml'); + expect(manifest.resources.length).toBeGreaterThan(0); + expect(manifest.projects.length).toBeGreaterThan(0); + }); + + test('project pages expose their machine-readable representation', () => { + const content = requireBuilt('projects/zodsharp/index.html'); + expect(content).toContain('rel="alternate"'); + expect(content).toContain('https://purview.dev/projects/zodsharp/llms-full.txt'); + expect(content).toContain('/projects/zodsharp/llms.txt'); + expect(content).toContain('"@type":"SoftwareSourceCode"'); }); test('key brand assets are present in the build', () => { @@ -191,8 +255,12 @@ describe('built HTML safety', () => { const files = glob.sync('**/*.html', { cwd: DIST }); for (const file of files) { const content = readFileSync(resolve(DIST, file), 'utf8'); - expect(content).not.toMatch(/[A-Za-z]:\\/); - expect(content).not.toMatch(/\bghp_[A-Za-z0-9]{36,}\b/); + for (const pattern of LOCAL_PATH_PATTERNS) { + expect(content, `${file} matches local path pattern ${pattern}`).not.toMatch(pattern); + } + for (const pattern of SECRET_PATTERNS) { + expect(content, `${file} matches secret pattern ${pattern}`).not.toMatch(pattern); + } } }); }); diff --git a/src/tests/unit/build-paths.test.ts b/src/tests/unit/build-paths.test.ts new file mode 100644 index 0000000..3acdd57 --- /dev/null +++ b/src/tests/unit/build-paths.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'bun:test'; + +import { + firstMatchingPattern, + LOCAL_PATH_PATTERNS, + SECRET_PATTERNS, +} from '../../src/lib/build-paths'; + +/** + * The scan exists to catch the build machine's own paths, not every absolute + * path a document happens to show. These cases pin both halves: real leaks are + * still caught, and sanctioned documentation examples are not (ADR 0006). + */ +describe('build-output safety patterns', () => { + const leaks = [ + String.raw`C:\Users\bob\source\repo`, + String.raw`C:\Users\runneradmin\AppData\Local\Temp\build`, + String.raw`D:\a\purview-dev\purview-dev\src\dist`, + String.raw`C:\actions-runner\_work\purview-dev\purview-dev`, + String.raw`C:\hostedtoolcache\windows\dotnet\10.0.0`, + '/Users/bob/source/repo', + '/home/runner/work/purview-dev/purview-dev', + 'src/.cache/docs/index.json', + 'node_modules/astro/package.json', + ]; + + const documentation = [ + String.raw`D:\wslc-images`, + String.raw`StoragePath = @"D:\wslc-images",`, + String.raw`%LOCALAPPDATA%\Purview\WslContainers\images`, + '/mnt/d/wslc-images', + ]; + + test('flags build-machine paths', () => { + for (const value of leaks) { + expect(firstMatchingPattern(LOCAL_PATH_PATTERNS, value), value).not.toBeNull(); + } + }); + + test('allows documentation drive-letter examples', () => { + for (const value of documentation) { + expect(firstMatchingPattern(LOCAL_PATH_PATTERNS, value), value).toBeNull(); + } + }); + + test('still matches credential patterns', () => { + const token = `ghp_${'a'.repeat(36)}`; + expect(firstMatchingPattern(SECRET_PATTERNS, token)).not.toBeNull(); + expect(firstMatchingPattern(SECRET_PATTERNS, 'plain documentation text')).toBeNull(); + }); +}); diff --git a/src/tests/unit/discovery/discovery-core.test.ts b/src/tests/unit/discovery/discovery-core.test.ts new file mode 100644 index 0000000..f191729 --- /dev/null +++ b/src/tests/unit/discovery/discovery-core.test.ts @@ -0,0 +1,95 @@ +import type { DiscoverableResource } from '../../../src/lib/discovery/types'; + +import { describe, expect, test } from 'bun:test'; + +import { latestTimestamp, parseGitLogDates } from '../../../src/lib/discovery/last-modified'; +import { renderSitemapIndex, renderUrlset } from '../../../src/lib/discovery/sitemap'; +import { + canonical, + isCanonicalUrl, + projectLlmsFullUrl, + projectLlmsUrl, + projectPageUrl, + sitemapIndexUrl, +} from '../../../src/lib/discovery/urls'; + +function resource( + url: string, + lastModified?: string, + kind: DiscoverableResource['kind'] = 'page', +): DiscoverableResource { + return { url, kind, lastModified, indexable: true }; +} + +describe('discovery canonical URLs', () => { + test('builds canonical production URLs', () => { + expect(canonical('/')).toBe('https://purview.dev/'); + expect(projectPageUrl('zodsharp')).toBe('https://purview.dev/projects/zodsharp/'); + expect(projectLlmsUrl('zodsharp')).toBe('https://purview.dev/projects/zodsharp/llms.txt'); + expect(projectLlmsFullUrl('zodsharp')).toBe( + 'https://purview.dev/projects/zodsharp/llms-full.txt', + ); + expect(sitemapIndexUrl()).toBe('https://purview.dev/sitemap-index.xml'); + }); + + test('isCanonicalUrl only accepts the configured origin', () => { + expect(isCanonicalUrl('https://purview.dev/about/')).toBe(true); + expect(isCanonicalUrl('https://example.com/about/')).toBe(false); + expect(isCanonicalUrl('https://purview.dev.evil.com/')).toBe(false); + }); +}); + +describe('sitemap generation', () => { + test('renders a urlset with loc and lastmod', () => { + const xml = renderUrlset([ + resource('https://purview.dev/about/', '2025-01-02T03:04:05Z'), + resource('https://purview.dev/projects/', undefined), + ]); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain( + 'https://purview.dev/about/2025-01-02T03:04:05Z', + ); + // No lastmod when provenance is unknown — never the build time. + expect(xml).toContain('https://purview.dev/projects/'); + }); + + test('renders a sitemap index', () => { + const xml = renderSitemapIndex([ + { loc: 'https://purview.dev/sitemaps/pages.xml', lastmod: '2025-01-02' }, + { loc: 'https://purview.dev/sitemaps/llms.xml' }, + ]); + expect(xml).toContain(''); + expect(xml).toContain( + 'https://purview.dev/sitemaps/pages.xml2025-01-02', + ); + expect(xml).toContain('https://purview.dev/sitemaps/llms.xml'); + }); + + test('escapes XML-significant characters', () => { + const xml = renderUrlset([resource('https://purview.dev/?a=1&b=2')]); + expect(xml).toContain('&'); + expect(xml).not.toContain('?a=1&b=2'); + }); +}); + +describe('last-modified parsing', () => { + test('indexes each file by its most recent commit', () => { + const output = [ + '2026-10-03T11:18:47+01:00', + 'src/src/pages/about.astro', + 'src/src/pages/index.astro', + '', + '2026-09-01T00:00:00Z', + 'src/src/pages/about.astro', + ].join('\n'); + const index = parseGitLogDates(output); + expect(index.get('src/src/pages/about.astro')).toBe('2026-10-03T11:18:47+01:00'); + expect(index.get('src/src/pages/index.astro')).toBe('2026-10-03T11:18:47+01:00'); + }); + + test('latestTimestamp picks the newest parseable value', () => { + expect(latestTimestamp(['2025-01-01', null, undefined, '2025-06-01'])).toBe('2025-06-01'); + expect(latestTimestamp([null, undefined])).toBeUndefined(); + }); +}); diff --git a/src/tests/unit/discovery/discovery-indexnow-key.test.ts b/src/tests/unit/discovery/discovery-indexnow-key.test.ts new file mode 100644 index 0000000..a2737f6 --- /dev/null +++ b/src/tests/unit/discovery/discovery-indexnow-key.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { isValidIndexNowKey, writeIndexNowKeyFile } from '../../../src/lib/discovery/emit'; + +describe('IndexNow key handling', () => { + test('accepts only protocol-valid keys', () => { + expect(isValidIndexNowKey('abc12345')).toBe(true); + expect(isValidIndexNowKey('A1b2C3d4E5f6-g7h8')).toBe(true); + expect(isValidIndexNowKey('short')).toBe(false); + expect(isValidIndexNowKey('has space')).toBe(false); + expect(isValidIndexNowKey('bad.symbol')).toBe(false); + expect(isValidIndexNowKey('')).toBe(false); + }); + + test('writes the verification file from the supplied key', () => { + const dir = mkdtempSync(join(tmpdir(), 'purview-key-')); + try { + const result = writeIndexNowKeyFile(dir, 'abc12345key'); + expect(result.ok).toBe(true); + const file = join(dir, 'abc12345key.txt'); + expect(existsSync(file)).toBe(true); + expect(readFileSync(file, 'utf8').trim()).toBe('abc12345key'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('refuses a missing key, an invalid key, or a missing build output', () => { + const dir = mkdtempSync(join(tmpdir(), 'purview-key-')); + try { + for (const key of [undefined, '', ' ', 'bad key']) { + const result = writeIndexNowKeyFile(dir, key); + expect(result.ok, `key ${JSON.stringify(key)} must be rejected`).toBe(false); + expect(result.file).toBeUndefined(); + } + const missing = writeIndexNowKeyFile(join(dir, 'does-not-exist'), 'abc12345key'); + expect(missing.ok).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/tests/unit/discovery/discovery-manifest.test.ts b/src/tests/unit/discovery/discovery-manifest.test.ts new file mode 100644 index 0000000..316691e --- /dev/null +++ b/src/tests/unit/discovery/discovery-manifest.test.ts @@ -0,0 +1,143 @@ +import type { DiscoverableResource } from '../../../src/lib/discovery/types'; +import type { ReleaseCacheData } from '../../../src/lib/releases/types'; + +import { describe, expect, test } from 'bun:test'; + +import { + assertCanonicalOrigin, + buildIndexNowPayload, + changeCount, + chunk, + diffDeploymentStates, + diffResources, + indexNowKeyLocation, + redactSecret, + urlsToSubmit, +} from '../../../src/lib/discovery/indexnow'; +import { + buildDeploymentState, + buildDiscoveryManifest, + buildManifestProjects, + hashResource, +} from '../../../src/lib/discovery/manifest'; +import { DISCOVERY_MANIFEST_SCHEMA_VERSION } from '../../../src/lib/discovery/types'; +import { siteOrigin } from '../../../src/lib/discovery/urls'; +import { loadProjects } from '../../../src/lib/manifest/load'; + +const emptyReleaseData: ReleaseCacheData = { + schema: 1, + retrievedAt: '2026-01-01T00:00:00.000Z', + repos: {}, + releases: {}, + packages: {}, + packageSearch: {}, +}; + +function resource( + url: string, + lastModified?: string, + kind: DiscoverableResource['kind'] = 'page', +): DiscoverableResource { + return { url, kind, lastModified, indexable: true }; +} + +describe('discovery manifest', () => { + test('builds a versioned manifest from resources and projects', () => { + const projects = loadProjects(); + const manifestProjects = buildManifestProjects(projects, emptyReleaseData, 'fixture'); + const manifest = buildDiscoveryManifest({ + resources: [ + resource('https://purview.dev/', '2025-01-01'), + resource('https://purview.dev/projects/zodsharp/', '2025-02-02', 'project'), + ], + projects: manifestProjects, + generatedAt: '2026-01-01T00:00:00.000Z', + }); + expect(manifest.schemaVersion).toBe(DISCOVERY_MANIFEST_SCHEMA_VERSION); + expect(manifest.url).toBe('https://purview.dev/'); + expect(manifest.sitemaps.index).toBe('https://purview.dev/sitemap-index.xml'); + expect(manifest.llms.full).toBe('https://purview.dev/llms-full.txt'); + expect(manifest.counts.resources).toBe(2); + + const zodsharp = manifest.projects.find((project) => project.slug === 'zodsharp'); + expect(zodsharp?.url).toBe('https://purview.dev/projects/zodsharp/'); + expect(zodsharp?.repository).toBe('purview-dev/zodsharp'); + expect(zodsharp?.llms?.summary).toBe('https://purview.dev/projects/zodsharp/llms.txt'); + }); + + test('deployment state hashes resources deterministically', () => { + const a = resource('https://purview.dev/about/', '2025-01-01'); + expect(hashResource(a)).toBe(hashResource({ ...a })); + expect(hashResource(a)).not.toBe( + hashResource(resource('https://purview.dev/about/', '2025-01-02')), + ); + const state = buildDeploymentState([a], '2026-01-01T00:00:00.000Z'); + expect(Object.keys(state.resources)).toEqual(['https://purview.dev/about/']); + }); +}); + +describe('indexnow change detection', () => { + test('everything is added when there is no previous deployment', () => { + const changes = diffResources(null, [ + resource('https://purview.dev/a/'), + resource('https://purview.dev/b/'), + ]); + expect(changes.added).toEqual(['https://purview.dev/a/', 'https://purview.dev/b/']); + expect(changes.modified).toEqual([]); + expect(changes.deleted).toEqual([]); + }); + + test('detects modified and deleted URLs', () => { + const previous = [ + resource('https://purview.dev/a/', '2025-01-01'), + resource('https://purview.dev/b/', '2025-01-01'), + resource('https://purview.dev/c/', '2025-01-01'), + ]; + const current = [ + resource('https://purview.dev/a/', '2025-01-01'), + resource('https://purview.dev/b/', '2025-02-02'), + resource('https://purview.dev/d/', '2025-02-02'), + ]; + const changes = diffResources(previous, current); + expect(changes.added).toEqual(['https://purview.dev/d/']); + expect(changes.modified).toEqual(['https://purview.dev/b/']); + expect(changes.deleted).toEqual(['https://purview.dev/c/']); + expect(urlsToSubmit(changes)).toEqual(['https://purview.dev/b/', 'https://purview.dev/d/']); + expect(changeCount(changes)).toBe(3); + }); + + test('diffDeploymentStates compares content hashes', () => { + const previous = buildDeploymentState([resource('https://purview.dev/a/', '2025-01-01')], 'x'); + const current = buildDeploymentState([resource('https://purview.dev/a/', '2025-01-01')], 'y'); + expect(diffDeploymentStates(previous, current).modified).toEqual([]); + }); +}); + +describe('indexnow payloads and safety', () => { + test('chunks URLs at the protocol limit', () => { + const urls = Array.from({ length: 5 }, (_, index) => `https://purview.dev/${index}/`); + expect(chunk(urls, 2)).toHaveLength(3); + expect(chunk(urls, 2)[2]).toEqual(['https://purview.dev/4/']); + expect(() => chunk(urls, 0)).toThrow(); + }); + + test('builds a payload and refuses off-origin URLs', () => { + const payload = buildIndexNowPayload('purview.dev', 'key', 'https://purview.dev/key.txt', [ + 'https://purview.dev/a/', + ]); + expect(payload.host).toBe('purview.dev'); + expect(payload.urlList).toEqual(['https://purview.dev/a/']); + expect(() => assertCanonicalOrigin(['https://purview.dev/a/'], siteOrigin())).not.toThrow(); + expect(() => assertCanonicalOrigin(['https://evil.example/a/'], siteOrigin())).toThrow( + /outside https:\/\/purview.dev/, + ); + }); + + test('key location and redaction', () => { + expect(indexNowKeyLocation('https://purview.dev/', 'abc12345')).toBe( + 'https://purview.dev/abc12345.txt', + ); + expect(redactSecret('key=abc12345 end', 'abc12345')).toBe('key=[redacted] end'); + expect(redactSecret('no secret here', undefined)).toBe('no secret here'); + }); +}); diff --git a/src/tests/unit/discovery/discovery-project-llms.test.ts b/src/tests/unit/discovery/discovery-project-llms.test.ts new file mode 100644 index 0000000..6a87e60 --- /dev/null +++ b/src/tests/unit/discovery/discovery-project-llms.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test'; + +import { + renderProjectLlms, + renderProjectLlmsFull, + stripFrontmatter, +} from '../../../src/lib/discovery/project-llms'; +import { loadProjects } from '../../../src/lib/manifest/load'; + +describe('per-project llms bundles', () => { + const project = loadProjects().find((candidate) => candidate.id === 'zodsharp'); + + test('the concise bundle links resources and documentation', () => { + if (!project) throw new Error('zodsharp missing from catalogue'); + const content = renderProjectLlms(project, [ + { slug: 'index', title: 'ZodSharp' }, + { slug: 'core-concepts', title: 'Core concepts' }, + ]); + expect(content).toContain(`# ${project.name}`); + expect(content).toContain(project.sourceUrl); + expect(content).toContain('https://purview.dev/docs/zodsharp/'); + expect(content).toContain('https://purview.dev/projects/zodsharp/llms-full.txt'); + expect(content).toContain('https://purview.dev/docs/zodsharp/core-concepts/'); + }); + + test('the full bundle concatenates the docs mirror without frontmatter', () => { + if (!project) throw new Error('zodsharp missing from catalogue'); + const content = renderProjectLlmsFull( + project, + [{ slug: 'index', title: 'ZodSharp' }], + () => '---\ntitle: ZodSharp\n---\n\n# ZodSharp\n\nBody.', + ); + expect(content).toContain('# ZodSharp'); + expect(content).toContain('Body.'); + expect(content).not.toContain('title: ZodSharp'); + expect(stripFrontmatter('---\na: b\n---\n\nHello')).toBe('Hello'); + }); +}); diff --git a/src/tests/unit/discovery/discovery-resources.test.ts b/src/tests/unit/discovery/discovery-resources.test.ts new file mode 100644 index 0000000..b29d2eb --- /dev/null +++ b/src/tests/unit/discovery/discovery-resources.test.ts @@ -0,0 +1,120 @@ +import type { DiscoveryContext } from '../../../src/lib/discovery/resources'; +import type { ReleaseCacheData } from '../../../src/lib/releases/types'; + +import { describe, expect, test } from 'bun:test'; + +import { + buildResources, + classifyPath, + normalizePagePath, + partitionResources, +} from '../../../src/lib/discovery/resources'; +import { loadProjects } from '../../../src/lib/manifest/load'; + +const emptyReleaseData: ReleaseCacheData = { + schema: 1, + retrievedAt: '2026-01-01T00:00:00.000Z', + repos: {}, + releases: {}, + packages: {}, + packageSearch: {}, +}; + +const context: DiscoveryContext = { + projects: loadProjects(), + docsManifest: { + schema: 1, + syncedAt: '2026-01-01T00:00:00.000Z', + projects: [ + { + projectId: 'zodsharp', + pages: [ + { slug: 'index', title: 'ZodSharp', order: 0 }, + { slug: 'core-concepts', title: 'Core concepts', order: 1 }, + ], + }, + ], + }, + releaseData: emptyReleaseData, + dataSource: 'fixture', + fileDates: new Map([['src/src/pages/about.astro', '2025-11-01T00:00:00Z']]), + fallbackDate: '2025-01-01T00:00:00Z', + docsLastReviewed: new Map([['zodsharp/core-concepts', '2025-12-02']]), +}; + +describe('page path normalisation', () => { + test('normalises Astro pathnames and base prefixes', () => { + expect(normalizePagePath('')).toBe(''); + expect(normalizePagePath('about/')).toBe('about'); + expect(normalizePagePath('/about/')).toBe('about'); + expect(normalizePagePath('about/index.html')).toBe('about'); + expect(normalizePagePath('index.html')).toBe(''); + expect(normalizePagePath('/purview-dev/about/', '/purview-dev')).toBe('about'); + }); + + test('classifies catalogue and documentation routes', () => { + const ids = new Set(['zodsharp']); + expect(classifyPath('', ids)).toEqual({ kind: 'page' }); + expect(classifyPath('projects/zodsharp', ids)).toEqual({ + kind: 'project', + projectId: 'zodsharp', + }); + expect(classifyPath('docs/zodsharp', ids)).toEqual({ + kind: 'documentation', + projectId: 'zodsharp', + docsSlug: 'index', + }); + expect(classifyPath('docs/zodsharp/core-concepts', ids)).toEqual({ + kind: 'documentation', + projectId: 'zodsharp', + docsSlug: 'core-concepts', + }); + expect(classifyPath('projects/unknown', ids)).toEqual({ kind: 'page' }); + }); +}); + +describe('resource building', () => { + const resources = buildResources( + ['', 'about/', 'projects/zodsharp/', 'docs/zodsharp/', 'docs/zodsharp/core-concepts/', '404/'], + context, + ); + const urls = resources.map((resource) => resource.url); + + test('classifies pages, projects, documentation and llms', () => { + expect(urls).toContain('https://purview.dev/'); + expect(urls).toContain('https://purview.dev/about/'); + expect(urls).toContain('https://purview.dev/projects/zodsharp/'); + expect(urls).toContain('https://purview.dev/docs/zodsharp/'); + expect(urls).toContain('https://purview.dev/docs/zodsharp/core-concepts/'); + expect(urls).toContain('https://purview.dev/llms.txt'); + expect(urls).toContain('https://purview.dev/projects/zodsharp/llms.txt'); + expect(urls).toContain('https://purview.dev/projects/zodsharp/llms-full.txt'); + }); + + test('excludes error pages and de-duplicates', () => { + expect(urls.some((url) => url.includes('404'))).toBe(false); + expect(new Set(urls).size).toBe(urls.length); + }); + + test('uses content-derived lastmod values, not the build time', () => { + const about = resources.find((resource) => resource.url === 'https://purview.dev/about/'); + expect(about?.lastModified).toBe('2025-11-01T00:00:00Z'); + const docs = resources.find( + (resource) => resource.url === 'https://purview.dev/docs/zodsharp/core-concepts/', + ); + expect(docs?.lastModified).toBe('2025-12-02'); + }); + + test('partitions indexable resources into three sitemaps', () => { + const partition = partitionResources(resources); + expect(partition.pages.some((resource) => resource.url === 'https://purview.dev/about/')).toBe( + true, + ); + expect( + partition.projects.some((resource) => resource.url === 'https://purview.dev/docs/zodsharp/'), + ).toBe(true); + expect(partition.llms.some((resource) => resource.url === 'https://purview.dev/llms.txt')).toBe( + true, + ); + }); +}); diff --git a/src/tests/unit/mkdocs.test.ts b/src/tests/unit/mkdocs.test.ts new file mode 100644 index 0000000..39ccf9f --- /dev/null +++ b/src/tests/unit/mkdocs.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from 'bun:test'; + +import { stripMkdocsMarkup } from '../../src/lib/docs/mkdocs'; + +describe('stripMkdocsMarkup', () => { + test('drops Material attr_list from button links', () => { + const markdown = [ + '[Get started](getting-started/){ .md-button .md-button--primary }', + '[Choose a backend](backends/){ .md-button }', + ].join('\n'); + const cleaned = stripMkdocsMarkup(markdown); + expect(cleaned).toContain('[Get started](getting-started/)'); + expect(cleaned).toContain('[Choose a backend](backends/)'); + expect(cleaned).not.toContain('md-button'); + }); + + test('drops inline attr_list from images and headings', () => { + const markdown = [ + '![the dashboard](dashboard.png){ align=right }', + '# Getting started { #custom-id }', + ].join('\n'); + const cleaned = stripMkdocsMarkup(markdown); + expect(cleaned).toContain('![the dashboard](dashboard.png)'); + expect(cleaned).toContain('# Getting started'); + expect(cleaned).not.toContain('align=right'); + expect(cleaned).not.toContain('custom-id'); + }); + + test('removes a standalone attr_list line', () => { + const cleaned = stripMkdocsMarkup(['Paragraph.', '', '{: .lead }', '', 'Next.'].join('\n')); + expect(cleaned).not.toContain('.lead'); + expect(cleaned).toContain('Paragraph.'); + expect(cleaned).toContain('Next.'); + }); + + test('removes TechDocs/Jinja macros and snippet includes', () => { + const markdown = [ + '{% include "snippets/note.md" %}', + 'Version: {{ version }}', + '--8<-- "snippets/prereqs.md"', + 'Keep this line.', + ].join('\n'); + const cleaned = stripMkdocsMarkup(markdown); + expect(cleaned).not.toContain('{%'); + expect(cleaned).not.toContain('{{'); + expect(cleaned).not.toContain('--8<--'); + expect(cleaned).toContain('Keep this line.'); + }); + + test('converts mkdocs admonitions to Starlight asides', () => { + const markdown = [ + '!!! note "Before you start"', + ' The first line.', + '', + ' The second line.', + '', + 'After the admonition.', + ].join('\n'); + const cleaned = stripMkdocsMarkup(markdown); + expect(cleaned).toContain(':::note[Before you start]'); + expect(cleaned).toContain('The first line.'); + expect(cleaned).toContain('The second line.'); + expect(cleaned).toContain(':::'); + expect(cleaned).not.toContain('!!!'); + expect(cleaned).toContain('After the admonition.'); + }); + + test('maps the details and warning admonition variants', () => { + const details = stripMkdocsMarkup(['??? tip "Bonus"', ' Hidden detail.'].join('\n')); + expect(details).toContain(':::tip[Bonus]'); + expect(details).toContain('Hidden detail.'); + + const warning = stripMkdocsMarkup(['!!! warning', ' Take care.'].join('\n')); + expect(warning).toContain(':::caution'); + expect(warning).toContain('Take care.'); + }); + + test('preserves attribute-looking braces inside fenced code', () => { + const markdown = [ + '```csharp', + 'public string Name { get; private set { ... } }', + 'var next = options with { ... };', + '[link](x){ .md-button }', + '!!! note', + '```', + ].join('\n'); + expect(stripMkdocsMarkup(markdown)).toBe(markdown); + }); + + test('preserves braces inside inline code spans', () => { + const markdown = 'Use `private set { ... }` and `x = { y }`.'; + expect(stripMkdocsMarkup(markdown)).toBe(markdown); + }); + + test('leaves already-converted Starlight asides untouched', () => { + const markdown = [':::note[Title]', 'Body text.', ':::'].join('\n'); + expect(stripMkdocsMarkup(markdown)).toBe(markdown); + }); + + test('is idempotent', () => { + const markdown = [ + '# Title { #id }', + '', + '[Go](guide/){ .md-button }', + '', + '!!! note "Heads up"', + ' Body.', + ].join('\n'); + const once = stripMkdocsMarkup(markdown); + expect(stripMkdocsMarkup(once)).toBe(once); + }); +});