diff --git a/.github/workflows/site-preview.yml b/.github/workflows/site-preview.yml index ccb4e3990..47b52ad83 100644 --- a/.github/workflows/site-preview.yml +++ b/.github/workflows/site-preview.yml @@ -1,6 +1,6 @@ -name: Deploy Nimbus site preview +name: Deploy docs preview -run-name: Preview Nimbus PR #${{ github.event.pull_request.number || inputs.pull_request_number }} +run-name: Deploy docs preview for PR #${{ github.event.pull_request.number || inputs.pull_request_number }} on: pull_request_target: @@ -30,6 +30,35 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 steps: + # A superseding run may start after GitHub terminated the previous runner + # before its EXIT trap completed. Clear that stale state before any + # validation in this run can fail. + - name: Clear an interrupted preview status + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || inputs.pull_request_number }} + run: | + marker="" + comment_id="$({ + gh api --paginate "repos/$GH_REPO/issues/$PULL_REQUEST_NUMBER/comments" \ + --jq ".[] | select(.user.login == \"github-actions[bot]\") | select(.body | contains(\"$marker\")) | .id" + } | tail -n 1)" + if [[ -z "$comment_id" ]]; then + exit 0 + fi + + body="$(gh api "repos/$GH_REPO/issues/comments/$comment_id" --jq .body)" + if [[ "$body" != *"🟑 Building"* ]]; then + exit 0 + fi + + updated_at="$(date -u '+%Y-%m-%d %H:%M UTC')" + body="$(jq -nr --arg body "$body" --arg updated_at "$updated_at" \ + '$body | gsub("🟑 Building"; "πŸ”΄ Failed") | sub("\\| [^|\\n]+ \\|$"; "| \($updated_at) |")')" + gh api --method PATCH "repos/$GH_REPO/issues/comments/$comment_id" -f body="$body" >/dev/null + - name: Require a trusted manual invocation if: github.event_name == 'workflow_dispatch' env: @@ -150,6 +179,9 @@ jobs: HEAD_SHA: ${{ steps.pull-request.outputs.head_sha }} PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || inputs.pull_request_number }} TRANSLATION_SCOPE: ${{ steps.translations.outputs.scope }} + TRANSLATION_SUMMARY: ${{ steps.translations.outputs.summary }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} TRUST: ${{ steps.pull-request.outputs.trust }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} @@ -158,6 +190,60 @@ jobs: repository_owner="${GITHUB_REPOSITORY%%/*}" repository_name="${GITHUB_REPOSITORY#*/}" + update_preview_comment() { + local deployment_status="$1" + local updated_at="$(date -u '+%Y-%m-%d %H:%M UTC')" + local marker="" + local docs_url="${preview_url%/}/docs" + local body + local comment_id + body="$(printf 'πŸ•΅ %s\n\n| Preview | Deployment | Components | Updated (UTC) |\n| --- | --- | --- | --- |\n| [Docs preview](%s) | [%s](%s) | %s | %s |' \ + "$marker" \ + "$docs_url" \ + "$deployment_status" \ + "$preview_url" \ + "$TRANSLATION_SUMMARY" \ + "$updated_at")" + if ! comment_id="$({ + gh api --paginate "repos/$GH_REPO/issues/$PULL_REQUEST_NUMBER/comments" \ + --jq ".[] | select(.user.login == \"github-actions[bot]\") | select(.body | contains(\"$marker\")) | .id" + } | tail -n 1)"; then + return 1 + fi + if [[ -n "$comment_id" ]]; then + gh api --method PATCH "repos/$GH_REPO/issues/comments/$comment_id" -f body="$body" >/dev/null + else + gh pr comment "$PULL_REQUEST_NUMBER" --body "$body" + fi + } + + update_preview_comment_with_retry() { + local deployment_status="$1" + local attempt + for attempt in 1 2 3; do + if update_preview_comment "$deployment_status"; then + return 0 + fi + echo "::warning::Unable to update the preview comment (attempt $attempt of 3)." + if (( attempt < 3 )); then + sleep 2 + fi + done + return 1 + } + + preview_finished=false + finalize_preview_comment() { + local exit_code=$? + trap - EXIT + if [[ "$preview_finished" != true ]]; then + if ! update_preview_comment_with_retry "πŸ”΄ Failed"; then + echo "::warning::Unable to mark the preview comment as failed." + fi + fi + exit "$exit_code" + } + if ! project_response="$( curl --silent --show-error --fail-with-body \ --header "Authorization: Bearer $VERCEL_TOKEN" \ @@ -225,8 +311,20 @@ jobs: preview_url="https://${deployment_url#https://}" echo "preview_url=$preview_url" >> "$GITHUB_OUTPUT" echo "Vercel deployment: $preview_url" + if [[ ! "$preview_url" =~ ^https://[^[:space:]]+$ ]]; then + echo "Vercel returned an invalid preview URL." >&2 + exit 1 + fi + trap finalize_preview_comment EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + if ! update_preview_comment_with_retry "🟑 Building"; then + echo "::warning::Unable to mark the preview comment as building." + fi - for _ in {1..360}; do + # Leave five minutes for the EXIT trap to update the comment before + # the 60-minute job timeout terminates the runner. + for _ in {1..330}; do if ! deployment_response="$( curl --silent --show-error --fail-with-body \ --header "Authorization: Bearer $VERCEL_TOKEN" \ @@ -260,30 +358,7 @@ jobs: echo "Timed out waiting for Vercel deployment $deployment_id." >&2 exit 1 fi - if [[ ! "$preview_url" =~ ^https://[^[:space:]]+$ ]]; then - echo "Vercel returned an invalid preview URL." >&2 - exit 1 - fi - - - name: Add the preview link to the pull request - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }} - PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || inputs.pull_request_number }} - TRANSLATION_SUMMARY: ${{ steps.translations.outputs.summary }} - TRUST: ${{ steps.pull-request.outputs.trust }} - run: | - marker="" - body="$(printf '%s\n%s' \ - "$marker" \ - "Nimbus documentation preview ($TRUST, $TRANSLATION_SUMMARY): $PREVIEW_URL")" - comment_id="$({ - gh api --paginate "repos/$GH_REPO/issues/$PULL_REQUEST_NUMBER/comments" \ - --jq ".[] | select(.user.login == \"github-actions[bot]\") | select(.body | contains(\"$marker\")) | .id" - } | tail -n 1)" - if [[ -n "$comment_id" ]]; then - gh api --method PATCH "repos/$GH_REPO/issues/comments/$comment_id" -f body="$body" >/dev/null - else - gh pr comment "$PULL_REQUEST_NUMBER" --body "$body" + if ! update_preview_comment_with_retry "🟒 Ready"; then + echo "::warning::Unable to mark the preview comment as ready." fi + preview_finished=true diff --git a/.gitignore b/.gitignore index 989e05f7f..7e7ef9d45 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ dist/ .astro/ .remote/ +.vercel-build/ .temp/ tmp/ src/generated/ diff --git a/astro.config.ts b/astro.config.ts index 73424e50f..9655d3d24 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -25,9 +25,8 @@ import { clickhouseSqlTransformer } from "./src/plugins/shiki-clickhouse-sql"; // The site is served at clickhouse.com/docs behind the website Worker. export const BASE = "/docs"; const buildScope = readScope(); -// Locale Workers share public images and Nimbus's static CSS through the -// English Worker, but their compiled chunks must never collide. The website -// router sends /docs/_astro-/* to the matching locale Worker. +// Locale build shards share public images from the English output, but their +// compiled chunks must never collide when all shards are merged. const buildAssetsDirectory = buildScope.locale === "en" ? "_astro" : `_astro-${buildScope.locale.toLowerCase()}`; @@ -39,6 +38,10 @@ const remoteMounts = (JSON.parse(fs.readFileSync(new URL("./remotes.json", impor // pnpm install does not expose those transitive links to Rolldown, so use the // vendored ESM runtime instead of relying on node_modules symlink layout. const tslibModule = fileURLToPath(new URL("./src/shims/tslib.mjs", import.meta.url)); +const activeHomepageModule = fileURLToPath(new URL( + `./src/generated/homepage/${buildScope.locale.toLowerCase()}.jsx`, + import.meta.url, +)); function markdownSnippet(relativePath: string) { const source = fs.readFileSync(new URL(relativePath, import.meta.url), "utf8") @@ -73,13 +76,16 @@ export default defineConfig({ base: BASE, output: "static", build: { assets: buildAssetsDirectory }, - publicDir: "./.remote/public-build", - // Overridable so parallel builds (CI shards, concurrent sessions) never + publicDir: process.env.DOCS_SKIP_PUBLIC === "1" ? "./.remote/public-empty" : "./.remote/public-build", + // Overridable so sequential locale shards and concurrent sessions never // share an output directory or the content-layer cache. outDir: process.env.DOCS_OUT_DIR ?? "./dist", cacheDir: process.env.DOCS_CACHE_DIR ?? "./node_modules/.astro", trailingSlash: "ignore", prefetch: { prefetchAll: true, defaultStrategy: "hover" }, + experimental: { + incrementalBuild: true, + }, markdown: { // Mermaid fences are rendered client-side (src/plugins/satteri-mermaid.ts). syntaxHighlight: { type: "shiki", excludeLangs: ["mermaid"] }, @@ -140,7 +146,7 @@ export default defineConfig({ }, plugins: [tailwindcss(), mintlifySnippets()], resolve: { - alias: { tslib: tslibModule }, + alias: { tslib: tslibModule, "@active-homepage": activeHomepageModule }, dedupe: ["react", "react-dom"], }, }, diff --git a/bin/vercel-build.ts b/bin/vercel-build.ts index bb0144b7a..4854d53dd 100644 --- a/bin/vercel-build.ts +++ b/bin/vercel-build.ts @@ -7,6 +7,10 @@ * post-processing run after every credential has been removed. */ import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { readScope, type Locale } from "../src/lib/scope.ts"; +import { localeRouteName } from "../src/util/locales.ts"; const root = process.cwd(); const credentialVariables = [ @@ -44,6 +48,35 @@ function run(command: string, args: string[], environment: NodeJS.ProcessEnv): v execFileSync(command, args, { cwd: root, env: environment, stdio: "inherit" }); } +function shardEnvironment( + environment: NodeJS.ProcessEnv, + locale: "en" | Locale, + availableLocales: Locale[], + outDir: string, +): NodeJS.ProcessEnv { + const child = { ...environment }; + delete child.DOCS_LOCALES; + child.DOCS_BUILD_SHARD = "1"; + child.DOCS_LOCALE = locale; + child.DOCS_AVAILABLE_LOCALES = availableLocales.join(",") || "none"; + child.DOCS_EMIT_ENGLISH = locale === "en" ? "true" : "false"; + child.DOCS_OUT_DIR = outDir; + child.DOCS_CACHE_DIR = path.join(root, "node_modules", ".astro", locale.toLowerCase()); + if (locale === "en") delete child.DOCS_SKIP_PUBLIC; + else child.DOCS_SKIP_PUBLIC = "1"; + return child; +} + +function copyDirectory(source: string, destination: string, merge = false): void { + if (!fs.existsSync(source)) { + throw new Error(`vercel-build: expected shard output ${path.relative(root, source)} does not exist`); + } + if (!merge && fs.existsSync(destination)) { + throw new Error(`vercel-build: refusing to overwrite merged output ${path.relative(root, destination)}`); + } + fs.cpSync(source, destination, { recursive: true, force: merge, errorOnExist: !merge }); +} + // The fetch child is the only process allowed to see the deployment OIDC // identity. It downloads bytes but never parses or imports remote-authored MDX. run(process.execPath, ["bin/fetch-remotes.ts"], { ...process.env }); @@ -54,5 +87,55 @@ const cleanEnvironment = sanitizedEnvironment(); assertCredentialFree(cleanEnvironment); run("pnpm", ["run", "prepare:site"], cleanEnvironment); -run("pnpm", ["exec", "astro", "build"], cleanEnvironment); -run(process.execPath, ["bin/postbuild.ts"], cleanEnvironment); + +const scope = readScope(root); +const finalOutDir = path.resolve(root, cleanEnvironment.DOCS_OUT_DIR ?? "dist"); +if (scope.locales.length === 0) { + const environment = shardEnvironment(cleanEnvironment, "en", [], finalOutDir); + run("pnpm", ["exec", "astro", "build"], environment); + run(process.execPath, ["bin/postbuild.ts"], environment); + process.exit(0); +} + +// Each locale gets its own process, module graph, content collection, output, +// and persistent Astro cache. Peak memory is bounded by one locale build while +// the final deployment remains a single Vercel artifact. +const shardsRoot = path.join(root, ".vercel-build", "shards"); +fs.rmSync(shardsRoot, { recursive: true, force: true }); +fs.mkdirSync(shardsRoot, { recursive: true }); +fs.mkdirSync(path.join(root, ".remote", "public-empty"), { recursive: true }); + +const englishOutDir = path.join(shardsRoot, "en"); +run( + "pnpm", + ["exec", "astro", "build"], + shardEnvironment(cleanEnvironment, "en", scope.locales, englishOutDir), +); + +const localeOutputs: Array<{ locale: Locale; outDir: string }> = []; +for (const locale of scope.locales) { + const outDir = path.join(shardsRoot, locale.toLowerCase()); + run( + "pnpm", + ["exec", "astro", "build"], + shardEnvironment(cleanEnvironment, locale, scope.locales, outDir), + ); + localeOutputs.push({ locale, outDir }); +} + +fs.rmSync(finalOutDir, { recursive: true, force: true }); +copyDirectory(englishOutDir, finalOutDir); +for (const { locale, outDir } of localeOutputs) { + copyDirectory( + path.join(outDir, localeRouteName(locale)), + path.join(finalOutDir, localeRouteName(locale)), + true, + ); + copyDirectory( + path.join(outDir, `_astro-${locale.toLowerCase()}`), + path.join(finalOutDir, `_astro-${locale.toLowerCase()}`), + ); +} + +const postbuildEnvironment = { ...cleanEnvironment, DOCS_OUT_DIR: finalOutDir }; +run(process.execPath, ["bin/postbuild.ts"], postbuildEnvironment); diff --git a/package.json b/package.json index add93a8b9..7acc0948d 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@readme/httpsnippet": "11.4.0", "@scalar/openapi-parser": "0.28.12", "@vercel/connect": "^2.0.2", - "astro": "~7.0.9", + "astro": "~7.3.2", "clsx": "^2.1.1", "mermaid": "^11.17.2", "openapi-sampler": "1.7.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da3086025..b84dacb24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,7 +24,7 @@ importers: version: 5.0.7(@types/node@24.13.3)(@types/react-dom@19.2.6(@types/react@19.2.18))(@types/react@19.2.18)(jiti@2.7.0)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(yaml@2.9.0) '@cloudflare/nimbus-docs': specifier: 0.13.1 - version: 0.13.1(patch_hash=291b76e22c5f035fd612c4f8086e9026262c2ecbe5af3de58a9603413f4838b0)(@readme/httpsnippet@11.4.0)(@scalar/openapi-parser@0.28.12)(@types/node@24.13.3)(astro@7.0.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(rollup@4.63.1)(yaml@2.9.0))(esbuild@0.28.2)(jiti@2.7.0)(openapi-sampler@1.7.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 0.13.1(patch_hash=291b76e22c5f035fd612c4f8086e9026262c2ecbe5af3de58a9603413f4838b0)(@readme/httpsnippet@11.4.0)(@scalar/openapi-parser@0.28.12)(@types/node@24.13.3)(astro@7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(esbuild@0.28.2)(jiti@2.7.0)(openapi-sampler@1.7.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fontsource-variable/inter': specifier: ^5.2.8 version: 5.3.0 @@ -50,8 +50,8 @@ importers: specifier: ^2.0.2 version: 2.0.2 astro: - specifier: ~7.0.9 - version: 7.0.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(rollup@4.63.1)(yaml@2.9.0) + specifier: ~7.3.2 + version: 7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -128,65 +128,65 @@ packages: peerDependencies: typescript: ^5.0.0 || ^6.0.0 - '@astrojs/compiler-binding-darwin-arm64@0.3.2': - resolution: {integrity: sha512-MM8tn8CSimcfytaOla4b6acN8mKWiL/rlAA1fpT3/Wl7dNGSE4y8FjTN/zJVNnb63CsLWG5zZwCt01TXtDKh9g==} + '@astrojs/compiler-binding-darwin-arm64@0.4.0': + resolution: {integrity: sha512-ZVUwHundaQyFNjE6uoa0usaC0WOCitDCLS/4mdb4rOiJXwVUuKJBMxI5WMzXLWmamsXtK/Z//ifLXvV5Yeh4Hw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@astrojs/compiler-binding-darwin-x64@0.3.2': - resolution: {integrity: sha512-2lXOlzf8xb7jLomRsf/aswh61/NnGusynB2OwFkK6k4pmOtpfXMYnG0PLfXrEvxXYj69NdCnmUYXtHDd+JOOag==} + '@astrojs/compiler-binding-darwin-x64@0.4.0': + resolution: {integrity: sha512-FI6G8AY8u6fR1SI/QRR5yGMwtvZwP34CDmZpZ5HwJGa50UM1VISTLhqkhV4a476pmgd25X1Aur2dqw6hUnrlKA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@astrojs/compiler-binding-linux-arm64-gnu@0.3.2': - resolution: {integrity: sha512-BmU3kWj7qnLrd4vzm49zFEPJ5oFnn1tCT4Vt9hZbqdU5Cmb8GZl7fn6VFsnNfe7B18a2gIFtVzbLINtYl5kBjQ==} + '@astrojs/compiler-binding-linux-arm64-gnu@0.4.0': + resolution: {integrity: sha512-lB9gLFJK7m82EnjaU8nlRBEfcwGNeHidW3sSjODTUjMNaoewVuUz9fwwdY5M4jiSXIqWLH3yl6TX8FTDKA74Sw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@astrojs/compiler-binding-linux-arm64-musl@0.3.2': - resolution: {integrity: sha512-f0heT9ZZEseSu5bHCeb80eL2DH07ArE6U9xi1WT/PEusNjzPmEr3GJsjG1tRLo5VYUUYX7h3ScaqGmGrMOVGmw==} + '@astrojs/compiler-binding-linux-arm64-musl@0.4.0': + resolution: {integrity: sha512-HPbvWqbxFxyaoQJhLxCaSjtYBx9KBo7JGVzEFZCmMl968a2PsSH0UfiODYgYPXofTOIsIH2aoCcrHXML0IA3ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@astrojs/compiler-binding-linux-x64-gnu@0.3.2': - resolution: {integrity: sha512-M8fOUt0itRpqiGyoEA/ij184s8O+hqbCz3+YozRusOOM3osgGljpDThhbKAJjqh82wOo6FioQ4w8PBvU1XMD5Q==} + '@astrojs/compiler-binding-linux-x64-gnu@0.4.0': + resolution: {integrity: sha512-tQKolMxoJ/+0AmLWm1PmJ/i+z3i10ZU1bNuVjEDulCf48azEMtUNjTZgHJ5MPtpYRNc7dlETr8QujUfduzoC7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@astrojs/compiler-binding-linux-x64-musl@0.3.2': - resolution: {integrity: sha512-/Kebk8sO6HnLeSd691JkaAPfN7CqR9/KEXmWvyNPkaKNGmj8rTZ/lf2uXtnPu92Lan84UrKdIKVPy1fSo2encQ==} + '@astrojs/compiler-binding-linux-x64-musl@0.4.0': + resolution: {integrity: sha512-5v5YymudsxMHp3NBLCS8BUlu5CRqeLtWD9cKS/4nIhIEHCbpz9okmVV6I0HWqmBAPhWYcDa3vw/vltYPrOQCTA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@astrojs/compiler-binding-wasm32-wasi@0.3.2': - resolution: {integrity: sha512-pUA6xbcOSB7DhfzIArB8BCAkFfAIqriiR7zl5zOStd6oU2G0kIKj+GUdGnyXyhfiv881Hffyk5tC0mR18sDjDw==} + '@astrojs/compiler-binding-wasm32-wasi@0.4.0': + resolution: {integrity: sha512-m/phuH3x3PREvv1OnkM44NoPh4MatUadix1fB1u5SvMLCyDTUZykDJbKnWf1cjnYmHdlB8HcjTjl6JrCqAIXcw==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@astrojs/compiler-binding-win32-arm64-msvc@0.3.2': - resolution: {integrity: sha512-ESruf+6Qkl1trHUFxI6GSf6t52j8yN2kCNSzMWdzt7V/T09tFHrYzrVaJQohb2C9bJUH76pNvX6Zb51+xCQc9Q==} + '@astrojs/compiler-binding-win32-arm64-msvc@0.4.0': + resolution: {integrity: sha512-B9zYf3okEY83kM8gydlpH2BHP00w4ifxPqlYlWrgTwuD6wnkrJDCwBlgy1q31cERjCJRXN1lrE2VmkLvFjv/6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@astrojs/compiler-binding-win32-x64-msvc@0.3.2': - resolution: {integrity: sha512-wzzVrEbOwbsLWOdEbocskjMRx2aZPxJ7ZbmL+jnpBamFwmigm+2M/wzuM6JWncocgYwLic1csSpalBh96kQKXA==} + '@astrojs/compiler-binding-win32-x64-msvc@0.4.0': + resolution: {integrity: sha512-zB0Nrv0dGc0zZWPGDRmmETTPhDRqyZjAjk+gWMlVrJX5U89obpB3VUUE1ZiHxOCN5LQojeLK6O8L/dnoHolvNQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@astrojs/compiler-binding@0.3.2': - resolution: {integrity: sha512-8w/9CWmYrAJJ8N0SY3O43ws2BgxoW6u3QsD8u2mE140lMYAlwh+tlNoUeSBq22wVheFuiBbR212l6ixZ2IIgCQ==} + '@astrojs/compiler-binding@0.4.0': + resolution: {integrity: sha512-x2RjDUuWfwLNtc3mjAdSRInwqh/rqbLar9cm/5FOMbHvmYZB7yfKewzSclAxWjIZsypJDXv1lhaP2WG+P8TK3g==} engines: {node: ^20.19.0 || >=22.12.0} - '@astrojs/compiler-rs@0.3.2': - resolution: {integrity: sha512-xlx/T7JovIKduu4ucbTQUxQ5+Q8wxkHxhLjnZk3VlJbhbQ9RLvvuDk1p2YYFYFQ5y14dVm3FGGO4isQXa4F+Tg==} + '@astrojs/compiler-rs@0.4.0': + resolution: {integrity: sha512-koVikeon1kreEy+/JzLQRy3vzHHQVOjycs4degg4vFufKApZOwMZvSSAEztYNhmcQVfNVsVZZI4cEge3cexAbQ==} engines: {node: '>=22.12.0'} '@astrojs/compiler@2.13.1': @@ -195,12 +195,12 @@ packages: '@astrojs/internal-helpers@0.10.0': resolution: {integrity: sha512-Ry2R3VPeIN4uPCSA4xQc+e+vsJXkalKpEbDc07hV+a/o5Bs2N/s/uDcPJH/05L19DKh9tAy7e6JM3YZ6Cxfezw==} - '@astrojs/internal-helpers@0.10.1': - resolution: {integrity: sha512-5phcroT/vmOOrYuuAxtkbPixy5hePtlz9i8K4OeDv3dNK6/UQRuXPOSRTxIOBbUY5Sonw2UaxjbuVc43Mcir6Q==} - '@astrojs/internal-helpers@0.10.4': resolution: {integrity: sha512-nozZSy/mKYLqe4YrqbKtdOszedAfXYCtw3wZ0d+CAjz4GqQ4L9rl1ltIL5BlgwmYVinJg/RZ0MgGuWOdlyRZlA==} + '@astrojs/internal-helpers@0.11.0': + resolution: {integrity: sha512-3rzxJ+xbo0+8YyqOzLziIN32wmsHdCjEVz2sGOpRxJ+Ben/KiLph4ItxBy1abEL+E8fkRzqjg0rfXmaHJGw9JA==} + '@astrojs/language-server@2.16.16': resolution: {integrity: sha512-KuS17AOOqH/M5mHXPmKvtp8L+NNteARC7Xjf395+9R9dtJOBndqdStwU4V+OKzYZpgZ+hliV13knzx/oMtFl7Q==} hasBin: true @@ -216,12 +216,12 @@ packages: '@astrojs/markdown-remark@7.2.4': resolution: {integrity: sha512-MvspGMynWKAjTe4/lTUdmBPHIFKNVLTCF6UlyWGogTGzNrTvjD+D4n48k7h8swxsEPKHK2TwxkZO7uoaCv1Pow==} - '@astrojs/markdown-satteri@0.3.4': - resolution: {integrity: sha512-6Lvt/bQZEBW+zzdhPblvfZEy5PGEYJaUsUqaCgwHeRPxZJL1gc9I+DRLKWJjjYTWDzVUTzXlMq4WwSK+X34CVw==} - '@astrojs/markdown-satteri@0.3.8': resolution: {integrity: sha512-n8ItpFTCmlDsVR5+rwDmehSf+jFCYLWmZiisZNGuF7xILqYhsVeBfcha4qgS2Seq3fAc9Tm58ZVrvqFQ6RQgRQ==} + '@astrojs/markdown-satteri@0.4.1': + resolution: {integrity: sha512-EniHbFNa6SQHDih7JnpPos3NWXO6hdt4raBcOosfGmlfc3gP9CI/sESA3VOf1PgJZ7SFfewlZW9Livn0JugEZg==} + '@astrojs/mdx@7.0.8': resolution: {integrity: sha512-RNuwq2ccTSi7NX9YqlR0noaWoVdOrZuJvdfWXotAzdhMVB0b0zEMLnNU+xar7le0GwTMlTObD/QAu8jeHA/3nA==} engines: {node: '>=22.12.0'} @@ -350,91 +350,46 @@ packages: cpu: [arm64] os: [darwin] - '@bruits/satteri-darwin-arm64@0.9.5': - resolution: {integrity: sha512-iw4nZgx9v30lWo/MTngQqi1pI78KI0DnkSm+lVJGYdmPLgAyDNJigVhpG42/Iq55A6c1Ll8q66ljyyRiQUxwow==} - cpu: [arm64] - os: [darwin] - '@bruits/satteri-darwin-x64@0.10.5': resolution: {integrity: sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==} cpu: [x64] os: [darwin] - '@bruits/satteri-darwin-x64@0.9.5': - resolution: {integrity: sha512-6T26Z5Kf3cFW2PSlk9p7zT7yVxvuBSiJvYyz9u8KjYwMTqZyIDOj2wDyNpxKV4+6yUVG7rddq2QwvG/8LJA2+Q==} - cpu: [x64] - os: [darwin] - '@bruits/satteri-linux-arm64-gnu@0.10.5': resolution: {integrity: sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==} cpu: [arm64] os: [linux] - '@bruits/satteri-linux-arm64-gnu@0.9.5': - resolution: {integrity: sha512-u51id17uJwNEMK9nBlICsq6U31c+XVqQueVBkwRIzZG+gMpS8TOJctt5h5Wz33Z8xnMdTd+adtACVz0yHgGuOA==} - cpu: [arm64] - os: [linux] - '@bruits/satteri-linux-arm64-musl@0.10.5': resolution: {integrity: sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==} cpu: [arm64] os: [linux] - '@bruits/satteri-linux-arm64-musl@0.9.5': - resolution: {integrity: sha512-v39HxiwGC5Rqm01HksP6+5Y+xKLPlsuVFgIgpEAo+SiQ22c+mJVhS3u7Z6ePAKdhL5NJoK1xq70kLz3L13AhpQ==} - cpu: [arm64] - os: [linux] - '@bruits/satteri-linux-x64-gnu@0.10.5': resolution: {integrity: sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==} cpu: [x64] os: [linux] - '@bruits/satteri-linux-x64-gnu@0.9.5': - resolution: {integrity: sha512-F3uO8uFp3pAP5ZGXttwvh57GS7s0lL953tnNdyI2gRyP4kOOkp6pyGojNJzCjkDvWI2Cvb9iNrKok3aqQPauAw==} - cpu: [x64] - os: [linux] - '@bruits/satteri-linux-x64-musl@0.10.5': resolution: {integrity: sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==} cpu: [x64] os: [linux] - '@bruits/satteri-linux-x64-musl@0.9.5': - resolution: {integrity: sha512-bicEqglLlz++mWyADaZoP0JY20s4vDfLjaPYgQqC+NI4zZLTOOg1T4GB8aqtc822Pqji8SQBmSrTb7CrP8i08Q==} - cpu: [x64] - os: [linux] - '@bruits/satteri-wasm32-wasi@0.10.5': resolution: {integrity: sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@bruits/satteri-wasm32-wasi@0.9.5': - resolution: {integrity: sha512-zauAuMwfPnKPUkd4AFixRFpXdgKwP2mKgxrIIo2gJzW0/ZneF9dbHnLkojSpaBnCCp7VUL1hIi5WWZvB1CqmAQ==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - '@bruits/satteri-win32-arm64-msvc@0.10.5': resolution: {integrity: sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==} cpu: [arm64] os: [win32] - '@bruits/satteri-win32-arm64-msvc@0.9.5': - resolution: {integrity: sha512-SrfE7NEsgZjBvU3c+RR6oQRu0ToXY5uVJEbieXEF0YTctIV2zAVlbaMjWLts074QCgh3a+XHWkR/lWh2VH2LUg==} - cpu: [arm64] - os: [win32] - '@bruits/satteri-win32-x64-msvc@0.10.5': resolution: {integrity: sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==} cpu: [x64] os: [win32] - '@bruits/satteri-win32-x64-msvc@0.9.5': - resolution: {integrity: sha512-5Kw9ZAtTGS8WHizyn+CJhjjfIQrw+7jcZodpmpXJjefnO15M8UexIi6JR2E5thyvsmHyhL6ZDDMUNR4bKJPd4g==} - cpu: [x64] - os: [win32] - '@capsizecss/unpack@4.0.1': resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} engines: {node: '>=18'} @@ -2021,15 +1976,6 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@rollup/pluginutils@5.4.0': - resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - '@rollup/rollup-android-arm-eabi@4.63.1': resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==} cpu: [arm] @@ -2695,12 +2641,12 @@ packages: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true - astro@7.0.9: - resolution: {integrity: sha512-WB5pA4LLQnmqjBh6EIu0z8aUV4q2/AoThgSZq57Rsp+oqqvPu7OwZ5eF+W4ku20TUTxIhiJW8dccuGvJPiW2UA==} + astro@7.3.2: + resolution: {integrity: sha512-ysTcdpGP61XZpHoMRYC/CK19DQ94qkBvzXMtXEo0gEqPNkmOU/Tv++CtWmzaI4nF2Ck8RXFdiWvdlTWa2oOr9w==} engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} hasBin: true peerDependencies: - '@astrojs/markdown-remark': 7.2.1 + '@astrojs/markdown-remark': ^7.3.0 peerDependenciesMeta: '@astrojs/markdown-remark': optional: true @@ -2823,6 +2769,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} @@ -3059,8 +3009,8 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - diff@8.0.4: - resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} engines: {node: '>=0.3.1'} dom-serializer@2.0.0: @@ -3175,9 +3125,6 @@ packages: estree-util-visit@2.0.0: resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} - estree-walker@2.0.2: - resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -3232,6 +3179,10 @@ packages: fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + find-proc@0.1.0: + resolution: {integrity: sha512-OaOpEYv2PiQ7SQ5LIrl+deA1XaWcxEjnpM6VuWXTUvn+teIXxeFTLDmu18/zDQpFmHN4o3oDBX+BT0AGwEhemg==} + engines: {node: ^20.19.0 || >=22.12.0} + flattie@1.1.1: resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} engines: {node: '>=8'} @@ -3682,6 +3633,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.2.3: + resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==} + magicast@0.5.4: resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} @@ -3917,8 +3871,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - neotraverse@0.6.18: - resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} + neotraverse@1.0.1: + resolution: {integrity: sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==} engines: {node: '>= 10'} nlcst-to-string@4.0.0: @@ -4323,9 +4277,6 @@ packages: satteri@0.10.5: resolution: {integrity: sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==} - satteri@0.9.5: - resolution: {integrity: sha512-ZuWVl+vnM64y+/TtX8Kosv2c00W+hLQiiwnEL6H0UKVVrxFqMw4D2CJHHQaouVd89OAhtBBfjWLqhKi3TVUV4w==} - sax@1.6.1: resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} @@ -5043,25 +4994,25 @@ snapshots: - prettier - prettier-plugin-astro - '@astrojs/compiler-binding-darwin-arm64@0.3.2': + '@astrojs/compiler-binding-darwin-arm64@0.4.0': optional: true - '@astrojs/compiler-binding-darwin-x64@0.3.2': + '@astrojs/compiler-binding-darwin-x64@0.4.0': optional: true - '@astrojs/compiler-binding-linux-arm64-gnu@0.3.2': + '@astrojs/compiler-binding-linux-arm64-gnu@0.4.0': optional: true - '@astrojs/compiler-binding-linux-arm64-musl@0.3.2': + '@astrojs/compiler-binding-linux-arm64-musl@0.4.0': optional: true - '@astrojs/compiler-binding-linux-x64-gnu@0.3.2': + '@astrojs/compiler-binding-linux-x64-gnu@0.4.0': optional: true - '@astrojs/compiler-binding-linux-x64-musl@0.3.2': + '@astrojs/compiler-binding-linux-x64-musl@0.4.0': optional: true - '@astrojs/compiler-binding-wasm32-wasi@0.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': + '@astrojs/compiler-binding-wasm32-wasi@0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': dependencies: '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) transitivePeerDependencies: @@ -5069,30 +5020,30 @@ snapshots: - '@emnapi/runtime' optional: true - '@astrojs/compiler-binding-win32-arm64-msvc@0.3.2': + '@astrojs/compiler-binding-win32-arm64-msvc@0.4.0': optional: true - '@astrojs/compiler-binding-win32-x64-msvc@0.3.2': + '@astrojs/compiler-binding-win32-x64-msvc@0.4.0': optional: true - '@astrojs/compiler-binding@0.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': + '@astrojs/compiler-binding@0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': optionalDependencies: - '@astrojs/compiler-binding-darwin-arm64': 0.3.2 - '@astrojs/compiler-binding-darwin-x64': 0.3.2 - '@astrojs/compiler-binding-linux-arm64-gnu': 0.3.2 - '@astrojs/compiler-binding-linux-arm64-musl': 0.3.2 - '@astrojs/compiler-binding-linux-x64-gnu': 0.3.2 - '@astrojs/compiler-binding-linux-x64-musl': 0.3.2 - '@astrojs/compiler-binding-wasm32-wasi': 0.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) - '@astrojs/compiler-binding-win32-arm64-msvc': 0.3.2 - '@astrojs/compiler-binding-win32-x64-msvc': 0.3.2 + '@astrojs/compiler-binding-darwin-arm64': 0.4.0 + '@astrojs/compiler-binding-darwin-x64': 0.4.0 + '@astrojs/compiler-binding-linux-arm64-gnu': 0.4.0 + '@astrojs/compiler-binding-linux-arm64-musl': 0.4.0 + '@astrojs/compiler-binding-linux-x64-gnu': 0.4.0 + '@astrojs/compiler-binding-linux-x64-musl': 0.4.0 + '@astrojs/compiler-binding-wasm32-wasi': 0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + '@astrojs/compiler-binding-win32-arm64-msvc': 0.4.0 + '@astrojs/compiler-binding-win32-x64-msvc': 0.4.0 transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' - '@astrojs/compiler-rs@0.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': + '@astrojs/compiler-rs@0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': dependencies: - '@astrojs/compiler-binding': 0.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + '@astrojs/compiler-binding': 0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -5110,7 +5061,7 @@ snapshots: smol-toml: 1.8.0 unified: 11.0.5 - '@astrojs/internal-helpers@0.10.1': + '@astrojs/internal-helpers@0.10.4': dependencies: '@types/hast': 3.0.5 '@types/mdast': 4.0.4 @@ -5121,7 +5072,7 @@ snapshots: smol-toml: 1.8.0 unified: 11.0.5 - '@astrojs/internal-helpers@0.10.4': + '@astrojs/internal-helpers@0.11.0': dependencies: '@types/hast': 3.0.5 '@types/mdast': 4.0.4 @@ -5179,28 +5130,27 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/markdown-satteri@0.3.4': + '@astrojs/markdown-satteri@0.3.8': dependencies: - '@astrojs/internal-helpers': 0.10.1 + '@astrojs/internal-helpers': 0.10.4 '@astrojs/prism': 4.0.2 github-slugger: 2.0.0 - hast-util-from-html: 2.0.3 - satteri: 0.9.5 + satteri: 0.10.5 - '@astrojs/markdown-satteri@0.3.8': + '@astrojs/markdown-satteri@0.4.1': dependencies: - '@astrojs/internal-helpers': 0.10.4 + '@astrojs/internal-helpers': 0.11.0 '@astrojs/prism': 4.0.2 github-slugger: 2.0.0 satteri: 0.10.5 - '@astrojs/mdx@7.0.8(@astrojs/markdown-satteri@0.3.8)(astro@7.0.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(rollup@4.63.1)(yaml@2.9.0))': + '@astrojs/mdx@7.0.8(@astrojs/markdown-satteri@0.3.8)(astro@7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@astrojs/internal-helpers': 0.10.4 '@astrojs/markdown-remark': 7.2.4 '@mdx-js/mdx': 3.1.1 acorn: 8.18.0 - astro: 7.0.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(rollup@4.63.1)(yaml@2.9.0) + astro: 7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0) es-module-lexer: 2.3.2 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -5381,39 +5331,21 @@ snapshots: '@bruits/satteri-darwin-arm64@0.10.5': optional: true - '@bruits/satteri-darwin-arm64@0.9.5': - optional: true - '@bruits/satteri-darwin-x64@0.10.5': optional: true - '@bruits/satteri-darwin-x64@0.9.5': - optional: true - '@bruits/satteri-linux-arm64-gnu@0.10.5': optional: true - '@bruits/satteri-linux-arm64-gnu@0.9.5': - optional: true - '@bruits/satteri-linux-arm64-musl@0.10.5': optional: true - '@bruits/satteri-linux-arm64-musl@0.9.5': - optional: true - '@bruits/satteri-linux-x64-gnu@0.10.5': optional: true - '@bruits/satteri-linux-x64-gnu@0.9.5': - optional: true - '@bruits/satteri-linux-x64-musl@0.10.5': optional: true - '@bruits/satteri-linux-x64-musl@0.9.5': - optional: true - '@bruits/satteri-wasm32-wasi@0.10.5': dependencies: '@emnapi/core': 1.11.1 @@ -5421,25 +5353,12 @@ snapshots: '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true - '@bruits/satteri-wasm32-wasi@0.9.5': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) - optional: true - '@bruits/satteri-win32-arm64-msvc@0.10.5': optional: true - '@bruits/satteri-win32-arm64-msvc@0.9.5': - optional: true - '@bruits/satteri-win32-x64-msvc@0.10.5': optional: true - '@bruits/satteri-win32-x64-msvc@0.9.5': - optional: true - '@capsizecss/unpack@4.0.1': dependencies: fontkitten: 1.0.3 @@ -5473,10 +5392,10 @@ snapshots: '@cloudflare/kv-asset-handler@0.5.0': {} - '@cloudflare/nimbus-docs@0.13.1(patch_hash=291b76e22c5f035fd612c4f8086e9026262c2ecbe5af3de58a9603413f4838b0)(@readme/httpsnippet@11.4.0)(@scalar/openapi-parser@0.28.12)(@types/node@24.13.3)(astro@7.0.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(rollup@4.63.1)(yaml@2.9.0))(esbuild@0.28.2)(jiti@2.7.0)(openapi-sampler@1.7.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@cloudflare/nimbus-docs@0.13.1(patch_hash=291b76e22c5f035fd612c4f8086e9026262c2ecbe5af3de58a9603413f4838b0)(@readme/httpsnippet@11.4.0)(@scalar/openapi-parser@0.28.12)(@types/node@24.13.3)(astro@7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))(esbuild@0.28.2)(jiti@2.7.0)(openapi-sampler@1.7.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@astrojs/markdown-satteri': 0.3.8 - '@astrojs/mdx': 7.0.8(@astrojs/markdown-satteri@0.3.8)(astro@7.0.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(rollup@4.63.1)(yaml@2.9.0)) + '@astrojs/mdx': 7.0.8(@astrojs/markdown-satteri@0.3.8)(astro@7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)) '@astrojs/sitemap': 3.7.2 '@clack/prompts': 0.9.1 '@iconify/tools': 5.0.14 @@ -5485,7 +5404,7 @@ snapshots: '@shikijs/transformers': 4.4.3 '@shikijs/types': 4.4.3 '@vercel/detect-agent': 1.2.3 - astro: 7.0.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(rollup@4.63.1)(yaml@2.9.0) + astro: 7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0) clsx: 2.1.1 giget: 3.3.1 github-slugger: 2.0.0 @@ -6836,14 +6755,6 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@rollup/pluginutils@5.4.0(rollup@4.63.1)': - dependencies: - '@types/estree': 1.0.9 - estree-walker: 2.0.2 - picomatch: 4.0.7 - optionalDependencies: - rollup: 4.63.1 - '@rollup/rollup-android-arm-eabi@4.63.1': optional: true @@ -7512,28 +7423,28 @@ snapshots: astring@1.9.0: {} - astro@7.0.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(rollup@4.63.1)(yaml@2.9.0): + astro@7.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0): dependencies: - '@astrojs/compiler-rs': 0.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) - '@astrojs/internal-helpers': 0.10.1 - '@astrojs/markdown-satteri': 0.3.4 + '@astrojs/compiler-rs': 0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + '@astrojs/internal-helpers': 0.11.0 + '@astrojs/markdown-satteri': 0.4.1 '@astrojs/telemetry': 3.3.3 '@capsizecss/unpack': 4.0.1 '@clack/prompts': 1.7.0 '@oslojs/encoding': 1.1.0 - '@rollup/pluginutils': 5.4.0(rollup@4.63.1) 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: 1.1.1 + cookie: 2.0.1 devalue: 5.9.2 - diff: 8.0.4 + diff: 9.0.0 dset: 3.1.4 es-module-lexer: 2.3.2 esbuild: 0.28.2 + find-proc: 0.1.0 flattie: 1.1.1 fontace: 0.4.1 get-tsconfig: 5.0.0-beta.4 @@ -7542,10 +7453,10 @@ snapshots: http-cache-semantics: 4.2.0 js-yaml: 4.3.2 jsonc-parser: 3.3.1 - magic-string: 0.30.21 + magic-string: 1.2.3 magicast: 0.5.4 mrmime: 2.0.1 - neotraverse: 0.6.18 + neotraverse: 1.0.1 obug: 2.1.4 p-limit: 7.3.2 p-queue: 9.3.3 @@ -7594,7 +7505,6 @@ snapshots: - ioredis - jiti - less - - rollup - sass - sass-embedded - stylus @@ -7694,6 +7604,8 @@ snapshots: cookie@1.1.1: {} + cookie@2.0.1: {} + cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -7954,7 +7866,7 @@ snapshots: dependencies: dequal: 2.0.3 - diff@8.0.4: {} + diff@9.0.0: {} dom-serializer@2.0.0: dependencies: @@ -8130,8 +8042,6 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/unist': 3.0.3 - estree-walker@2.0.2: {} - estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -8192,6 +8102,8 @@ snapshots: fflate@0.8.3: {} + find-proc@0.1.0: {} + flattie@1.1.1: {} fontace@0.4.1: @@ -8641,6 +8553,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + magic-string@1.2.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + magicast@0.5.4: dependencies: '@babel/parser': 7.29.8 @@ -9157,7 +9073,7 @@ snapshots: nanoid@3.3.18: {} - neotraverse@0.6.18: {} + neotraverse@1.0.1: {} nlcst-to-string@4.0.0: dependencies: @@ -9722,23 +9638,6 @@ snapshots: '@bruits/satteri-win32-arm64-msvc': 0.10.5 '@bruits/satteri-win32-x64-msvc': 0.10.5 - satteri@0.9.5: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.5 - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - optionalDependencies: - '@bruits/satteri-darwin-arm64': 0.9.5 - '@bruits/satteri-darwin-x64': 0.9.5 - '@bruits/satteri-linux-arm64-gnu': 0.9.5 - '@bruits/satteri-linux-arm64-musl': 0.9.5 - '@bruits/satteri-linux-x64-gnu': 0.9.5 - '@bruits/satteri-linux-x64-musl': 0.9.5 - '@bruits/satteri-wasm32-wasi': 0.9.5 - '@bruits/satteri-win32-arm64-msvc': 0.9.5 - '@bruits/satteri-win32-x64-msvc': 0.9.5 - sax@1.6.1: {} scheduler@0.27.0: {} diff --git a/src/README.md b/src/README.md index f01c1059c..cfdd64c7a 100644 --- a/src/README.md +++ b/src/README.md @@ -10,8 +10,8 @@ Mintlify-flavoured MDX build (see `src/plugins/vite-mintlify-snippets.ts` and | Command | What it does | |---|---| | `pnpm install` | Node 24, pnpm 10. | -| `pnpm build` | Fetches registered sources, builds the selected locale scope, then prunes `.mdx` twins, rebases URLs, generates `__redirects`, nests under `dist/docs`, and enforces the Worker asset limits. | -| `pnpm run build:vercel` | Fetches registered sources using Vercel Connect where required, removes the deployment OIDC identity, then builds the Vercel output. | +| `pnpm build` | Fetches registered sources, builds the selected locale scope, rebases URLs, generates `__redirects`, nests under `dist/docs`, and enforces the Worker asset limits. | +| `pnpm run build:vercel` | Fetches registered sources using Vercel Connect where required, removes the deployment OIDC identity, builds English and requested locales in isolated sequential Astro processes, then merges one Vercel output. | | `pnpm dev` | Astro dev server (`/docs/...`). | | `pnpm check:mdx` | Compiles every MDX file with SΓ€tteri and reports undefined components; seconds, no build. | | `pnpm measure` | Page weight, anchor parity, base-path check, URL parity vs the live Mintlify sitemap (needs a nested build in `$DOCS_OUT_DIR`). | @@ -25,7 +25,7 @@ Mintlify-flavoured MDX build (see `src/plugins/vite-mintlify-snippets.ts` and | Variable | Effect | |---|---| | `DOCS_INCLUDE` | Comma-separated globs restricting the English collection (spikes, scoped previews). | -| `DOCS_LOCALE` | The one locale Worker to build (`en`, `es`, `pt-BR`, and so on); unset = English. | +| `DOCS_LOCALE` | A singular locale build (`en`, `es`, `pt-BR`, and so on); normally set only by the Vercel shard orchestrator. | | `DOCS_LOCALES` | Translations to add to the English Vercel artifact: `none`, `all`, or a comma-separated list such as `es,fr`. Vercel production always builds `all`. | | `DOCS_REMOTES` | Registered remote-source scope: `none` for a base-repository preview and `all` for source previews and production. | | `DOCS_REMOTE_NAME`, `DOCS_REMOTE_REPOSITORY`, `DOCS_REMOTE_REF` | CI-only tuple selecting one registered remote at an immutable commit for an English pull-request preview. | @@ -33,8 +33,8 @@ Mintlify-flavoured MDX build (see `src/plugins/vite-mintlify-snippets.ts` and | `DOCS_REMOTES_PREFETCHED=1` | Requires the remote mounts and fetch-state files supplied by the credentialed CI fetch job. | | `DOCS_PREVIEW_ALIAS` | Lowercase Cloudflare alias used by `pnpm run deploy:preview`. | | `DOCS_GITHUB_CONNECTOR` | Vercel Connect GitHub connector UID, for example `github/clickhouse-docs`. Configure it only for `production` and the `connect-preview` Custom Environment. | -| `DOCS_OUT_DIR`, `DOCS_CACHE_DIR` | Isolated output and cache directories (parallel builds never share `dist/`). | -| `NODE_OPTIONS=--max-old-space-size=8192` | Recommended for full builds (peak RSS ~3 GB). | +| `DOCS_OUT_DIR`, `DOCS_CACHE_DIR` | Isolated output and cache directories. Vercel uses one persistent Astro cache per locale under `node_modules/.astro/`. | +| `NODE_OPTIONS=--max-old-space-size=8192` | Recommended for full builds. Locale processes run sequentially, so memory is bounded to one content tree at a time. | ## Layout @@ -42,7 +42,7 @@ Mintlify-flavoured MDX build (see `src/plugins/vite-mintlify-snippets.ts` and - `src/content.config.ts`: `docs` (English, path-derived ids) and one collection per locale (`es`, `pt-br`, ...). - `src/pages/[...slug].astro`, `src/pages/[locale]/[...slug].astro`: page routes (locale pages fall back to English). - `src/pages/nav/[...key].astro`: lazy sidebar fragments; `src/lib/sidebar-lazy.ts`. -- `src/pages/**/llms*.txt.ts`, `**/index.md.ts`: agent surfaces. The root `llms-full.txt` links to full-text, top-level-section `llms.txt` files; `src/lib/corpus.ts` recursively subdivides any corpus that reaches 24 MiB. +- `src/pages/[...slug].md.ts`, `src/pages/**/llms*.txt.ts`: English-only agent surfaces. Every human-language route points to the same canonical English `.md`; no generated `.mdx` or localized agent copies are emitted. The root `llms-full.txt` links to full-text, top-level-section `llms.txt` files; `src/lib/corpus.ts` recursively subdivides any corpus that reaches 24 MiB. - `src/components/compat/`: Mintlify component names on Nimbus components; `react/` shims for snippet JSX. - `bin/`: generators and measurement scripts; `worker/`: Cloudflare Worker; `wrangler.jsonc`. - `src/generated/` (gitignored): sidebar items, import index, island wrappers. @@ -97,7 +97,10 @@ more locale labels such as `docs-translations-es` and preview with the resulting locale set. `.github/workflows/site-production.yml` asks Vercel to fetch the merged `main` commit through the same Git connection and build English with every translation. Both workflows can also be invoked -manually from the default branch. +manually from the default branch. `bin/vercel-build.ts` compiles English and +each requested locale in its own sequential Astro child process, preserving a +separate incremental cache for each one, and merges the locale routes plus +their namespaced assets into one deployment. Vercel must be provisioned as follows: @@ -122,6 +125,7 @@ Vercel must be provisioned as follows: 9. Keep the Vercel build command as `pnpm run build:vercel` and the output directory as `dist`. -The website Worker routes `/docs//*` and -`/docs/_astro-/*` to `clickhouse-docs-`. The English Worker -handles the remaining `/docs/*` paths, including shared images and Nimbus assets. +The single Vercel project serves English and every selected locale. English +owns shared public files and `/docs/_astro`; each locale contributes only +`/docs/` and its `/docs/_astro-` asset namespace to the merged +artifact. diff --git a/src/components/api/ApiPage.astro b/src/components/api/ApiPage.astro index 42e403f58..83d56dcb4 100644 --- a/src/components/api/ApiPage.astro +++ b/src/components/api/ApiPage.astro @@ -37,7 +37,7 @@ const headings = [ ...(view.body.length || view.bodyUnion ? [{ depth: 2, text: "Request body", slug: "request-body" }] : []), ...(view.responses.length ? [{ depth: 2, text: "Response", slug: "response" }] : []), ]; -const markdownPath = withBase(`/${source.route}/index.md`); +const markdownPath = withBase(`/${source.route}.md`); const markdownUrl = Astro.site ? new URL(markdownPath, Astro.site).href : markdownPath; --- diff --git a/src/content.config.ts b/src/content.config.ts index afd81cb13..9001710c1 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -131,11 +131,15 @@ export type LocaleCollectionName = "ar" | "es" | "fr" | "ja" | "ko" | "pt-br" | /** * Locales come from the build scope (`DOCS_LOCALES` for a combined Vercel - * artifact, `DOCS_LOCALE` for one locale Worker, or `.preview-scope.json` for + * artifact, `DOCS_LOCALE` for one locale shard, or `.preview-scope.json` for * remote previews; see src/lib/scope.ts). Collection names deliberately avoid * the `docs-` prefix, which Nimbus reserves for versions. */ export const ACTIVE_LOCALES: string[] = [...scope.locales]; +/** Locales present in the final merged artifact and exposed by language links. */ +export const AVAILABLE_LOCALES: string[] = [...scope.availableLocales]; +/** English routes are emitted only by the English build shard. */ +export const EMIT_ENGLISH = scope.emitEnglish; /** * Collection names are lowercase even when the canonical URL segment is not. @@ -168,14 +172,18 @@ const partialSchema = z.object({ export const collections = { docs: defineCollection({ - loader: withNimbusMarkdown(glob({ base: ".", pattern: treePattern("."), generateId: pathId })), + loader: withNimbusMarkdown(glob({ + base: ".", + pattern: EMIT_ENGLISH ? treePattern(".") : "__inactive_english__/**/*.{md,mdx}", + generateId: pathId, + })), // Non-strict: the content carries Docusaurus-era keys we do not model. schema, }), changelog: defineCollection({ loader: withNimbusMarkdown(glob({ base: ".remote/changelog", - pattern: scope.remotePreview ? "__remote_preview_excludes_changelog__/**/*.mdx" : "**/*.mdx", + pattern: !EMIT_ENGLISH || scope.remotePreview ? "__inactive_changelog__/**/*.mdx" : "**/*.mdx", generateId: pathId, })), schema: changelogSchema, diff --git a/src/lib/corpus.ts b/src/lib/corpus.ts index f66f6c02d..7957e06b6 100644 --- a/src/lib/corpus.ts +++ b/src/lib/corpus.ts @@ -119,7 +119,8 @@ export function renderCorpus(entries: IndexedEntry[], title: string, indexPath: } function markdownUrl(entry: IndexedEntry): string { - return new URL(withBase(`${entry.url.replace(/\/+$/, "")}/index.md`), config.site).href; + const route = entry.url.replace(/\/+$/, ""); + return new URL(withBase(route === "/" ? "/index.md" : `${route}.md`), config.site).href; } function corpusUrl(pathname: string): string { diff --git a/src/lib/locale-content.server.ts b/src/lib/locale-content.server.ts new file mode 100644 index 000000000..1b23ff741 --- /dev/null +++ b/src/lib/locale-content.server.ts @@ -0,0 +1,21 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** Content ids that have an authored translation for one locale. */ +export function translatedPageIds(locale: string, sections: readonly string[]): Set { + const ids = new Set(); + const localeRoot = path.join(process.cwd(), locale); + const visit = (directory: string): void => { + if (!fs.existsSync(directory)) return; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) visit(file); + else if (/\.mdx?$/.test(entry.name)) { + const relative = path.relative(localeRoot, file).replaceAll(path.sep, "/"); + ids.add(relative.replace(/\.mdx?$/, "").replace(/\/index$/, "")); + } + } + }; + for (const section of sections) visit(path.join(localeRoot, section)); + return ids; +} diff --git a/src/lib/openapi.ts b/src/lib/openapi.ts index ad1fef76e..55723bba4 100644 --- a/src/lib/openapi.ts +++ b/src/lib/openapi.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { createHash } from "node:crypto"; import { buildApiModel, getApiPageProps, @@ -103,6 +104,18 @@ export interface ApiOperationPage { badge?: string; } +const documentDigestCache = new WeakMap(); + +/** Invalidate every operation when its shared OpenAPI document changes. */ +export function apiPageCacheKey(page: ApiOperationPage): string { + let digest = documentDigestCache.get(page.document); + if (!digest) { + digest = createHash("sha256").update(JSON.stringify(page.document)).digest("hex"); + documentDigestCache.set(page.document, digest); + } + return `${digest}:${page.method}:${page.path}`; +} + const COLLECTIONS: Record = { cloud: { file: ".remote/specs/cloud-openapi.json", @@ -287,7 +300,7 @@ export async function getRoutedApiOperation( view: { ...page, href, - markdownHref: `${href}/index.md`, + markdownHref: `${href}.md`, }, }; } diff --git a/src/lib/scope.ts b/src/lib/scope.ts index 9be18a67e..a52c726c8 100644 --- a/src/lib/scope.ts +++ b/src/lib/scope.ts @@ -1,11 +1,11 @@ /** - * Build scope for Vercel's combined site and independently deployed locale - * Workers. + * Build scope for Vercel's combined site and its isolated locale build shards. * * `DOCS_LOCALES` keeps English active and adds zero, one, several, or every * translated collection to the same Vercel artifact. Vercel production always * builds every translation, even when the variable is omitted. `DOCS_LOCALE` - * retains the singular build contract used by locale Workers. + * selects one internal child build; `bin/vercel-build.ts` runs those children + * sequentially and merges their outputs. * * { * "locale": "en", @@ -36,10 +36,14 @@ export interface RemotePreview { } export interface BuildScope { - /** Primary locale; non-English only for a singular locale Worker build. */ + /** Primary locale; non-English only inside a locale build shard. */ locale: BuildLocale; /** Non-English collections included in this artifact. */ locales: Locale[]; + /** Locales linked by the shared chrome, even when built in another shard. */ + availableLocales: Locale[]; + /** Whether this shard emits the English routes and English-only surfaces. */ + emitEnglish: boolean; /** Whether `reference/**` is part of the build. */ reference: boolean; /** Whether registered remote sources participate in this build. */ @@ -150,6 +154,8 @@ function parseRemotes(value: unknown, source: string): boolean { export function readScope(root = process.cwd()): BuildScope { const envLocale = (process.env.DOCS_LOCALE ?? "").trim(); const envLocales = (process.env.DOCS_LOCALES ?? "").trim(); + const envAvailableLocales = (process.env.DOCS_AVAILABLE_LOCALES ?? "").trim(); + const envEmitEnglish = (process.env.DOCS_EMIT_ENGLISH ?? "").trim(); const envRemotes = (process.env.DOCS_REMOTES ?? "").trim(); const envReference = (process.env.DOCS_REFERENCE ?? "").trim().toLowerCase(); const envRemoteName = (process.env.DOCS_REMOTE_NAME ?? "").trim(); @@ -183,6 +189,7 @@ export function readScope(root = process.cwd()): BuildScope { .trim() .toLowerCase(); const isVercelProduction = process.env.VERCEL === "1" && vercelTarget === "production"; + const isBuildShard = process.env.DOCS_BUILD_SHARD === "1"; const locale = envLocales ? "en" @@ -197,7 +204,7 @@ export function readScope(root = process.cwd()): BuildScope { ? [] : [locale]; - if (isVercelProduction) { + if (isVercelProduction && !isBuildShard) { if (locale !== "en") { throw new Error("Vercel production builds use DOCS_LOCALES=all, not DOCS_LOCALE"); } @@ -206,6 +213,15 @@ export function readScope(root = process.cwd()): BuildScope { } locales = [...ALL_LOCALES]; } + const availableLocales = envAvailableLocales + ? parseLocales(envAvailableLocales, "DOCS_AVAILABLE_LOCALES") + : [...locales]; + const emitEnglish = envEmitEnglish + ? parseReference(envEmitEnglish, "DOCS_EMIT_ENGLISH") + : locale === "en"; + if (!emitEnglish && locale === "en") { + throw new Error("DOCS_EMIT_ENGLISH=false requires a non-English DOCS_LOCALE shard"); + } const reference = envReference ? parseReference(envReference, "DOCS_REFERENCE") : fileScope?.reference !== undefined @@ -237,10 +253,12 @@ export function readScope(root = process.cwd()): BuildScope { if (isVercelProduction && !remotes) { throw new Error("Vercel production builds require DOCS_REMOTES=all"); } - if (envLocale || envLocales || envRemotes || envReference || hasRemoteEnvironment) source = "env"; + if (envLocale || envLocales || envAvailableLocales || envEmitEnglish || envRemotes || envReference || hasRemoteEnvironment) source = "env"; return { locale, locales, + availableLocales, + emitEnglish, reference, remotes, remotePreview, diff --git a/src/lib/sidebar-lazy.ts b/src/lib/sidebar-lazy.ts index e22b2ec86..f5fd48308 100644 --- a/src/lib/sidebar-lazy.ts +++ b/src/lib/sidebar-lazy.ts @@ -41,6 +41,74 @@ function siblingKeys(labels: string[]): string[] { export type LazyGroup = { key: string; label: string; items: ConfigItem[]; path: string[] }; +type ConfigGroup = Extract; +type NavigationLocation = { + tabIndex: number; + railItems: ConfigItem[]; + railPath: string[]; +}; +type NavigationIndex = { + locations: Map; + tabs: ConfigGroup[]; + firstLinks: string[]; +}; + +const navigationIndexes = new WeakMap(); +const normPath = (value: string): string => value.replace(/\/+$/, "") || "/"; + +function internalLinks(nodes: ConfigItem[]): string[] { + const links: string[] = []; + for (const node of nodes) { + if ("items" in node) links.push(...internalLinks(node.items)); + else if (!/^(https?:)?\/\//.test(node.link)) links.push(normPath(withBase(node.link))); + } + return links; +} + +/** Index every page's rail selection once instead of rescanning the tree per page. */ +function navigationIndex(items: ConfigItem[]): NavigationIndex { + const cached = navigationIndexes.get(items); + if (cached) return cached; + + const tabs = items.filter((item): item is ConfigGroup => "items" in item); + const locations = new Map(); + tabs.forEach((tab, tabIndex) => { + const tabLocation = { tabIndex, railItems: tab.items, railPath: [] }; + for (const link of internalLinks(tab.items)) locations.set(link, tabLocation); + + const topGroups = tab.items.filter((item): item is ConfigGroup => "items" in item); + const topKeys = siblingKeys(topGroups.map((group) => group.label)); + topGroups.forEach((topGroup, topIndex) => { + const topLocation = { + tabIndex, + railItems: topGroup.items, + railPath: [topKeys[topIndex]], + }; + for (const link of internalLinks(topGroup.items)) locations.set(link, topLocation); + + if (tab.label !== "Solutions") return; + const productGroups = topGroup.items.filter((item): item is ConfigGroup => "items" in item); + const productKeys = siblingKeys(productGroups.map((group) => group.label)); + productGroups.forEach((productGroup, productIndex) => { + const productLocation = { + tabIndex, + railItems: productGroup.items, + railPath: [topKeys[topIndex], productKeys[productIndex]], + }; + for (const link of internalLinks(productGroup.items)) locations.set(link, productLocation); + }); + }); + }); + + const index = { + locations, + tabs, + firstLinks: tabs.map((tab) => internalLinks(tab.items)[0] ?? "/"), + }; + navigationIndexes.set(items, index); + return index; +} + /** Every group in the config tree with its fragment key. Tabs are skipped. */ export function collectGroups(items: ConfigItem[], path: string[] = [], out: LazyGroup[] = [], depth = 0): LazyGroup[] { const groups = items.filter((i): i is Extract => "items" in i); @@ -108,44 +176,19 @@ export function assignLazyKeys(items: T[], path: string[] * lazy fragments (`/nav//...`). */ export function buildRailFromConfig(items: ConfigItem[], currentPath: string, keyPrefix: string[] = []): SidebarItem[] { - const norm = (p: string) => p.replace(/\/+$/, "") || "/"; - const target = norm(currentPath); - const tabs = items.filter((i): i is Extract => "items" in i); - const contains = (nodes: ConfigItem[]): boolean => - nodes.some((n) => ("items" in n ? contains(n.items) : norm(withBase(n.link)) === target)); - const tab = tabs.find((t) => contains(t.items)) ?? tabs[0]; - if (!tab) return []; - - const groupsWithKeys = (nodes: ConfigItem[]) => { - const groups = nodes.filter((i): i is Extract => "items" in i); - const keys = siblingKeys(groups.map((group) => group.label)); - return groups.map((group, index) => ({ group, key: keys[index] })); - }; - - // Database, Integrations, and Resources expose their direct groups in the - // top menu. Solutions adds a presentational section level (ClickHouse Cloud - // and Open source), so select its actual product entry one level deeper. - const topMatch = groupsWithKeys(tab.items).find(({ group }) => contains(group.items)); - let railItems = tab.items; - let railPath = keyPrefix; - if (topMatch) { - railItems = topMatch.group.items; - railPath = [...keyPrefix, topMatch.key]; - if (tab.label === "Solutions") { - const productMatch = groupsWithKeys(topMatch.group.items).find(({ group }) => contains(group.items)); - if (productMatch) { - railItems = productMatch.group.items; - railPath = [...railPath, productMatch.key]; - } - } - } + const target = normPath(currentPath); + const index = navigationIndex(items); + const location = index.locations.get(target); + const railItems = location?.railItems ?? index.tabs[0]?.items; + if (!railItems) return []; + const railPath = [...keyPrefix, ...(location?.railPath ?? [])]; const rendered = toRendered(railItems, railPath); const mark = (nodes: SidebarItem[]): boolean => { let any = false; for (const n of nodes) { if (n.type === "link") { - if (norm(n.href) === target) { (n as { isCurrent?: boolean }).isCurrent = true; any = true; } + if (normPath(n.href) === target) { (n as { isCurrent?: boolean }).isCurrent = true; any = true; } } else if (n.type === "group") { const hit = mark(n.children); if (hit) { n.collapsed = false; any = true; } @@ -159,18 +202,12 @@ export function buildRailFromConfig(items: ConfigItem[], currentPath: string, ke /** Top-level sections (tabs) for the header, from the generated config tree. */ export function sectionsFromConfig(items: ConfigItem[], currentPath: string): Array<{ label: string; href: string; isActive: boolean }> { - const norm = (p: string) => p.replace(/\/+$/, "") || "/"; - const target = norm(currentPath); - const firstLink = (nodes: ConfigItem[]): string | undefined => { - for (const n of nodes) { - if ("items" in n) { const l = firstLink(n.items); if (l) return l; } - else if (!/^(https?:)?\/\//.test(n.link)) return n.link; - } - return undefined; - }; - const contains = (nodes: ConfigItem[]): boolean => - nodes.some((n) => ("items" in n ? contains(n.items) : norm(withBase(n.link)) === target)); - return items - .filter((i): i is Extract => "items" in i) - .map((t) => ({ label: t.label, href: withBase(firstLink(t.items) ?? "/"), isActive: contains(t.items) })); + const target = normPath(currentPath); + const index = navigationIndex(items); + const activeTab = index.locations.get(target)?.tabIndex ?? -1; + return index.tabs.map((tab, tabIndex) => ({ + label: tab.label, + href: index.firstLinks[tabIndex], + isActive: tabIndex === activeTab, + })); } diff --git a/src/lib/sidebar-navigation.server.ts b/src/lib/sidebar-navigation.server.ts new file mode 100644 index 000000000..a70227f4e --- /dev/null +++ b/src/lib/sidebar-navigation.server.ts @@ -0,0 +1,30 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import type { ConfigItem } from "./sidebar-lazy"; + +interface NavigationData { + items: ConfigItem[]; + digest: string; +} + +const cache = new Map(); + +/** Read and parse one generated navigation tree once for the entire build. */ +export function loadGeneratedNavigation(locale: string): NavigationData { + const key = locale.toLowerCase(); + const cached = cache.get(key); + if (cached) return cached; + + const localized = path.join(process.cwd(), "src/generated", `sidebar.items.${locale}.json`); + const english = path.join(process.cwd(), "src/generated", "sidebar.items.json"); + const file = fs.existsSync(localized) ? localized : english; + const source = fs.readFileSync(file, "utf8"); + const data = { + items: JSON.parse(source) as ConfigItem[], + digest: createHash("sha256").update(source).digest("hex"), + }; + cache.set(key, data); + return data; +} diff --git a/src/pages/[...corpus]/llms.txt.ts b/src/pages/[...corpus]/llms.txt.ts index 6d4704c0d..87e1deb98 100644 --- a/src/pages/[...corpus]/llms.txt.ts +++ b/src/pages/[...corpus]/llms.txt.ts @@ -1,5 +1,7 @@ import { config } from "virtual:nimbus/config"; import { buildCorpusShards, englishCorpusEntries } from "../../lib/corpus"; +import { EMIT_ENGLISH } from "../../content.config"; +import { createHash } from "node:crypto"; export const prerender = true; @@ -8,9 +10,11 @@ interface Props { } export async function getStaticPaths() { + if (!EMIT_ENGLISH) return []; return buildCorpusShards(await englishCorpusEntries(), config.title).map((shard) => ({ params: { corpus: shard.path }, props: { body: shard.body }, + cacheKey: createHash("sha256").update(shard.body).digest("hex"), })); } diff --git a/src/pages/[...slug].astro b/src/pages/[...slug].astro index 25a08d4fc..01b92b18f 100644 --- a/src/pages/[...slug].astro +++ b/src/pages/[...slug].astro @@ -13,7 +13,7 @@ import { import { components } from "../components"; import { withBase, stripBase } from "../lib/base"; import { buildRailFromConfig, type ConfigItem } from "../lib/sidebar-lazy"; -import { ACTIVE_LOCALES, localeCollectionName } from "../content.config"; +import { AVAILABLE_LOCALES, EMIT_ENGLISH, localeCollectionName } from "../content.config"; import { localeRouteName } from "../util/locales"; import { getRemoteEditUrl } from "../lib/remotes"; import navigation from "../generated/sidebar.items.json"; @@ -21,6 +21,7 @@ import type { GetStaticPaths } from "astro"; export const prerender = true; export const getStaticPaths: GetStaticPaths = async (options) => { + if (!EMIT_ENGLISH) return []; const paths = await getDocsStaticPaths(options); return paths.filter(({ params }) => { const slug = params.slug; @@ -69,17 +70,17 @@ const toc = tocOn && effectiveTocConfig !== false ? getTOC(headings, effectiveTo const layoutMode = entry.data.mode === "custom" || entry.data.mode === "wide" ? entry.data.mode : "doc"; const data = entry.data as { title?: string; sidebarTitle?: string }; const pageTitle = data.title ?? data.sidebarTitle ?? entry.id.split("/").pop() ?? entry.id; -const markdownPath = withBase(`/${entry.id}/index.md`); +const markdownPath = withBase(`/${entry.id}.md`); const markdownUrl = Astro.site ? new URL(markdownPath, Astro.site).href : markdownPath; // No per-page OG cards yet (canvaskit); fall back to the site image. const socialImage = entry.data.socialImage; // hreflang alternates for the translated (or English-fallback) copies of this page. const HREFLANG: Record = { "pt-br": "pt-BR" }; -const alternates = Astro.site && ACTIVE_LOCALES.length && entry.id !== "index" +const alternates = Astro.site && AVAILABLE_LOCALES.length && entry.id !== "index" ? [ { hreflang: "en", href: new URL(withBase(`/${entry.id}/`), Astro.site).href }, { hreflang: "x-default", href: new URL(withBase(`/${entry.id}/`), Astro.site).href }, - ...ACTIVE_LOCALES.map((l) => ({ hreflang: HREFLANG[localeCollectionName(l)] ?? l, href: new URL(withBase(`/${localeRouteName(l)}/${entry.id}/`), Astro.site!).href })), + ...AVAILABLE_LOCALES.map((l) => ({ hreflang: HREFLANG[localeCollectionName(l)] ?? l, href: new URL(withBase(`/${localeRouteName(l)}/${entry.id}/`), Astro.site!).href })), ] : []; --- @@ -107,7 +108,7 @@ const alternates = Astro.site && ACTIVE_LOCALES.length && entry.id !== "index" lang="en" alternates={alternates} currentLocale="en" - activeLocales={ACTIVE_LOCALES} + activeLocales={AVAILABLE_LOCALES} > diff --git a/src/pages/[...slug].md.ts b/src/pages/[...slug].md.ts index 35be373ec..eff4a37b8 100644 --- a/src/pages/[...slug].md.ts +++ b/src/pages/[...slug].md.ts @@ -1,4 +1,4 @@ -/** Convenience alias: `/.md` serves the same artifact as `//index.md`. */ +/** Canonical agent-readable representation for every English documentation page. */ import { getPreparedMarkdownArtifact, getPreparedMarkdownStaticPaths, @@ -6,6 +6,7 @@ import { } from "@cloudflare/nimbus-docs/build"; import { prepareAgentMarkdown } from "../lib/agent-links"; import { withBase } from "../lib/base"; +import { EMIT_ENGLISH } from "../content.config"; export const prerender = true; @@ -14,14 +15,11 @@ interface Props { } export async function getStaticPaths() { + if (!EMIT_ENGLISH) return []; return (await getPreparedMarkdownStaticPaths({ collection: "docs", surface: "markdown", - })).filter( - ({ props }) => - props.artifact.id !== "index" && - !props.artifact.id.startsWith("products/cloud/api-reference/"), - ); + })).filter(({ props }) => !props.artifact.id.startsWith("products/cloud/api-reference/")); } export async function GET({ props }: { props: Props }) { diff --git a/src/pages/[...slug].mdx.ts b/src/pages/[...slug].mdx.ts deleted file mode 100644 index 886b068bf..000000000 --- a/src/pages/[...slug].mdx.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** Convenience alias: `/.mdx` serves the same source as `//index.mdx`. */ -import { - getPreparedMarkdownArtifact, - getPreparedMarkdownStaticPaths, - type PreparedMarkdownReference, -} from "@cloudflare/nimbus-docs/build"; - -export const prerender = true; - -interface Props { - artifact: PreparedMarkdownReference; -} - -export async function getStaticPaths() { - return (await getPreparedMarkdownStaticPaths({ - collection: "docs", - surface: "source", - })).filter( - ({ props }) => - props.artifact.id !== "index" && - !props.artifact.id.startsWith("products/cloud/api-reference/"), - ); -} - -export async function GET({ props }: { props: Props }) { - const artifact = await getPreparedMarkdownArtifact(props.artifact); - return new Response(artifact.body, { - headers: { "Content-Type": artifact.mediaType }, - }); -} diff --git a/src/pages/[...slug]/index.md.ts b/src/pages/[...slug]/index.md.ts deleted file mode 100644 index 2eba1239e..000000000 --- a/src/pages/[...slug]/index.md.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - getPreparedMarkdownArtifact, - getPreparedMarkdownStaticPaths, - type PreparedMarkdownReference, -} from "@cloudflare/nimbus-docs/build"; -import { prepareAgentMarkdown } from "../../lib/agent-links"; -import { withBase } from "../../lib/base"; - -export const prerender = true; - -interface Props { - artifact: PreparedMarkdownReference; -} - -export async function getStaticPaths() { - return (await getPreparedMarkdownStaticPaths({ - collection: "docs", - surface: "markdown", - })).filter(({ props }) => !props.artifact.id.startsWith("products/cloud/api-reference/")); -} - -export async function GET({ props }: { props: Props }) { - const artifact = await getPreparedMarkdownArtifact(props.artifact); - const pagePath = withBase(artifact.id === "index" ? "/" : `/${artifact.id}/`); - return new Response(prepareAgentMarkdown(artifact.body, pagePath), { - headers: { "Content-Type": artifact.mediaType }, - }); -} diff --git a/src/pages/[...slug]/index.mdx.ts b/src/pages/[...slug]/index.mdx.ts deleted file mode 100644 index 3a4e0f29c..000000000 --- a/src/pages/[...slug]/index.mdx.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - getPreparedMarkdownArtifact, - getPreparedMarkdownStaticPaths, - type PreparedMarkdownReference, -} from "@cloudflare/nimbus-docs/build"; - -export const prerender = true; - -interface Props { - artifact: PreparedMarkdownReference; -} - -export async function getStaticPaths() { - return (await getPreparedMarkdownStaticPaths({ - collection: "docs", - surface: "source", - })).filter(({ props }) => !props.artifact.id.startsWith("products/cloud/api-reference/")); -} - -export async function GET({ props }: { props: Props }) { - const artifact = await getPreparedMarkdownArtifact(props.artifact); - return new Response(artifact.body, { - headers: { "Content-Type": artifact.mediaType }, - }); -} diff --git a/src/pages/[locale]/[...slug].astro b/src/pages/[locale]/[...slug].astro index 23e7a3f8d..49d1a073e 100644 --- a/src/pages/[locale]/[...slug].astro +++ b/src/pages/[locale]/[...slug].astro @@ -19,41 +19,60 @@ import { components } from "../../components"; import { getCollection } from "astro:content"; import { Aside } from "@/components/ui/aside"; import { withBase, stripBase } from "../../lib/base"; -import { buildRailFromConfig, sectionsFromConfig, type ConfigItem } from "../../lib/sidebar-lazy"; -import fs from "node:fs"; -import path from "node:path"; -import { ACTIVE_LOCALES, localeCollectionName, type LocaleCollectionName } from "../../content.config"; +import { buildRailFromConfig, sectionsFromConfig } from "../../lib/sidebar-lazy"; +import { + ACTIVE_LOCALES, + AVAILABLE_LOCALES, + EMIT_ENGLISH, + SECTIONS, + localeCollectionName, + type LocaleCollectionName, +} from "../../content.config"; import { localeRouteName } from "../../util/locales"; import { getRemoteEditUrl } from "../../lib/remotes"; +import { loadGeneratedNavigation } from "../../lib/sidebar-navigation.server"; +import { translatedPageIds } from "../../lib/locale-content.server"; export const prerender = true; export async function getStaticPaths() { const paths: Array<{ params: { locale: string; slug: string | undefined }; props: unknown; cacheKey?: string }> = []; - const english = await getCollection("docs"); for (const sourceLocale of ACTIVE_LOCALES) { const collection = localeCollectionName(sourceLocale); const locale = localeRouteName(sourceLocale); + const navigationDigest = loadGeneratedNavigation(sourceLocale).digest; const localePaths = (await getCollectionStaticPaths(collection)({} as never)) as Array<{ params: { slug?: string }; - props: { entry: { id: string } }; + props: { entry: { id: string; digest?: string } }; cacheKey?: string; }>; - const translated = new Set(); for (const p of localePaths) { - translated.add(p.props.entry.id); - paths.push({ params: { locale, slug: p.params.slug }, props: p.props, cacheKey: p.cacheKey }); - } - // English fallback: every English page exists under every locale prefix. - for (const e of english) { - if (translated.has(e.id) || e.id === "index") continue; paths.push({ - params: { locale, slug: e.id }, - props: { entry: e, fallback: true }, - cacheKey: `fallback:${String((e as { digest?: string }).digest ?? e.id)}`, + params: { locale, slug: p.params.slug }, + props: p.props, + cacheKey: `${p.cacheKey ?? p.props.entry.digest ?? p.props.entry.id}:nav:${navigationDigest}`, }); } } + + // The English shard alone emits missing-translation fallbacks. Locale + // shards therefore compile only their translated collection, never English. + if (EMIT_ENGLISH && AVAILABLE_LOCALES.length) { + const english = await getCollection("docs"); + for (const sourceLocale of AVAILABLE_LOCALES) { + const locale = localeRouteName(sourceLocale); + const translated = translatedPageIds(sourceLocale, SECTIONS); + const navigationDigest = loadGeneratedNavigation(sourceLocale).digest; + for (const entry of english) { + if (translated.has(entry.id) || entry.id === "index") continue; + paths.push({ + params: { locale, slug: entry.id }, + props: { entry, fallback: true }, + cacheKey: `fallback:${String(entry.digest ?? entry.id)}:nav:${navigationDigest}`, + }); + } + } + } return paths; } @@ -69,9 +88,7 @@ const { sidebar: sidebarOn, tableOfContents: tocOn } = await getRouteFlags(entry // Locale rail: from the locale's generated navigation (labels and // // links), falling back to the English tree when a locale file is absent. -const localeItemsFile = path.join(process.cwd(), "src/generated", `sidebar.items.${ACTIVE_LOCALES.find((l) => localeCollectionName(l) === localeCode) ?? localeParam}.json`); -const englishItemsFile = path.join(process.cwd(), "src/generated", "sidebar.items.json"); -const navItems = JSON.parse(fs.readFileSync(fs.existsSync(localeItemsFile) ? localeItemsFile : englishItemsFile, "utf8")) as ConfigItem[]; +const navItems = loadGeneratedNavigation(localeParam).items; const sidebar = sidebarOn ? buildRailFromConfig(navItems, browserPath, [localeParam]) : false; const sections = sectionsFromConfig(navItems, browserPath); const prevNext = await getPrevNext(browserPath, { @@ -95,7 +112,7 @@ const layoutMode = entry.data.mode === "custom" ? "custom" : "doc"; const data = entry.data as { title?: string; sidebarTitle?: string }; const pageTitle = data.title ?? data.sidebarTitle ?? entry.id.split("/").pop() ?? entry.id; const markdownUrl = Astro.site - ? new URL(withBase(`/${localeParam}/${entry.id}/index.md`), Astro.site).href + ? new URL(withBase(`/${entry.id}.md`), Astro.site).href : undefined; // Untranslated pages point search engines at the English original. const canonicalUrl = fallback && Astro.site ? new URL(withBase(`/${entry.id}/`), Astro.site).href : undefined; @@ -105,7 +122,7 @@ const alternates = Astro.site ? [ { hreflang: "en", href: new URL(withBase(`/${entry.id}/`), Astro.site).href }, { hreflang: "x-default", href: new URL(withBase(`/${entry.id}/`), Astro.site).href }, - ...ACTIVE_LOCALES.map((l) => ({ hreflang: HREFLANG[localeCollectionName(l)] ?? l, href: new URL(withBase(`/${localeRouteName(l)}/${entry.id}/`), Astro.site!).href })), + ...AVAILABLE_LOCALES.map((l) => ({ hreflang: HREFLANG[localeCollectionName(l)] ?? l, href: new URL(withBase(`/${localeRouteName(l)}/${entry.id}/`), Astro.site!).href })), ] : []; const htmlLang = HREFLANG[localeCode] ?? localeParam; @@ -137,7 +154,7 @@ const fallbackNotice = NOT_TRANSLATED[localeCode] ?? { label: "Note", title: "No canonicalUrl={canonicalUrl} sections={sections} currentLocale={localeParam} - activeLocales={ACTIVE_LOCALES} + activeLocales={AVAILABLE_LOCALES} searchable={entry.data.searchable} noindex={entry.data.noindex} markdownUrl={markdownUrl} diff --git a/src/pages/[locale]/[...slug]/index.md.ts b/src/pages/[locale]/[...slug]/index.md.ts deleted file mode 100644 index 693a5a4d2..000000000 --- a/src/pages/[locale]/[...slug]/index.md.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Markdown twins for locale pages: `///index.md`. Untranslated - * (fallback) pages have no twin of their own; the English twin is canonical. - */ -import { cleanMarkdown, entriesFor, type IndexedEntry } from "../../../lib/corpus"; -import { config } from "virtual:nimbus/config"; -import { withBase } from "../../../lib/base"; -import { ACTIVE_LOCALES, localeCollectionName } from "../../../content.config"; -import { localeRouteName } from "../../../util/locales"; -import { prepareAgentMarkdown } from "../../../lib/agent-links"; - -export const prerender = true; - -interface Props { - item: IndexedEntry; -} - -export async function getStaticPaths() { - const paths: Array<{ params: { locale: string; slug: string }; props: Props }> = []; - for (const sourceLocale of ACTIVE_LOCALES) { - const locale = localeRouteName(sourceLocale); - for (const item of await entriesFor(localeCollectionName(sourceLocale))) { - paths.push({ params: { locale, slug: item.entry.id }, props: { item } }); - } - } - return paths; -} - -export async function GET({ props, params }: { props: Props; params: { locale: string } }) { - const { item } = props; - const { entry, title, description } = item; - const markdown = cleanMarkdown(entry); - const body = [ - "---", - `title: ${JSON.stringify(title)}`, - ...(description ? [`description: ${JSON.stringify(description)}`] : []), - `lang: ${JSON.stringify(params.locale)}`, - "---", - "", - "> Documentation Index", - "> A complete documentation index can be fetched from [https://clickhouse.com/docs/llms.txt](https://clickhouse.com/docs/llms.txt)", - "", - `# ${title}`, - "", - markdown, - "", - `Source: ${new URL(withBase(`/${params.locale}/${entry.id}/`), config.site).href}`, - "", - ].join("\n"); - const pagePath = withBase(`/${params.locale}/${entry.id}/`); - return new Response(prepareAgentMarkdown(body, pagePath), { - headers: { "Content-Type": "text/markdown; charset=utf-8" }, - }); -} diff --git a/src/pages/[locale]/[section]/llms-full.txt.ts b/src/pages/[locale]/[section]/llms-full.txt.ts deleted file mode 100644 index e6831bcc0..000000000 --- a/src/pages/[locale]/[section]/llms-full.txt.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Per-locale, per-section corpus: //
/llms-full.txt -import { config } from "virtual:nimbus/config"; -import { entriesFor, renderCorpus, sectionOf, sectionsOf } from "../../../lib/corpus"; -import { ACTIVE_LOCALES, localeCollectionName } from "../../../content.config"; -import { localeRouteName } from "../../../util/locales"; - -export const prerender = true; - -export async function getStaticPaths() { - const paths: Array<{ params: { locale: string; section: string } }> = []; - for (const sourceLocale of ACTIVE_LOCALES) { - const locale = localeRouteName(sourceLocale); - for (const section of sectionsOf(await entriesFor(localeCollectionName(sourceLocale)))) paths.push({ params: { locale, section } }); - } - return paths; -} - -export async function GET({ params }: { params: { locale: string; section: string } }) { - const entries = (await entriesFor(localeCollectionName(params.locale))).filter((i) => sectionOf(i) === params.section); - return new Response(renderCorpus(entries, `${config.title} (${params.locale}) / ${params.section}`, `/${params.locale}/llms.txt`), { - headers: { "Content-Type": "text/plain; charset=utf-8" }, - }); -} diff --git a/src/pages/[locale]/index.astro b/src/pages/[locale]/index.astro index 6c3a36cf6..17ff5c624 100644 --- a/src/pages/[locale]/index.astro +++ b/src/pages/[locale]/index.astro @@ -6,22 +6,18 @@ */ import BaseLayout from "../../layouts/BaseLayout.astro"; import Header from "../../components/Header.astro"; -import HomepageAr from "../../generated/homepage/ar.jsx"; -import HomepageEs from "../../generated/homepage/es.jsx"; -import HomepageFr from "../../generated/homepage/fr.jsx"; -import HomepageJa from "../../generated/homepage/ja.jsx"; -import HomepageKo from "../../generated/homepage/ko.jsx"; -import HomepagePtBr from "../../generated/homepage/pt-br.jsx"; -import HomepageRu from "../../generated/homepage/ru.jsx"; -import HomepageZh from "../../generated/homepage/zh.jsx"; +import ActiveHomepage from "@active-homepage"; import { config } from "virtual:nimbus/config"; -import { ACTIVE_LOCALES, localeCollectionName } from "../../content.config"; +import { ACTIVE_LOCALES, AVAILABLE_LOCALES, localeCollectionName } from "../../content.config"; import { localeInfo, localeRouteName } from "../../util/locales"; export const prerender = true; export function getStaticPaths() { - return ACTIVE_LOCALES.map((locale) => ({ params: { locale: localeRouteName(locale) } })); + return ACTIVE_LOCALES.map((locale) => ({ + params: { locale: localeRouteName(locale) }, + cacheKey: `homepage:${locale}`, + })); } const locale = Astro.params.locale as string; @@ -33,15 +29,8 @@ if (!["ar", "es", "fr", "ja", "ko", "pt-br", "ru", "zh"].includes(localeCode)) { --- -
+
- {localeCode === "ar" && } - {localeCode === "es" && } - {localeCode === "fr" && } - {localeCode === "ja" && } - {localeCode === "ko" && } - {localeCode === "pt-br" && } - {localeCode === "ru" && } - {localeCode === "zh" && } +
diff --git a/src/pages/[locale]/llms-full.txt.ts b/src/pages/[locale]/llms-full.txt.ts deleted file mode 100644 index 59e5f5d44..000000000 --- a/src/pages/[locale]/llms-full.txt.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Per-locale corpus index: //llms-full.txt -import { config } from "virtual:nimbus/config"; -import { entriesFor, renderCorpusIndex, sectionsOf } from "../../lib/corpus"; -import { ACTIVE_LOCALES, localeCollectionName } from "../../content.config"; -import { localeRouteName } from "../../util/locales"; - -export const prerender = true; - -export function getStaticPaths() { - return ACTIVE_LOCALES.map((locale) => ({ params: { locale: localeRouteName(locale) } })); -} - -export async function GET({ params }: { params: { locale: string } }) { - const sections = sectionsOf(await entriesFor(localeCollectionName(params.locale))); - return new Response(renderCorpusIndex(`${config.title} (${params.locale})`, sections, `/${params.locale}`), { - headers: { "Content-Type": "text/plain; charset=utf-8" }, - }); -} diff --git a/src/pages/[locale]/llms.txt.ts b/src/pages/[locale]/llms.txt.ts deleted file mode 100644 index 698df4252..000000000 --- a/src/pages/[locale]/llms.txt.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Per-locale agent index: `//llms.txt` lists the translated pages of - * that locale with links to their markdown twins. - */ -import { entriesFor } from "../../lib/corpus"; -import { config } from "virtual:nimbus/config"; -import { withBase } from "../../lib/base"; -import { ACTIVE_LOCALES, localeCollectionName } from "../../content.config"; -import { localeRouteName } from "../../util/locales"; - -export const prerender = true; - -export function getStaticPaths() { - return ACTIVE_LOCALES.map((locale) => ({ params: { locale: localeRouteName(locale) } })); -} - -export async function GET({ params }: { params: { locale: string } }) { - const locale = params.locale; - const items = await entriesFor(localeCollectionName(locale)); - items.sort((a, b) => a.entry.id.localeCompare(b.entry.id)); - const lines = [ - `# ${config.title} (${locale})`, - "", - config.description ?? "", - "", - `English index: ${new URL(withBase("/llms.txt"), config.site).href}`, - "", - "## Pages", - "", - ...items.map((i) => { - const url = new URL(withBase(`/${locale}/${i.entry.id}/index.md`), config.site).href; - return `- [${i.title}](${url})${i.description ? ` β€” ${i.description}` : ""}`; - }), - "", - ]; - return new Response(lines.join("\n"), { headers: { "Content-Type": "text/plain; charset=utf-8" } }); -} diff --git a/src/pages/[section]/llms-full.txt.ts b/src/pages/[section]/llms-full.txt.ts index 5a66c6fad..60683eeaf 100644 --- a/src/pages/[section]/llms-full.txt.ts +++ b/src/pages/[section]/llms-full.txt.ts @@ -2,11 +2,16 @@ import { config } from "virtual:nimbus/config"; import { englishCorpusEntries, sectionsOf } from "../../lib/corpus"; import { withBase } from "../../lib/base"; +import { EMIT_ENGLISH } from "../../content.config"; export const prerender = true; export async function getStaticPaths() { - return sectionsOf(await englishCorpusEntries()).map((section) => ({ params: { section } })); + if (!EMIT_ENGLISH) return []; + return sectionsOf(await englishCorpusEntries()).map((section) => ({ + params: { section }, + cacheKey: `llms-full-pointer:${section}`, + })); } export async function GET({ params }: { params: { section: string } }) { diff --git a/src/pages/changelog/[product]/[...slug].astro b/src/pages/changelog/[product]/[...slug].astro index 4d73c8c28..06d8b13ab 100644 --- a/src/pages/changelog/[product]/[...slug].astro +++ b/src/pages/changelog/[product]/[...slug].astro @@ -6,12 +6,17 @@ import { readScope } from "../../../lib/scope"; export const prerender = true; export async function getStaticPaths() { - if (readScope().remotePreview) return []; + const scope = readScope(); + if (!scope.emitEnglish || scope.remotePreview) return []; const entries = await getCollection("changelog"); return entries.map((entry) => { const [product, ...slug] = entry.id.split("/"); if (!product || !slug.length) throw new Error(`Invalid changelog id: ${entry.id}`); - return { params: { product, slug: slug.join("/") }, props: { entry } }; + return { + params: { product, slug: slug.join("/") }, + props: { entry }, + cacheKey: entry.digest, + }; }); } diff --git a/src/pages/changelog/[product]/index.astro b/src/pages/changelog/[product]/index.astro index 341ff5514..b4a0387ca 100644 --- a/src/pages/changelog/[product]/index.astro +++ b/src/pages/changelog/[product]/index.astro @@ -4,11 +4,19 @@ import BaseLayout from "../../../layouts/BaseLayout.astro"; import Header from "../../../components/Header.astro"; import { changelogPath, entriesForProduct, productTitle, type ChangelogEntry } from "../../../lib/changelog"; import { readScope } from "../../../lib/scope"; +import { createHash } from "node:crypto"; export async function getStaticPaths() { - if (readScope().remotePreview) return []; + const scope = readScope(); + if (!scope.emitEnglish || scope.remotePreview) return []; const entries = await getCollection("changelog"); - return ["cloud", "oss"].map((product) => ({ params: { product }, props: { entries: entriesForProduct(entries, product) } })); + return ["cloud", "oss"].map((product) => { + const productEntries = entriesForProduct(entries, product); + const cacheKey = createHash("sha256") + .update(productEntries.map((entry) => `${entry.id}:${entry.digest}`).join("\n")) + .digest("hex"); + return { params: { product }, props: { entries: productEntries }, cacheKey }; + }); } const product = Astro.params.product as string; diff --git a/src/pages/changelog/[product]/rss.xml.ts b/src/pages/changelog/[product]/rss.xml.ts index b1d4cd747..1119108da 100644 --- a/src/pages/changelog/[product]/rss.xml.ts +++ b/src/pages/changelog/[product]/rss.xml.ts @@ -1,15 +1,23 @@ import { getCollection } from "astro:content"; import { changelogPath, entriesForProduct, productTitle, type ChangelogEntry } from "../../../lib/changelog"; import { readScope } from "../../../lib/scope"; +import { createHash } from "node:crypto"; function xml(value: string): string { return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); } export async function getStaticPaths() { - if (readScope().remotePreview) return []; + const scope = readScope(); + if (!scope.emitEnglish || scope.remotePreview) return []; const entries = await getCollection("changelog"); - return ["cloud", "oss"].map((product) => ({ params: { product }, props: { entries: entriesForProduct(entries, product) } })); + return ["cloud", "oss"].map((product) => { + const productEntries = entriesForProduct(entries, product); + const cacheKey = createHash("sha256") + .update(productEntries.map((entry) => `${entry.id}:${entry.digest}`).join("\n")) + .digest("hex"); + return { params: { product }, props: { entries: productEntries }, cacheKey }; + }); } export function GET({ params, props }: { params: { product: string }; props: { entries: ChangelogEntry[] } }) { diff --git a/src/pages/clickstack/api-reference/[tag]/[operation].astro b/src/pages/clickstack/api-reference/[tag]/[operation].astro index 8fa9525ad..4c5c20265 100644 --- a/src/pages/clickstack/api-reference/[tag]/[operation].astro +++ b/src/pages/clickstack/api-reference/[tag]/[operation].astro @@ -1,14 +1,16 @@ --- import ApiPage from "@/components/api/ApiPage.astro"; -import { getApiOperations, getRoutedApiOperation } from "@/lib/openapi"; +import { apiPageCacheKey, getApiOperations, getRoutedApiOperation } from "@/lib/openapi"; import { readScope } from "@/lib/scope"; export const prerender = true; export function getStaticPaths() { - if (readScope().remotePreview) return []; + const scope = readScope(); + if (!scope.emitEnglish || scope.remotePreview) return []; return getApiOperations("clickstack").map((page) => ({ params: { tag: page.tagSlug, operation: page.slug }, + cacheKey: apiPageCacheKey(page), })); } diff --git a/src/pages/clickstack/api-reference/[tag]/[operation]/index.md.ts b/src/pages/clickstack/api-reference/[tag]/[operation].md.ts similarity index 60% rename from src/pages/clickstack/api-reference/[tag]/[operation]/index.md.ts rename to src/pages/clickstack/api-reference/[tag]/[operation].md.ts index f067c3ad1..c6376c5ed 100644 --- a/src/pages/clickstack/api-reference/[tag]/[operation]/index.md.ts +++ b/src/pages/clickstack/api-reference/[tag]/[operation].md.ts @@ -1,13 +1,17 @@ import type { APIRoute } from "astro"; -import { getApiOperation, getApiOperations, renderApiOperationMarkdown } from "@/lib/openapi"; +import { apiPageCacheKey, getApiOperation, getApiOperations, renderApiOperationMarkdown } from "@/lib/openapi"; import { readScope } from "@/lib/scope"; import { prepareAgentMarkdown } from "@/lib/agent-links"; export const prerender = true; export function getStaticPaths() { - if (readScope().remotePreview) return []; - return getApiOperations("clickstack").map((page) => ({ params: { tag: page.tagSlug, operation: page.slug } })); + const scope = readScope(); + if (!scope.emitEnglish || scope.remotePreview) return []; + return getApiOperations("clickstack").map((page) => ({ + params: { tag: page.tagSlug, operation: page.slug }, + cacheKey: apiPageCacheKey(page), + })); } export const GET: APIRoute = async ({ params }) => { diff --git a/src/pages/clickstack/api-reference/[tag]/[operation]/index.mdx.ts b/src/pages/clickstack/api-reference/[tag]/[operation]/index.mdx.ts deleted file mode 100644 index 0bf8ef9f8..000000000 --- a/src/pages/clickstack/api-reference/[tag]/[operation]/index.mdx.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { APIRoute } from "astro"; -import { getApiOperation, getApiOperations, renderApiOperationMarkdown } from "@/lib/openapi"; -import { readScope } from "@/lib/scope"; - -export const prerender = true; - -export function getStaticPaths() { - if (readScope().remotePreview) return []; - return getApiOperations("clickstack").map((page) => ({ params: { tag: page.tagSlug, operation: page.slug } })); -} - -export const GET: APIRoute = async ({ params }) => new Response( - await renderApiOperationMarkdown(getApiOperation("clickstack", params.tag!, params.operation!)), - { headers: { "Content-Type": "text/markdown; charset=utf-8" } }, -); diff --git a/src/pages/index.astro b/src/pages/index.astro index 99f131e33..ff7a7037a 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -3,9 +3,10 @@ import BaseLayout from "../layouts/BaseLayout.astro"; import Header from "../components/Header.astro"; import Homepage from "../generated/homepage/en.jsx"; import { config } from "virtual:nimbus/config"; +import { AVAILABLE_LOCALES } from "../content.config"; --- -
+
diff --git a/src/pages/nav/[...key].astro b/src/pages/nav/[...key].astro index 01259de8a..53dbbe2e5 100644 --- a/src/pages/nav/[...key].astro +++ b/src/pages/nav/[...key].astro @@ -5,33 +5,42 @@ * a collapsed group is first expanded. Built from src/generated/sidebar.items.json. */ import SidebarChildren from "@/components/ui/sidebar/SidebarChildren.astro"; -import { collectGroups, toRendered, type ConfigItem } from "@/lib/sidebar-lazy"; +import { LAZY_MIN_CHILDREN, collectGroups, toRendered, type ConfigItem } from "@/lib/sidebar-lazy"; // Generated by bin/gen-sidebar.ts in `prebuild`; a static import keeps the // route independent of the build's working directory. import sidebarItems from "@/generated/sidebar.items.json"; import fs from "node:fs"; import nodePath from "node:path"; -import { ACTIVE_LOCALES } from "@/content.config"; +import { AVAILABLE_LOCALES, EMIT_ENGLISH } from "@/content.config"; import { localeRouteName } from "@/util/locales"; import type { SidebarItem } from "@cloudflare/nimbus-docs/types"; +import { createHash } from "node:crypto"; export const partial = true; export const prerender = true; export function getStaticPaths() { - const paths: Array<{ params: { key: string }; props: { items: SidebarItem[] } }> = []; - const trees: Array<{ prefix: string[]; items: ConfigItem[] }> = [{ prefix: [], items: sidebarItems as ConfigItem[] }]; + const paths: Array<{ params: { key: string }; props: { items: SidebarItem[] }; cacheKey: string }> = []; + const trees: Array<{ prefix: string[]; items: ConfigItem[] }> = EMIT_ENGLISH + ? [{ prefix: [], items: sidebarItems as ConfigItem[] }] + : []; // Locale trees (generated by bin/gen-sidebar.ts --locale) are namespaced under /nav//. - for (const locale of ACTIVE_LOCALES) { + for (const locale of EMIT_ENGLISH ? AVAILABLE_LOCALES : []) { const file = nodePath.join(process.cwd(), "src/generated", `sidebar.items.${locale}.json`); if (fs.existsSync(file)) trees.push({ prefix: [localeRouteName(locale)], items: JSON.parse(fs.readFileSync(file, "utf8")) as ConfigItem[] }); } const seen = new Set(); for (const tree of trees) { for (const g of collectGroups(tree.items, tree.prefix)) { + if (g.items.length < LAZY_MIN_CHILDREN) continue; if (seen.has(g.key)) throw new Error(`sidebar-lazy: duplicate group key "${g.key}" (${g.label})`); seen.add(g.key); - paths.push({ params: { key: g.key }, props: { items: toRendered(g.items, g.path) } }); + const items = toRendered(g.items, g.path); + paths.push({ + params: { key: g.key }, + props: { items }, + cacheKey: createHash("sha256").update(JSON.stringify(items)).digest("hex"), + }); } } return paths; diff --git a/src/pages/products/cloud/api-reference/[tag]/[operation].astro b/src/pages/products/cloud/api-reference/[tag]/[operation].astro index 230c818a1..7a8f67f04 100644 --- a/src/pages/products/cloud/api-reference/[tag]/[operation].astro +++ b/src/pages/products/cloud/api-reference/[tag]/[operation].astro @@ -1,14 +1,16 @@ --- import ApiPage from "@/components/api/ApiPage.astro"; -import { getApiOperations, getRoutedApiOperation } from "@/lib/openapi"; +import { apiPageCacheKey, getApiOperations, getRoutedApiOperation } from "@/lib/openapi"; import { readScope } from "@/lib/scope"; export const prerender = true; export function getStaticPaths() { - if (readScope().remotePreview) return []; + const scope = readScope(); + if (!scope.emitEnglish || scope.remotePreview) return []; return getApiOperations("cloud").map((page) => ({ params: { tag: page.tagSlug, operation: page.slug }, + cacheKey: apiPageCacheKey(page), })); } diff --git a/src/pages/products/cloud/api-reference/[tag]/[operation]/index.md.ts b/src/pages/products/cloud/api-reference/[tag]/[operation].md.ts similarity index 60% rename from src/pages/products/cloud/api-reference/[tag]/[operation]/index.md.ts rename to src/pages/products/cloud/api-reference/[tag]/[operation].md.ts index 29469aff8..e21e36744 100644 --- a/src/pages/products/cloud/api-reference/[tag]/[operation]/index.md.ts +++ b/src/pages/products/cloud/api-reference/[tag]/[operation].md.ts @@ -1,13 +1,17 @@ import type { APIRoute } from "astro"; -import { getApiOperation, getApiOperations, renderApiOperationMarkdown } from "@/lib/openapi"; +import { apiPageCacheKey, getApiOperation, getApiOperations, renderApiOperationMarkdown } from "@/lib/openapi"; import { readScope } from "@/lib/scope"; import { prepareAgentMarkdown } from "@/lib/agent-links"; export const prerender = true; export function getStaticPaths() { - if (readScope().remotePreview) return []; - return getApiOperations("cloud").map((page) => ({ params: { tag: page.tagSlug, operation: page.slug } })); + const scope = readScope(); + if (!scope.emitEnglish || scope.remotePreview) return []; + return getApiOperations("cloud").map((page) => ({ + params: { tag: page.tagSlug, operation: page.slug }, + cacheKey: apiPageCacheKey(page), + })); } export const GET: APIRoute = async ({ params }) => { diff --git a/src/pages/products/cloud/api-reference/[tag]/[operation]/index.mdx.ts b/src/pages/products/cloud/api-reference/[tag]/[operation]/index.mdx.ts deleted file mode 100644 index 3693cc671..000000000 --- a/src/pages/products/cloud/api-reference/[tag]/[operation]/index.mdx.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { APIRoute } from "astro"; -import { getApiOperation, getApiOperations, renderApiOperationMarkdown } from "@/lib/openapi"; -import { readScope } from "@/lib/scope"; - -export const prerender = true; - -export function getStaticPaths() { - if (readScope().remotePreview) return []; - return getApiOperations("cloud").map((page) => ({ params: { tag: page.tagSlug, operation: page.slug } })); -} - -export const GET: APIRoute = async ({ params }) => new Response( - await renderApiOperationMarkdown(getApiOperation("cloud", params.tag!, params.operation!)), - { headers: { "Content-Type": "text/markdown; charset=utf-8" } }, -); diff --git a/tsconfig.json b/tsconfig.json index eb886e904..8a75640eb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,8 @@ "jsxImportSource": "react", "paths": { "@/*": ["src/*"], - "~/*": ["src/*"] + "~/*": ["src/*"], + "@active-homepage": ["src/generated/homepage/en.jsx"] } }, "include": [".astro/types.d.ts", "src/**/*", "bin/**/*", "astro.config.ts"], diff --git a/vercel.json b/vercel.json index 4064f1a6b..b9e03b848 100644 --- a/vercel.json +++ b/vercel.json @@ -11,6 +11,41 @@ "source": "/", "destination": "/docs", "permanent": false + }, + { + "source": "/docs/:locale(ar|es|fr|ja|ko|pt-BR|ru|zh)/llms.txt", + "destination": "/docs/llms.txt", + "permanent": true + }, + { + "source": "/docs/:locale(ar|es|fr|ja|ko|pt-BR|ru|zh)/llms-full.txt", + "destination": "/docs/llms-full.txt", + "permanent": true + }, + { + "source": "/docs/:locale(ar|es|fr|ja|ko|pt-BR|ru|zh)/:section/llms-full.txt", + "destination": "/docs/:section/llms-full.txt", + "permanent": true + }, + { + "source": "/docs/:locale(ar|es|fr|ja|ko|pt-BR|ru|zh)/index.md", + "destination": "/docs/index.md", + "permanent": true + }, + { + "source": "/docs/:locale(ar|es|fr|ja|ko|pt-BR|ru|zh)/:path+/index.md", + "destination": "/docs/:path+.md", + "permanent": true + }, + { + "source": "/docs/:locale(ar|es|fr|ja|ko|pt-BR|ru|zh)/:path+.md", + "destination": "/docs/:path+.md", + "permanent": true + }, + { + "source": "/docs/:path+/index.md", + "destination": "/docs/:path+.md", + "permanent": true } ], "rewrites": [