From b3b3b346e12eb7e18fac99ba8a354d22e6e9fe13 Mon Sep 17 00:00:00 2001 From: Shaun Struwig <41984034+Blargian@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:56:08 +0200 Subject: [PATCH 1/6] Report Vercel build output composition --- .github/workflows/site-production.yml | 3 +- bin/postbuild.ts | 200 +++++++++++++++++++++++--- 2 files changed, 183 insertions(+), 20 deletions(-) diff --git a/.github/workflows/site-production.yml b/.github/workflows/site-production.yml index 3e9dade35..a7317847d 100644 --- a/.github/workflows/site-production.yml +++ b/.github/workflows/site-production.yml @@ -98,7 +98,8 @@ jobs: }, build: {env: { DOCS_LOCALES: "all", - DOCS_REMOTES: "all" + DOCS_REMOTES: "all", + VERCEL_BUILD_SYSTEM_REPORT: "1" }}, meta: { buildScope: "production", diff --git a/bin/postbuild.ts b/bin/postbuild.ts index 14cf41421..00a885b44 100644 --- a/bin/postbuild.ts +++ b/bin/postbuild.ts @@ -6,27 +6,31 @@ import fs from "node:fs"; import path from "node:path"; import { execFileSync } from "node:child_process"; +import { ALL_LOCALES } from "../src/lib/scope.ts"; +import { localeRouteName } from "../src/util/locales.ts"; const root = process.cwd(); const outDir = path.resolve(root, process.env.DOCS_OUT_DIR ?? "dist"); const nested = path.join(outDir, "docs"); -if (fs.existsSync(nested) && fs.existsSync(path.join(nested, "index.html")) && !fs.existsSync(path.join(outDir, "index.html"))) { +const alreadyNested = fs.existsSync(nested) + && fs.existsSync(path.join(nested, "index.html")) + && !fs.existsSync(path.join(outDir, "index.html")); +if (alreadyNested) { console.log("postbuild: already nested, nothing to do"); - process.exit(0); -} - -// 1. stray URL rebase -execFileSync(process.execPath, [path.join(root, "bin/postbuild-rebase.ts"), outDir], { stdio: "inherit" }); +} else { + // 1. stray URL rebase + execFileSync(process.execPath, [path.join(root, "bin/postbuild-rebase.ts"), outDir], { stdio: "inherit" }); -// 3. nest under /docs (everything except __redirects and .assetsignore) -fs.mkdirSync(nested, { recursive: true }); -for (const name of fs.readdirSync(outDir)) { - if (name === "docs" || name === "__redirects" || name === ".assetsignore") continue; - fs.renameSync(path.join(outDir, name), path.join(nested, name)); + // 3. nest under /docs (everything except __redirects and .assetsignore) + fs.mkdirSync(nested, { recursive: true }); + for (const name of fs.readdirSync(outDir)) { + if (name === "docs" || name === "__redirects" || name === ".assetsignore") continue; + fs.renameSync(path.join(outDir, name), path.join(nested, name)); + } + // 2. redirects at the top level (imported by the Worker; not served as an asset) + execFileSync(process.execPath, [path.join(root, "bin/gen-redirects.ts"), outDir], { stdio: "inherit" }); + fs.copyFileSync(path.join(root, ".assetsignore"), path.join(outDir, ".assetsignore")); } -// 2. redirects at the top level (imported by the Worker; not served as an asset) -execFileSync(process.execPath, [path.join(root, "bin/gen-redirects.ts"), outDir], { stdio: "inherit" }); -fs.copyFileSync(path.join(root, ".assetsignore"), path.join(outDir, ".assetsignore")); // Workers static assets: 25 MiB per file, 100k files per version. Agent corpora // target 24 MiB and split recursively, while this remains a final guard for all @@ -49,11 +53,169 @@ if (process.env.VERCEL !== "1") { } } -const files = (function count(dir: string): number { - let n = 0; - for (const e of fs.readdirSync(dir, { withFileTypes: true })) n += e.isDirectory() ? count(path.join(dir, e.name)) : 1; - return n; -})(outDir); +interface OutputFile { + relativePath: string; + bytes: number; +} + +interface OutputStats { + files: number; + bytes: number; +} + +function collectFiles(dir: string): OutputFile[] { + const output: OutputFile[] = []; + (function scan(current: string) { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const absolutePath = path.join(current, entry.name); + if (entry.isDirectory()) { + scan(absolutePath); + } else { + output.push({ + relativePath: path.relative(outDir, absolutePath).split(path.sep).join("/"), + bytes: fs.statSync(absolutePath).size, + }); + } + } + })(dir); + return output; +} + +function summarize(files: OutputFile[]): OutputStats { + return { + files: files.length, + bytes: files.reduce((total, file) => total + file.bytes, 0), + }; +} + +function formatCount(value: number): string { + return new Intl.NumberFormat("en-US").format(value); +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KiB", "MiB", "GiB"]; + const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const value = bytes / (1024 ** unit); + return `${value.toFixed(unit === 0 ? 0 : value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[unit]}`; +} + +function printTable(headers: string[], rows: string[][]): void { + const widths = headers.map((header, column) => + Math.max(header.length, ...rows.map((row) => row[column]?.length ?? 0)) + ); + console.log(headers.map((header, column) => header.padEnd(widths[column])).join(" ")); + console.log(widths.map((width) => "-".repeat(width)).join(" ")); + for (const row of rows) { + console.log(row.map((value, column) => value.padEnd(widths[column])).join(" ")); + } +} + +function printOutputReport(files: OutputFile[]): void { + const total = summarize(files); + const imageExtensions = new Set([".avif", ".bmp", ".gif", ".ico", ".jpeg", ".jpg", ".png", ".svg", ".tif", ".tiff", ".webp"]); + const typeBuckets = new Map([ + ["HTML", []], + ["Markdown", []], + ["JavaScript", []], + ["CSS", []], + ["Images", []], + ["Other", []], + ]); + + for (const file of files) { + const extension = path.extname(file.relativePath).toLowerCase(); + const type = extension === ".html" || extension === ".htm" + ? "HTML" + : extension === ".md" || extension === ".mdx" + ? "Markdown" + : extension === ".js" || extension === ".mjs" || extension === ".cjs" + ? "JavaScript" + : extension === ".css" + ? "CSS" + : imageExtensions.has(extension) + ? "Images" + : "Other"; + typeBuckets.get(type)!.push(file); + } + + const localeRows: string[][] = []; + const localeFiles = new Set(); + for (const locale of ALL_LOCALES) { + const routeName = localeRouteName(locale); + const routePrefix = `docs/${routeName}/`; + const assetsPrefix = `docs/_astro-${locale.toLowerCase()}/`; + const matching = files.filter((file) => + file.relativePath.startsWith(routePrefix) || file.relativePath.startsWith(assetsPrefix) + ); + if (matching.length === 0) continue; + for (const file of matching) localeFiles.add(file); + const routeStats = summarize(matching.filter((file) => file.relativePath.startsWith(routePrefix))); + const assetStats = summarize(matching.filter((file) => file.relativePath.startsWith(assetsPrefix))); + const combined = summarize(matching); + localeRows.push([ + routeName, + formatCount(routeStats.files), + formatBytes(routeStats.bytes), + formatCount(assetStats.files), + formatBytes(assetStats.bytes), + formatCount(combined.files), + formatBytes(combined.bytes), + ]); + } + const englishAndShared = files.filter((file) => !localeFiles.has(file)); + const englishRouteStats = summarize(englishAndShared.filter((file) => + file.relativePath.startsWith("docs/") + && !file.relativePath.startsWith("docs/_astro/") + && [".html", ".md", ".mdx"].includes(path.extname(file.relativePath).toLowerCase()) + )); + const sharedAstroStats = summarize(englishAndShared.filter((file) => file.relativePath.startsWith("docs/_astro/"))); + const englishCombined = summarize(englishAndShared); + localeRows.unshift([ + "en + shared", + formatCount(englishRouteStats.files), + formatBytes(englishRouteStats.bytes), + formatCount(sharedAstroStats.files), + formatBytes(sharedAstroStats.bytes), + formatCount(englishCombined.files), + formatBytes(englishCombined.bytes), + ]); + + const localeAstroFiles = files.filter((file) => /^docs\/_astro-[^/]+\//.test(file.relativePath)); + const localeAstroStats = summarize(localeAstroFiles); + + console.log("\npostbuild: Vercel output report"); + console.log(`Total: ${formatCount(total.files)} files, ${formatBytes(total.bytes)} (${formatCount(total.bytes)} bytes)`); + + console.log("\nBy file type"); + printTable( + ["Type", "Files", "Bytes"], + [...typeBuckets].map(([type, bucket]) => { + const stats = summarize(bucket); + return [type, formatCount(stats.files), formatBytes(stats.bytes)]; + }), + ); + + console.log("\nBy locale (route tree plus its locale-specific _astro directory)"); + printTable( + ["Locale", "Route files", "Route bytes", "_astro files", "_astro bytes", "Total files", "Total bytes"], + localeRows, + ); + + console.log("\nAstro assets"); + printTable( + ["Scope", "Files", "Bytes"], + [ + ["Base/shared (_astro)", formatCount(sharedAstroStats.files), formatBytes(sharedAstroStats.bytes)], + ["Locale-specific (_astro-*)", formatCount(localeAstroStats.files), formatBytes(localeAstroStats.bytes)], + ...localeRows.slice(1).map((row) => [`${row[0]} (_astro-${row[0].toLowerCase()})`, row[3], row[4]]), + ], + ); +} + +const outputFiles = collectFiles(outDir); +printOutputReport(outputFiles); +const files = outputFiles.length; console.log( process.env.VERCEL === "1" ? `postbuild: site nested under ${path.relative(root, nested)}; ${files} files total` From 98a631d40278b9399e68e4c3e0fabdc7951bdc56 Mon Sep 17 00:00:00 2001 From: Shaun Struwig <41984034+Blargian@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:43:37 +0200 Subject: [PATCH 2/6] Split English and translation Vercel deployments --- .github/workflows/remote-docs-preview.yml | 4 +- .github/workflows/site-preview.yml | 182 +++++++++++---- .github/workflows/site-production.yml | 220 +++++++++++++----- astro.config.ts | 3 +- bin/gen-sidebar.ts | 6 +- bin/postbuild.ts | 7 +- bin/prepare-public.ts | 16 +- bin/vercel-build.ts | 89 ++++++-- microfrontends.json | 46 ++++ package.json | 1 + pnpm-lock.yaml | 259 ++++++++++++++++++++++ src/README.md | 86 ++++--- src/content.config.ts | 6 +- src/lib/scope.ts | 66 +++++- src/pages/[locale]/[...slug].astro | 10 +- src/pages/nav/[...key].astro | 4 +- 16 files changed, 817 insertions(+), 188 deletions(-) create mode 100644 microfrontends.json diff --git a/.github/workflows/remote-docs-preview.yml b/.github/workflows/remote-docs-preview.yml index a5a5b0614..3fb5b92f0 100644 --- a/.github/workflows/remote-docs-preview.yml +++ b/.github/workflows/remote-docs-preview.yml @@ -198,7 +198,9 @@ jobs: sha: $site_sha }, build: {env: { - DOCS_LOCALE: "en", + DOCS_DEPLOY_TARGET: "english", + DOCS_LOCALES: "none", + DOCS_AVAILABLE_LOCALES: "all", DOCS_REMOTES: "all", DOCS_REMOTE_NAME: $remote_name, DOCS_REMOTE_REPOSITORY: $remote_repository, diff --git a/.github/workflows/site-preview.yml b/.github/workflows/site-preview.yml index 47b52ad83..f8e7a16f6 100644 --- a/.github/workflows/site-preview.yml +++ b/.github/workflows/site-preview.yml @@ -124,13 +124,12 @@ jobs: gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PULL_REQUEST_NUMBER/labels" --jq '.[].name' ) known_locales=(ar es fr ja ko pt-BR ru zh) - selected_locales=() - include_all=false + include_translations=false for label in "${labels[@]}"; do normalized="${label,,}" if [[ "$normalized" == "docs-translations-all" ]]; then - include_all=true + include_translations=true continue fi if [[ "$normalized" != docs-translations-* ]]; then @@ -140,7 +139,7 @@ jobs: matched=false for locale in "${known_locales[@]}"; do if [[ "$normalized" == "docs-translations-${locale,,}" ]]; then - selected_locales+=("$locale") + include_translations=true matched=true break fi @@ -152,12 +151,9 @@ jobs: fi done - if [[ "$include_all" == true ]]; then + if [[ "$include_translations" == true ]]; then locale_scope="all" locale_summary="English and all translations" - elif (( ${#selected_locales[@]} > 0 )); then - locale_scope="$(IFS=,; echo "${selected_locales[*]}")" - locale_summary="English and $locale_scope" else locale_scope="none" locale_summary="English only" @@ -185,6 +181,7 @@ jobs: TRUST: ${{ steps.pull-request.outputs.trust }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_TRANSLATIONS_PROJECT_ID: ${{ vars.VERCEL_TRANSLATIONS_PROJECT_ID }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} run: | repository_owner="${GITHUB_REPOSITORY%%/*}" @@ -195,6 +192,10 @@ jobs: local updated_at="$(date -u '+%Y-%m-%d %H:%M UTC')" local marker="" local docs_url="${preview_url%/}/docs" + local components="English: $english_state; translations: Production fallback" + if [[ -n "${translations_url:-}" ]]; then + components="English: $english_state; [translations]($translations_url): $translations_state" + fi local body local comment_id body="$(printf '🕵 %s\n\n| Preview | Deployment | Components | Updated (UTC) |\n| --- | --- | --- | --- |\n| [Docs preview](%s) | [%s](%s) | %s | %s |' \ @@ -202,7 +203,7 @@ jobs: "$docs_url" \ "$deployment_status" \ "$preview_url" \ - "$TRANSLATION_SUMMARY" \ + "$components" \ "$updated_at")" if ! comment_id="$({ gh api --paginate "repos/$GH_REPO/issues/$PULL_REQUEST_NUMBER/comments" \ @@ -254,7 +255,7 @@ jobs: fi project_name="$(jq -er .name <<< "$project_response")" - payload="$( + english_payload="$( jq --null-input \ --arg approved_sha "$APPROVED_SHA" \ --arg git_ref "$GIT_REF" \ @@ -265,7 +266,6 @@ jobs: --arg pull_request "$PULL_REQUEST_NUMBER" \ --arg repository "$repository_name" \ --arg repository_owner "$repository_owner" \ - --arg translations "$TRANSLATION_SCOPE" \ --arg trust "$TRUST" \ '{ name: $project_name, @@ -278,43 +278,122 @@ jobs: sha: $approved_sha }, build: {env: { - DOCS_LOCALES: $translations, + DOCS_DEPLOY_TARGET: "english", + DOCS_LOCALES: "none", + DOCS_AVAILABLE_LOCALES: "all", DOCS_REMOTES: "none" }}, meta: { - buildScope: "base-preview", + buildScope: "english-preview", sourceRepository: ($repository_owner + "/" + $repository), pullRequestHeadRepository: $head_repository, pullRequestHeadSha: $head_sha, pullRequest: $pull_request, approvedSha: $approved_sha, - trust: $trust, - translations: $translations + trust: $trust } }' )" - if ! deployment_response="$( + if ! english_response="$( curl --silent --show-error --fail-with-body \ --request POST \ --header "Authorization: Bearer $VERCEL_TOKEN" \ --header "Content-Type: application/json" \ - --data "$payload" \ + --data "$english_payload" \ "https://api.vercel.com/v13/deployments?forceNew=1&teamId=$VERCEL_ORG_ID" )"; then - printf '%s\n' "$deployment_response" >&2 + printf '%s\n' "$english_response" >&2 exit 1 fi - deployment_id="$(jq -er .id <<< "$deployment_response")" - deployment_url="$(jq -er .url <<< "$deployment_response")" + english_id="$(jq -er .id <<< "$english_response")" + deployment_url="$(jq -er .url <<< "$english_response")" preview_url="https://${deployment_url#https://}" + english_state="🟡 Building" + translations_state="Production fallback" + translations_id="" + translations_url="" echo "preview_url=$preview_url" >> "$GITHUB_OUTPUT" - echo "Vercel deployment: $preview_url" + echo "English Vercel deployment: $preview_url" if [[ ! "$preview_url" =~ ^https://[^[:space:]]+$ ]]; then echo "Vercel returned an invalid preview URL." >&2 exit 1 fi + + if [[ "$TRANSLATION_SCOPE" == "all" ]]; then + if [[ -z "$VERCEL_TRANSLATIONS_PROJECT_ID" ]]; then + echo "docs-translations-all requires the VERCEL_TRANSLATIONS_PROJECT_ID repository variable." >&2 + exit 1 + fi + if ! translations_project_response="$( + curl --silent --show-error --fail-with-body \ + --header "Authorization: Bearer $VERCEL_TOKEN" \ + "https://api.vercel.com/v9/projects/$VERCEL_TRANSLATIONS_PROJECT_ID?teamId=$VERCEL_ORG_ID" + )"; then + printf '%s\n' "$translations_project_response" >&2 + exit 1 + fi + translations_project_name="$(jq -er .name <<< "$translations_project_response")" + translations_payload="$( + jq --null-input \ + --arg approved_sha "$APPROVED_SHA" \ + --arg git_ref "$GIT_REF" \ + --arg head_repository "$HEAD_REPOSITORY" \ + --arg head_sha "$HEAD_SHA" \ + --arg project_id "$VERCEL_TRANSLATIONS_PROJECT_ID" \ + --arg project_name "$translations_project_name" \ + --arg pull_request "$PULL_REQUEST_NUMBER" \ + --arg repository "$repository_name" \ + --arg repository_owner "$repository_owner" \ + --arg trust "$TRUST" \ + '{ + name: $project_name, + project: $project_id, + gitSource: { + type: "github", + org: $repository_owner, + repo: $repository, + ref: $git_ref, + sha: $approved_sha + }, + build: {env: { + DOCS_DEPLOY_TARGET: "translations", + DOCS_LOCALES: "all", + DOCS_AVAILABLE_LOCALES: "all", + DOCS_REMOTES: "none" + }}, + meta: { + buildScope: "translations-preview", + sourceRepository: ($repository_owner + "/" + $repository), + pullRequestHeadRepository: $head_repository, + pullRequestHeadSha: $head_sha, + pullRequest: $pull_request, + approvedSha: $approved_sha, + trust: $trust, + translations: "all" + } + }' + )" + if ! translations_response="$( + curl --silent --show-error --fail-with-body \ + --request POST \ + --header "Authorization: Bearer $VERCEL_TOKEN" \ + --header "Content-Type: application/json" \ + --data "$translations_payload" \ + "https://api.vercel.com/v13/deployments?forceNew=1&teamId=$VERCEL_ORG_ID" + )"; then + printf '%s\n' "$translations_response" >&2 + exit 1 + fi + translations_id="$(jq -er .id <<< "$translations_response")" + translations_deployment_url="$(jq -er .url <<< "$translations_response")" + translations_url="https://${translations_deployment_url#https://}" + translations_state="🟡 Building" + echo "translations_url=$translations_url" >> "$GITHUB_OUTPUT" + echo "Translations Vercel deployment: $translations_url" + fi + trap finalize_preview_comment EXIT trap 'exit 130' INT trap 'exit 143' TERM @@ -325,37 +404,46 @@ jobs: # 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 \ + if [[ "$english_state" != "🟢 Ready" ]]; then + english_response="$(curl --silent --show-error --fail-with-body \ --header "Authorization: Bearer $VERCEL_TOKEN" \ - "https://api.vercel.com/v13/deployments/$deployment_id?teamId=$VERCEL_ORG_ID" - )"; then - printf '%s\n' "$deployment_response" >&2 - exit 1 + "https://api.vercel.com/v13/deployments/$english_id?teamId=$VERCEL_ORG_ID")" + ready_state="$(jq -er .readyState <<< "$english_response")" + case "$ready_state" in + READY) english_state="🟢 Ready" ;; + ERROR|CANCELED|DELETED|BLOCKED) + jq -r '(.errorCode // "deployment_failed") + ": " + (.errorMessage // "English deployment did not complete")' <<< "$english_response" >&2 + exit 1 + ;; + QUEUED|INITIALIZING|BUILDING) ;; + *) echo "Unexpected English deployment state: $ready_state" >&2; exit 1 ;; + esac fi - ready_state="$(jq -er .readyState <<< "$deployment_response")" - case "$ready_state" in - READY) - break - ;; - ERROR|CANCELED|DELETED|BLOCKED) - jq -r '(.errorCode // "deployment_failed") + ": " + (.errorMessage // "Vercel deployment did not complete")' \ - <<< "$deployment_response" >&2 - exit 1 - ;; - QUEUED|INITIALIZING|BUILDING) - sleep 10 - ;; - *) - echo "Unexpected Vercel deployment state: $ready_state" >&2 - exit 1 - ;; - esac + if [[ -n "$translations_id" && "$translations_state" != "🟢 Ready" ]]; then + translations_response="$(curl --silent --show-error --fail-with-body \ + --header "Authorization: Bearer $VERCEL_TOKEN" \ + "https://api.vercel.com/v13/deployments/$translations_id?teamId=$VERCEL_ORG_ID")" + ready_state="$(jq -er .readyState <<< "$translations_response")" + case "$ready_state" in + READY) translations_state="🟢 Ready" ;; + ERROR|CANCELED|DELETED|BLOCKED) + jq -r '(.errorCode // "deployment_failed") + ": " + (.errorMessage // "Translations deployment did not complete")' <<< "$translations_response" >&2 + exit 1 + ;; + QUEUED|INITIALIZING|BUILDING) ;; + *) echo "Unexpected translations deployment state: $ready_state" >&2; exit 1 ;; + esac + fi + + if [[ "$english_state" == "🟢 Ready" && ( -z "$translations_id" || "$translations_state" == "🟢 Ready" ) ]]; then + break + fi + sleep 10 done - if [[ "$ready_state" != "READY" ]]; then - echo "Timed out waiting for Vercel deployment $deployment_id." >&2 + if [[ "$english_state" != "🟢 Ready" || ( -n "$translations_id" && "$translations_state" != "🟢 Ready" ) ]]; then + echo "Timed out waiting for the Vercel preview deployments." >&2 exit 1 fi if ! update_preview_comment_with_retry "🟢 Ready"; then diff --git a/.github/workflows/site-production.yml b/.github/workflows/site-production.yml index a7317847d..96a8db70c 100644 --- a/.github/workflows/site-production.yml +++ b/.github/workflows/site-production.yml @@ -4,7 +4,20 @@ on: push: branches: [main] workflow_dispatch: + inputs: + deployment_scope: + description: Documentation applications to deploy + required: true + default: all + type: choice + options: [all, english, translations] workflow_call: + inputs: + deployment_scope: + description: Documentation applications to deploy + required: false + default: english + type: string secrets: VERCEL_TOKEN: required: true @@ -60,29 +73,92 @@ jobs: fi echo "sha=$site_sha" >> "$GITHUB_OUTPUT" - - name: Trigger and wait for the Vercel production deployment + - name: Select production applications + id: scope + env: + GH_TOKEN: ${{ github.token }} + REQUESTED_SCOPE: ${{ inputs.deployment_scope }} + run: | + scope="$REQUESTED_SCOPE" + if [[ "$GITHUB_EVENT_NAME" == "workflow_call" && -z "$scope" ]]; then + scope="english" + elif [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && -z "$scope" ]]; then + scope="all" + elif [[ "$GITHUB_EVENT_NAME" == "push" ]]; then + scope="" + deploy_english=false + deploy_translations=false + before="${{ github.event.before }}" + if [[ "$before" =~ ^0+$ ]]; then + scope="all" + else + mapfile -t changed_files < <( + gh api --paginate "repos/$GITHUB_REPOSITORY/compare/$before...$GITHUB_SHA" --jq '.files[].filename' + ) + for file in "${changed_files[@]}"; do + case "$file" in + ar/*|es/*|fr/*|ja/*|ko/*|pt-BR/*|ru/*|zh/*) + deploy_translations=true + ;; + src/*|bin/*|snippets/*|.github/*|astro.config.*|package.json|pnpm-lock.yaml|vercel.json|microfrontends.json|docs.json|navigation.json|remotes.json) + deploy_english=true + deploy_translations=true + ;; + *) + deploy_english=true + ;; + esac + done + if [[ "$deploy_english" == true && "$deploy_translations" == true ]]; then + scope="all" + elif [[ "$deploy_translations" == true ]]; then + scope="translations" + else + scope="english" + fi + fi + fi + + case "$scope" in + all) echo "english=true" >> "$GITHUB_OUTPUT"; echo "translations=true" >> "$GITHUB_OUTPUT" ;; + english) echo "english=true" >> "$GITHUB_OUTPUT"; echo "translations=false" >> "$GITHUB_OUTPUT" ;; + translations) echo "english=false" >> "$GITHUB_OUTPUT"; echo "translations=true" >> "$GITHUB_OUTPUT" ;; + *) echo "deployment_scope must be all, english or translations" >&2; exit 1 ;; + esac + echo "name=$scope" >> "$GITHUB_OUTPUT" + + - name: Trigger and wait for the Vercel production deployments id: deploy env: + DEPLOY_ENGLISH: ${{ steps.scope.outputs.english }} + DEPLOY_TRANSLATIONS: ${{ steps.scope.outputs.translations }} SITE_SHA: ${{ steps.site.outputs.sha }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_TRANSLATIONS_PROJECT_ID: ${{ vars.VERCEL_TRANSLATIONS_PROJECT_ID }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} run: | - if ! project_response="$( - curl --silent --show-error --fail-with-body \ + project_name() { + local project_id="$1" + local response + response="$(curl --silent --show-error --fail-with-body \ --header "Authorization: Bearer $VERCEL_TOKEN" \ - "https://api.vercel.com/v9/projects/$VERCEL_PROJECT_ID?teamId=$VERCEL_ORG_ID" - )"; then - printf '%s\n' "$project_response" >&2 - exit 1 - fi - project_name="$(jq -er .name <<< "$project_response")" + "https://api.vercel.com/v9/projects/$project_id?teamId=$VERCEL_ORG_ID")" + jq -er .name <<< "$response" + } - payload="$( - jq --null-input \ + create_deployment() { + local project_id="$1" + local deploy_target="$2" + local locales="$3" + local project_name_value="$4" + local payload + payload="$(jq --null-input \ --arg approved_sha "$SITE_SHA" \ - --arg project_id "$VERCEL_PROJECT_ID" \ - --arg project_name "$project_name" \ + --arg deploy_target "$deploy_target" \ + --arg locales "$locales" \ + --arg project_id "$project_id" \ + --arg project_name "$project_name_value" \ --arg triggering_repository "$GITHUB_REPOSITORY" \ --arg triggering_sha "$GITHUB_SHA" \ '{ @@ -97,82 +173,108 @@ jobs: sha: $approved_sha }, build: {env: { - DOCS_LOCALES: "all", + DOCS_DEPLOY_TARGET: $deploy_target, + DOCS_LOCALES: $locales, + DOCS_AVAILABLE_LOCALES: "all", DOCS_REMOTES: "all", VERCEL_BUILD_SYSTEM_REPORT: "1" }}, meta: { - buildScope: "production", + buildScope: ($deploy_target + "-production"), sourceRepository: "ClickHouse/mintlify-docs-dev", approvedSha: $approved_sha, triggeringRepository: $triggering_repository, triggeringSha: $triggering_sha, - translations: "all" + translations: $locales } - }' - )" - - if ! deployment_response="$( + }')" curl --silent --show-error --fail-with-body \ --request POST \ --header "Authorization: Bearer $VERCEL_TOKEN" \ --header "Content-Type: application/json" \ --data "$payload" \ "https://api.vercel.com/v13/deployments?forceNew=1&teamId=$VERCEL_ORG_ID" - )"; then - printf '%s\n' "$deployment_response" >&2 - exit 1 + } + + english_id="" + english_url="" + english_state="SKIPPED" + translations_id="" + translations_url="" + translations_state="SKIPPED" + + if [[ "$DEPLOY_ENGLISH" == "true" ]]; then + english_project_name="$(project_name "$VERCEL_PROJECT_ID")" + english_response="$(create_deployment "$VERCEL_PROJECT_ID" english none "$english_project_name")" + english_id="$(jq -er .id <<< "$english_response")" + english_host="$(jq -er .url <<< "$english_response")" + english_url="https://${english_host#https://}" + english_state="BUILDING" + echo "English Vercel deployment: $english_url" fi - deployment_id="$(jq -er .id <<< "$deployment_response")" - deployment_url="$(jq -er .url <<< "$deployment_response")" - production_url="https://${deployment_url#https://}" - echo "production_url=$production_url" >> "$GITHUB_OUTPUT" - echo "Vercel deployment: $production_url" + if [[ "$DEPLOY_TRANSLATIONS" == "true" ]]; then + if [[ -z "$VERCEL_TRANSLATIONS_PROJECT_ID" ]]; then + echo "Translation deployment requires the VERCEL_TRANSLATIONS_PROJECT_ID repository variable." >&2 + exit 1 + fi + translations_project_name="$(project_name "$VERCEL_TRANSLATIONS_PROJECT_ID")" + translations_response="$(create_deployment "$VERCEL_TRANSLATIONS_PROJECT_ID" translations all "$translations_project_name")" + translations_id="$(jq -er .id <<< "$translations_response")" + translations_host="$(jq -er .url <<< "$translations_response")" + translations_url="https://${translations_host#https://}" + translations_state="BUILDING" + echo "Translations Vercel deployment: $translations_url" + fi for _ in {1..720}; do - if ! deployment_response="$( - curl --silent --show-error --fail-with-body \ + if [[ "$english_state" == "BUILDING" ]]; then + response="$(curl --silent --show-error --fail-with-body \ --header "Authorization: Bearer $VERCEL_TOKEN" \ - "https://api.vercel.com/v13/deployments/$deployment_id?teamId=$VERCEL_ORG_ID" - )"; then - printf '%s\n' "$deployment_response" >&2 - exit 1 + "https://api.vercel.com/v13/deployments/$english_id?teamId=$VERCEL_ORG_ID")" + english_state="$(jq -er .readyState <<< "$response")" + if [[ "$english_state" =~ ^(ERROR|CANCELED|DELETED|BLOCKED)$ ]]; then + jq -r '(.errorCode // "deployment_failed") + ": " + (.errorMessage // "English deployment did not complete")' <<< "$response" >&2 + exit 1 + fi + [[ "$english_state" =~ ^(QUEUED|INITIALIZING)$ ]] && english_state="BUILDING" fi - ready_state="$(jq -er .readyState <<< "$deployment_response")" - case "$ready_state" in - READY) - break - ;; - ERROR|CANCELED|DELETED|BLOCKED) - jq -r '(.errorCode // "deployment_failed") + ": " + (.errorMessage // "Vercel deployment did not complete")' \ - <<< "$deployment_response" >&2 - exit 1 - ;; - QUEUED|INITIALIZING|BUILDING) - sleep 10 - ;; - *) - echo "Unexpected Vercel deployment state: $ready_state" >&2 + if [[ "$translations_state" == "BUILDING" ]]; then + response="$(curl --silent --show-error --fail-with-body \ + --header "Authorization: Bearer $VERCEL_TOKEN" \ + "https://api.vercel.com/v13/deployments/$translations_id?teamId=$VERCEL_ORG_ID")" + translations_state="$(jq -er .readyState <<< "$response")" + if [[ "$translations_state" =~ ^(ERROR|CANCELED|DELETED|BLOCKED)$ ]]; then + jq -r '(.errorCode // "deployment_failed") + ": " + (.errorMessage // "Translations deployment did not complete")' <<< "$response" >&2 exit 1 - ;; - esac + fi + [[ "$translations_state" =~ ^(QUEUED|INITIALIZING)$ ]] && translations_state="BUILDING" + fi + + if [[ "$english_state" =~ ^(READY|SKIPPED)$ && "$translations_state" =~ ^(READY|SKIPPED)$ ]]; then + break + fi + sleep 10 done - if [[ "$ready_state" != "READY" ]]; then - echo "Timed out waiting for Vercel deployment $deployment_id." >&2 - exit 1 - fi - if [[ ! "$production_url" =~ ^https://[^[:space:]]+$ ]]; then - echo "Vercel returned an invalid production URL." >&2 + if [[ ! "$english_state" =~ ^(READY|SKIPPED)$ || ! "$translations_state" =~ ^(READY|SKIPPED)$ ]]; then + echo "Timed out waiting for the Vercel production deployments." >&2 exit 1 fi + production_url="${english_url:-$translations_url}" + echo "production_url=$production_url" >> "$GITHUB_OUTPUT" + echo "english_url=$english_url" >> "$GITHUB_OUTPUT" + echo "translations_url=$translations_url" >> "$GITHUB_OUTPUT" - name: Publish deployment summary env: - PRODUCTION_URL: ${{ steps.deploy.outputs.production_url }} + DEPLOYMENT_SCOPE: ${{ steps.scope.outputs.name }} + ENGLISH_URL: ${{ steps.deploy.outputs.english_url }} + TRANSLATIONS_URL: ${{ steps.deploy.outputs.translations_url }} run: | echo "### Nimbus production deployment" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" - echo "English and all translations: $PRODUCTION_URL" >> "$GITHUB_STEP_SUMMARY" + echo "Scope: $DEPLOYMENT_SCOPE" >> "$GITHUB_STEP_SUMMARY" + [[ -n "$ENGLISH_URL" ]] && echo "- English: $ENGLISH_URL" >> "$GITHUB_STEP_SUMMARY" + [[ -n "$TRANSLATIONS_URL" ]] && echo "- Translations: $TRANSLATIONS_URL" >> "$GITHUB_STEP_SUMMARY" diff --git a/astro.config.ts b/astro.config.ts index 9655d3d24..fa9a7c50e 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from "astro/config"; import react from "@astrojs/react"; import tailwindcss from "@tailwindcss/vite"; +import { microfrontends } from "@vercel/microfrontends/experimental/vite"; import nimbus, { defineConfig as defineNimbusConfig } from "@cloudflare/nimbus-docs"; import { tableScroll } from "@cloudflare/nimbus-docs/markdown"; import { satteri } from "@astrojs/markdown-satteri"; @@ -144,7 +145,7 @@ export default defineConfig({ jsxImportSource: "react", jsxDev: false, }, - plugins: [tailwindcss(), mintlifySnippets()], + plugins: [microfrontends(), tailwindcss(), mintlifySnippets()], resolve: { alias: { tslib: tslibModule, "@active-homepage": activeHomepageModule }, dedupe: ["react", "react-dom"], diff --git a/bin/gen-sidebar.ts b/bin/gen-sidebar.ts index 6d5056d13..8cb5d9e49 100644 --- a/bin/gen-sidebar.ts +++ b/bin/gen-sidebar.ts @@ -15,9 +15,9 @@ type Json = string | number | boolean | null | Json[] | { [k: string]: Json }; type Obj = { [k: string]: Json }; const root = process.cwd(); -// `--locale es` generates one locale; without it, English plus every locale in -// every non-English collection selected by `DOCS_LOCALES`, which is what -// Vercel's combined production and label-selected preview builds rely on. +// `--locale es` generates one locale; without it, English plus every locale +// selected by `DOCS_LOCALES`. Vercel uses that mode before its translations +// artifact is split into isolated locale build processes. const localeArg = process.argv.indexOf("--locale"); const scope = readScope(root); if (localeArg < 0 && !process.env.__GEN_SIDEBAR_CHILD) { diff --git a/bin/postbuild.ts b/bin/postbuild.ts index 00a885b44..1c7c55b7f 100644 --- a/bin/postbuild.ts +++ b/bin/postbuild.ts @@ -12,9 +12,12 @@ import { localeRouteName } from "../src/util/locales.ts"; const root = process.cwd(); const outDir = path.resolve(root, process.env.DOCS_OUT_DIR ?? "dist"); const nested = path.join(outDir, "docs"); +const topLevelSiteEntries = fs.existsSync(outDir) + ? fs.readdirSync(outDir).filter((name) => name !== "__redirects" && name !== ".assetsignore") + : []; const alreadyNested = fs.existsSync(nested) - && fs.existsSync(path.join(nested, "index.html")) - && !fs.existsSync(path.join(outDir, "index.html")); + && topLevelSiteEntries.length === 1 + && topLevelSiteEntries[0] === "docs"; if (alreadyNested) { console.log("postbuild: already nested, nothing to do"); } else { diff --git a/bin/prepare-public.ts b/bin/prepare-public.ts index 2a436b9c8..e58808fdd 100644 --- a/bin/prepare-public.ts +++ b/bin/prepare-public.ts @@ -109,7 +109,11 @@ function materialize(source: string, destination: string): void { fs.rmSync(output, { recursive: true, force: true }); fs.mkdirSync(output, { recursive: true }); -if (scope.remotePreview) { +if (scope.deployTarget === "translations") { + // Locale routes reference the shared `/docs/images` and `/docs/img` paths, + // which the default English application owns. Do not copy that corpus into + // the translation project's build workspace or deployment. +} else if (scope.remotePreview) { materialize(path.join(root, "public", "favicon.svg"), path.join(output, "favicon.svg")); materialize(path.join(root, "images", "logo.svg"), path.join(output, "images", "logo.svg")); materialize(path.join(root, "images", "icons"), path.join(output, "images", "icons")); @@ -155,10 +159,12 @@ for (const remote of manifest.remotes) { console.log(`prepare-public: rewrote asset references in ${rewrittenAssetReferences} ${remote.name} files`); } - for (const [index, asset] of (remote.assets ?? []).entries()) { - const source = relativeManifestPath(asset.source, `${remote.name}.assets[${index}].source`); - const mount = relativeManifestPath(asset.mount, `${remote.name}.assets[${index}].mount`); - materialize(path.join(root, remote.mount, source), path.join(output, mount)); + if (scope.deployTarget !== "translations") { + for (const [index, asset] of (remote.assets ?? []).entries()) { + const source = relativeManifestPath(asset.source, `${remote.name}.assets[${index}].source`); + const mount = relativeManifestPath(asset.mount, `${remote.name}.assets[${index}].mount`); + materialize(path.join(root, remote.mount, source), path.join(output, mount)); + } } } diff --git a/bin/vercel-build.ts b/bin/vercel-build.ts index 4854d53dd..d5984921a 100644 --- a/bin/vercel-build.ts +++ b/bin/vercel-build.ts @@ -53,16 +53,30 @@ function shardEnvironment( locale: "en" | Locale, availableLocales: Locale[], outDir: string, + options: { + emitEnglish: boolean; + loadEnglish: boolean; + emitLocaleFallbacks: boolean; + includePublic: boolean; + }, ): 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_EMIT_ENGLISH = options.emitEnglish ? "true" : "false"; + child.DOCS_LOAD_ENGLISH = options.loadEnglish ? "true" : "false"; + child.DOCS_EMIT_LOCALE_FALLBACKS = options.emitLocaleFallbacks ? "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; + child.DOCS_CACHE_DIR = path.join( + root, + "node_modules", + ".astro", + child.DOCS_DEPLOY_TARGET ?? "combined", + locale.toLowerCase(), + ); + if (options.includePublic) delete child.DOCS_SKIP_PUBLIC; else child.DOCS_SKIP_PUBLIC = "1"; return child; } @@ -77,6 +91,19 @@ function copyDirectory(source: string, destination: string, merge = false): void fs.cpSync(source, destination, { recursive: true, force: merge, errorOnExist: !merge }); } +function copyLocaleOutput(locale: Locale, outDir: string, finalOutDir: string): void { + const routeName = localeRouteName(locale); + copyDirectory(path.join(outDir, routeName), path.join(finalOutDir, routeName), true); + copyDirectory( + path.join(outDir, `_astro-${locale.toLowerCase()}`), + path.join(finalOutDir, `_astro-${locale.toLowerCase()}`), + ); + const localeNavigation = path.join(outDir, "nav", routeName); + if (fs.existsSync(localeNavigation)) { + copyDirectory(localeNavigation, path.join(finalOutDir, "nav", routeName), true); + } +} + // 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 }); @@ -90,8 +117,19 @@ run("pnpm", ["run", "prepare:site"], 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); +if (scope.deployTarget === "english" || (scope.deployTarget === "combined" && scope.locales.length === 0)) { + const environment = shardEnvironment( + cleanEnvironment, + "en", + scope.availableLocales, + finalOutDir, + { + emitEnglish: true, + loadEnglish: true, + emitLocaleFallbacks: scope.deployTarget === "combined" && scope.availableLocales.length > 0, + includePublic: true, + }, + ); run("pnpm", ["exec", "astro", "build"], environment); run(process.execPath, ["bin/postbuild.ts"], environment); process.exit(0); @@ -105,12 +143,20 @@ 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), -); +let englishOutDir: string | undefined; +if (scope.deployTarget === "combined") { + englishOutDir = path.join(shardsRoot, "en"); + run( + "pnpm", + ["exec", "astro", "build"], + shardEnvironment(cleanEnvironment, "en", scope.locales, englishOutDir, { + emitEnglish: true, + loadEnglish: true, + emitLocaleFallbacks: true, + includePublic: true, + }), + ); +} const localeOutputs: Array<{ locale: Locale; outDir: string }> = []; for (const locale of scope.locales) { @@ -118,24 +164,23 @@ for (const locale of scope.locales) { run( "pnpm", ["exec", "astro", "build"], - shardEnvironment(cleanEnvironment, locale, scope.locales, outDir), + shardEnvironment(cleanEnvironment, locale, scope.locales, outDir, { + emitEnglish: false, + loadEnglish: scope.deployTarget === "translations", + emitLocaleFallbacks: scope.deployTarget === "translations", + includePublic: false, + }), ); localeOutputs.push({ locale, outDir }); } fs.rmSync(finalOutDir, { recursive: true, force: true }); -copyDirectory(englishOutDir, finalOutDir); +if (englishOutDir) copyDirectory(englishOutDir, finalOutDir); +else fs.mkdirSync(finalOutDir, { recursive: true }); 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()}`), - ); + copyLocaleOutput(locale, outDir, finalOutDir); } const postbuildEnvironment = { ...cleanEnvironment, DOCS_OUT_DIR: finalOutDir }; run(process.execPath, ["bin/postbuild.ts"], postbuildEnvironment); +fs.rmSync(shardsRoot, { recursive: true, force: true }); diff --git a/microfrontends.json b/microfrontends.json new file mode 100644 index 000000000..f0bd3bcc8 --- /dev/null +++ b/microfrontends.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://openapi.vercel.sh/microfrontends.json", + "version": "1", + "applications": { + "clickhouse-docs": { + "development": { + "local": 4321, + "fallback": "https://clickhouse.com" + } + }, + "clickhouse-docs-translations": { + "packageName": "clickhouse-docs", + "routing": [ + { + "group": "translations", + "paths": [ + "/docs/ar/:path*", + "/docs/es/:path*", + "/docs/fr/:path*", + "/docs/ja/:path*", + "/docs/ko/:path*", + "/docs/pt-BR/:path*", + "/docs/ru/:path*", + "/docs/zh/:path*", + "/docs/_astro-ar/:path*", + "/docs/_astro-es/:path*", + "/docs/_astro-fr/:path*", + "/docs/_astro-ja/:path*", + "/docs/_astro-ko/:path*", + "/docs/_astro-pt-br/:path*", + "/docs/_astro-ru/:path*", + "/docs/_astro-zh/:path*", + "/docs/nav/ar/:path*", + "/docs/nav/es/:path*", + "/docs/nav/fr/:path*", + "/docs/nav/ja/:path*", + "/docs/nav/ko/:path*", + "/docs/nav/pt-BR/:path*", + "/docs/nav/ru/:path*", + "/docs/nav/zh/:path*" + ] + } + ] + } + } +} diff --git a/package.json b/package.json index d9f546bd1..8b45d2545 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@readme/httpsnippet": "11.4.0", "@scalar/openapi-parser": "0.28.12", "@vercel/connect": "^2.0.2", + "@vercel/microfrontends": "^2.4.0", "@vercel/speed-insights": "^2.0.0", "astro": "~7.3.2", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a65efa3b9..a52e29cb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,6 +52,9 @@ importers: '@vercel/connect': specifier: ^2.0.2 version: 2.0.2 + '@vercel/microfrontends': + specifier: ^2.4.0 + version: 2.4.0(@vercel/speed-insights@2.0.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@vercel/speed-insights': specifier: ^2.0.0 version: 2.0.0(react@19.2.8) @@ -1229,9 +1232,24 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@next/env@16.0.10': + resolution: {integrity: sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==} + '@nodable/entities@3.0.0': resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -2391,6 +2409,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/md5@2.3.6': + resolution: {integrity: sha512-WD69gNXtRBnpknfZcb4TRQ0XJQbUPZcai/Qdhmka3sxUR3Et8NrXoeAoknG/LghYHTf4ve795rInVYHBTQdNVA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -2478,6 +2499,33 @@ packages: resolution: {integrity: sha512-VYNCgUc0nOmC4WJmWw9GkrKdfr8Zl4/rxhC5SvgacBgxiW9W/9NRttUoHHXV8xdII3MaRgkZZVX8Ikzc/Jmjag==} engines: {node: '>=14'} + '@vercel/microfrontends@2.4.0': + resolution: {integrity: sha512-dpUzyjpLtE40gB+vxU3AWy5Dlkx/O91joVc/Q7PTYTtHVmw7ZkKgbR0QFEQJC0kw6SXOb35q46QN1BVwVRL5DQ==} + hasBin: true + peerDependencies: + '@sveltejs/kit': '>=1' + '@vercel/analytics': '>=1.5.0' + '@vercel/speed-insights': '>=1.2.0' + next: '>=13' + react: '>=17.0.0' + react-dom: '>=17.0.0' + vite: '>=5' + peerDependenciesMeta: + '@sveltejs/kit': + optional: true + '@vercel/analytics': + optional: true + '@vercel/speed-insights': + optional: true + next: + optional: true + react: + optional: true + react-dom: + optional: true + vite: + optional: true + '@vercel/oidc@3.8.5': resolution: {integrity: sha512-RwXYtnt6za+5UO4IaLywN/6B95AlLqynPRUWRJxeJ/qufwkcLUbZNUxYtzT0uMpuraWhlNcGqPNGkTnZr4BGBw==} engines: {node: '>= 20'} @@ -2707,6 +2755,10 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + browserslist@4.28.8: resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -2738,6 +2790,9 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + charenc@0.0.2: + resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -2778,6 +2833,10 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} @@ -2800,6 +2859,10 @@ packages: cookie-es@1.2.3: resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -2821,6 +2884,9 @@ packages: crossws@0.3.5: resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + crypt@0.0.2: + resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} + css-select@6.0.0: resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==} @@ -3167,6 +3233,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -3180,6 +3249,10 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -3202,6 +3275,9 @@ packages: fastdom@1.0.12: resolution: {integrity: sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==} + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -3214,6 +3290,10 @@ packages: fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + find-proc@0.1.0: resolution: {integrity: sha512-OaOpEYv2PiQ7SQ5LIrl+deA1XaWcxEjnpM6VuWXTUvn+teIXxeFTLDmu18/zDQpFmHN4o3oDBX+BT0AGwEhemg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3222,6 +3302,15 @@ packages: resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} engines: {node: '>=8'} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + fontace@0.4.1: resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} @@ -3293,6 +3382,10 @@ packages: github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -3369,6 +3462,10 @@ packages: http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -3405,6 +3502,9 @@ packages: is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-buffer@1.1.6: + resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} + is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} @@ -3413,9 +3513,21 @@ packages: engines: {node: '>=20'} hasBin: true + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + is-obj@1.0.1: resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} engines: {node: '>=0.10.0'} @@ -3695,6 +3807,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + md5@2.3.0: + resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} + mdast-util-definitions@6.0.0: resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} @@ -3759,6 +3874,10 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + mermaid@11.17.2: resolution: {integrity: sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==} @@ -3867,6 +3986,10 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -4091,6 +4214,9 @@ packages: resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quotation@2.0.3: resolution: {integrity: sha512-yEc24TEgCFLXx7D4JHJJkK4JFVtatO8fziwUxY4nB/Jbea9o9CVS3gt22mA0W7rPYAGW2fWzYDSOtD94PwOyqA==} @@ -4272,6 +4398,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -4287,6 +4416,10 @@ packages: retext@9.0.0: resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -4303,6 +4436,9 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} @@ -4477,6 +4613,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -6153,8 +6293,22 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@next/env@16.0.10': {} + '@nodable/entities@3.0.0': {} + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.3 + '@oslojs/encoding@1.1.0': {} '@oxc-project/types@0.148.0': {} @@ -7180,6 +7334,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/md5@2.3.6': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -7250,6 +7406,28 @@ snapshots: '@vercel/detect-agent@1.2.3': {} + '@vercel/microfrontends@2.4.0(@vercel/speed-insights@2.0.0(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': + dependencies: + '@next/env': 16.0.10 + '@types/md5': 2.3.6 + ajv: 8.20.0 + commander: 12.1.0 + cookie: 1.0.2 + fast-glob: 3.3.3 + http-proxy: 1.18.1 + jsonc-parser: 3.3.1 + md5: 2.3.0 + nanoid: 3.3.18 + path-to-regexp: 6.3.0 + semver: 7.8.5 + optionalDependencies: + '@vercel/speed-insights': 2.0.0(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) + transitivePeerDependencies: + - debug + '@vercel/oidc@3.8.5': dependencies: '@vercel/cli-config': 0.2.4 @@ -7569,6 +7747,10 @@ snapshots: boolbase@1.0.0: {} + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + browserslist@4.28.8: dependencies: baseline-browser-mapping: 2.11.20 @@ -7599,6 +7781,8 @@ snapshots: character-reference-invalid@2.0.1: {} + charenc@0.0.2: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -7633,6 +7817,8 @@ snapshots: commander@11.1.0: {} + commander@12.1.0: {} + commander@7.2.0: {} commander@8.3.0: {} @@ -7645,6 +7831,8 @@ snapshots: cookie-es@1.2.3: {} + cookie@1.0.2: {} + cookie@1.1.1: {} cookie@2.0.1: {} @@ -7667,6 +7855,8 @@ snapshots: dependencies: uncrypto: 0.1.3 + crypt@0.0.2: {} + css-select@6.0.0: dependencies: boolbase: 1.0.0 @@ -8091,6 +8281,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@4.0.7: {} + eventemitter3@5.0.4: {} execa@5.1.1: @@ -8109,6 +8301,14 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -8139,16 +8339,26 @@ snapshots: dependencies: strictdom: 1.0.1 + fastq@1.20.3: + dependencies: + reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 fflate@0.8.3: {} + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + find-proc@0.1.0: {} flattie@1.1.1: {} + follow-redirects@1.16.0: {} + fontace@0.4.1: dependencies: fontkitten: 1.0.3 @@ -8217,6 +8427,10 @@ snapshots: github-slugger@2.0.0: {} + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -8387,6 +8601,14 @@ snapshots: http-cache-semantics@4.2.0: {} + http-proxy@1.18.1: + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.16.0 + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + human-signals@2.1.0: {} humanize-ms@1.2.1: @@ -8416,12 +8638,22 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 + is-buffer@1.1.6: {} + is-decimal@2.0.1: {} is-docker@4.0.0: {} + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-number@7.0.0: {} + is-obj@1.0.1: {} is-plain-obj@4.1.0: {} @@ -8616,6 +8848,12 @@ snapshots: math-intrinsics@1.1.0: {} + md5@2.3.0: + dependencies: + charenc: 0.0.2 + crypt: 0.0.2 + is-buffer: 1.1.6 + mdast-util-definitions@6.0.0: dependencies: '@types/mdast': 4.0.4 @@ -8795,6 +9033,8 @@ snapshots: merge-stream@2.0.0: {} + merge2@1.4.1: {} + mermaid@11.17.2: dependencies: '@braintree/sanitize-url': 7.1.2 @@ -9084,6 +9324,11 @@ snapshots: transitivePeerDependencies: - supports-color + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + mime-db@1.52.0: {} mime-types@2.1.35: @@ -9286,6 +9531,8 @@ snapshots: es-define-property: 1.0.1 side-channel: 1.1.1 + queue-microtask@1.2.3: {} + quotation@2.0.3: {} radix3@1.1.2: {} @@ -9571,6 +9818,8 @@ snapshots: require-from-string@2.0.2: {} + requires-port@1.0.0: {} + resolve-pkg-maps@1.0.0: {} retext-latin@4.0.0: @@ -9598,6 +9847,8 @@ snapshots: retext-stringify: 4.0.0 unified: 11.0.5 + reusify@1.1.0: {} + robust-predicates@3.0.3: {} rolldown@1.2.7: @@ -9660,6 +9911,10 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + rw@1.3.3: {} safer-buffer@2.1.2: {} @@ -9898,6 +10153,10 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + tr46@0.0.3: {} trim-lines@3.0.1: {} diff --git a/src/README.md b/src/README.md index cfdd64c7a..4558bdb76 100644 --- a/src/README.md +++ b/src/README.md @@ -11,7 +11,7 @@ Mintlify-flavoured MDX build (see `src/plugins/vite-mintlify-snippets.ts` and |---|---| | `pnpm install` | Node 24, pnpm 10. | | `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 run build:vercel` | Fetches registered sources using Vercel Connect where required, removes the deployment OIDC identity, and builds the English, translations, or legacy combined Vercel artifact selected by `DOCS_DEPLOY_TARGET`. | | `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`). | @@ -24,16 +24,17 @@ Mintlify-flavoured MDX build (see `src/plugins/vite-mintlify-snippets.ts` and | Variable | Effect | |---|---| +| `DOCS_DEPLOY_TARGET` | Vercel artifact to assemble: `english`, `translations`, or the backward-compatible `combined` default. | | `DOCS_INCLUDE` | Comma-separated globs restricting the English collection (spikes, scoped previews). | | `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_LOCALES` | Translations included in a `translations` or legacy `combined` artifact: `none`, `all`, or a comma-separated list such as `es,fr`. The translations project uses `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. | | `DOCS_REMOTE_SOURCE_REPOSITORY` | CI-derived repository that owns the preview SHA. It defaults to the registered repository and differs only for a fork PR. | | `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. Vercel uses one persistent Astro cache per locale under `node_modules/.astro/`. | +| `DOCS_OUT_DIR`, `DOCS_CACHE_DIR` | Isolated output and cache directories. Vercel uses one persistent Astro cache per deployment target and 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 @@ -90,42 +91,61 @@ repository calls `.github/workflows/site-production.yml`. The reusable workflow builds the latest trusted Nimbus `main` with every registered remote and every translation, rather than promoting the source-only preview. -Pull-request previews build English only by default. Add -`docs-translations-all` to include every translated collection, or add one or -more locale labels such as `docs-translations-es` and -`docs-translations-pt-br`. Adding or removing one of these labels starts a new -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. `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. +Pull-request previews build the English project only by default. The Vercel +Microfrontends production fallback serves the last promoted translation +deployment for locale routes. Add `docs-translations-all` (or any legacy +`docs-translations-` label) to build the catch-all translations project +as well. Because all translations are one application, a translation-labelled +preview always builds every locale; a partial locale artifact would hide the +other locales rather than falling them back individually. + +`.github/workflows/site-production.yml` selects applications from the merged +paths. English content deploys only the English project, locale-only changes +deploy only the translations project, and shared renderer or build changes +deploy both. Reusable invocations from a remote source repository default to +English. A maintainer can also dispatch `english`, `translations`, or `all` +manually. Both projects build the same immutable `mintlify-docs-dev` revision. +When both participate, the workflow creates both Vercel deployments before it +waits, so their builds run concurrently. + +`bin/vercel-build.ts` compiles each requested locale in an isolated sequential +Astro child process and merges only `/docs/`, its namespaced +`/docs/_astro-` assets, and `/docs/nav/`. The translations +project loads English MDX so it can own localized fallback pages, but it does +not emit English routes or copy the shared image corpus. Consequently a +translation deployment contains every locale route—including English fallback +content—and the English deployment owns `/docs`, `/docs/_astro`, +`/docs/images`, and `/docs/img`. Vercel must be provisioned as follows: -1. Keep the single Git-connected Vercel project, but leave automatic Git - deployments disabled as specified in `vercel.json`; GitHub Actions creates - Git-backed preview and production deployments through the Vercel API. -2. Enable automatic System Environment Variables for the project. -3. Create the `connect-preview` Custom Environment. -4. Create a Vercel-managed GitHub connector named `clickhouse-docs` and install +1. Keep the existing `clickhouse-docs` project and create a second project + named exactly `clickhouse-docs-translations`. Connect both to + `ClickHouse/mintlify-docs-dev`; `vercel.json` disables automatic Git + deployments because GitHub Actions creates the Git-backed deployments. +2. Give both projects the same build command (`pnpm run build:vercel`), output + directory (`dist`), Node version, and automatic System Environment Variables. +3. Create a Microfrontends group containing both projects. Select + `clickhouse-docs` as the default application, set `/docs` as its default + route, and set the Preview fallback environment to Production. The routing + source of truth is `microfrontends.json`; the existing website Worker needs + to route only to the default project. +4. Create the `connect-preview` Custom Environment on the English project. +5. Create a Vercel-managed GitHub connector named `clickhouse-docs` and install it only for the private repositories registered in `remotes.json` and any private forks that are explicitly allowed to receive previews. -5. Attach `github/clickhouse-docs` to `production` and `connect-preview`. Do not - attach it to standard `preview`. -6. Set `DOCS_GITHUB_CONNECTOR=github/clickhouse-docs` in `production` and - `connect-preview`, but not in standard `preview`. +6. Attach `github/clickhouse-docs` to Production on both projects and to + `connect-preview` on the English project. Set + `DOCS_GITHUB_CONNECTOR=github/clickhouse-docs` in those environments, but + not in standard Preview. 7. Keep standard Preview free of secrets and privileged integrations. Every base-repository pull request builds from the primary repository's synthetic merge ref in this environment and omits registered remotes. 8. Add `VERCEL_TOKEN`, `VERCEL_ORG_ID`, and `VERCEL_PROJECT_ID` as repository - secrets under GitHub Actions. Do not store them as repository variables or - environment-scoped secrets. -9. Keep the Vercel build command as `pnpm run build:vercel` and the output - directory as `dist`. - -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. + secrets under GitHub Actions. Add the translations project's ID as the + repository variable `VERCEL_TRANSLATIONS_PROJECT_ID`; project IDs are not + credentials. Remote repositories need no new Vercel secret because their + reusable production calls deploy English only. +9. For the initial rollout, manually deploy `translations` first, then deploy + `english`. After the translations project has a Production deployment, + ordinary English previews can safely use it as their Production fallback. diff --git a/src/content.config.ts b/src/content.config.ts index 9001710c1..367c65da6 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -140,6 +140,10 @@ export const ACTIVE_LOCALES: string[] = [...scope.locales]; export const AVAILABLE_LOCALES: string[] = [...scope.availableLocales]; /** English routes are emitted only by the English build shard. */ export const EMIT_ENGLISH = scope.emitEnglish; +/** English content may also be loaded solely to emit untranslated locale fallbacks. */ +export const LOAD_ENGLISH = scope.loadEnglish; +/** Missing translations are emitted by the artifact which owns locale routes. */ +export const EMIT_LOCALE_FALLBACKS = scope.emitLocaleFallbacks; /** * Collection names are lowercase even when the canonical URL segment is not. @@ -174,7 +178,7 @@ export const collections = { docs: defineCollection({ loader: withNimbusMarkdown(glob({ base: ".", - pattern: EMIT_ENGLISH ? treePattern(".") : "__inactive_english__/**/*.{md,mdx}", + pattern: LOAD_ENGLISH ? treePattern(".") : "__inactive_english__/**/*.{md,mdx}", generateId: pathId, })), // Non-strict: the content carries Docusaurus-era keys we do not model. diff --git a/src/lib/scope.ts b/src/lib/scope.ts index a52c726c8..e31a49c69 100644 --- a/src/lib/scope.ts +++ b/src/lib/scope.ts @@ -1,11 +1,10 @@ /** - * Build scope for Vercel's combined site and its isolated locale build shards. + * Build scope for Vercel's English, translations, and legacy combined artifacts. * - * `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` - * selects one internal child build; `bin/vercel-build.ts` runs those children - * sequentially and merges their outputs. + * `DOCS_DEPLOY_TARGET` chooses the artifact. `DOCS_LOCALES` selects the locale + * set for a translations or combined artifact, while `DOCS_LOCALE` selects one + * internal child build. `bin/vercel-build.ts` runs locale children sequentially + * and merges their outputs. * * { * "locale": "en", @@ -25,6 +24,7 @@ import path from "node:path"; export const ALL_LOCALES = ["ar", "es", "fr", "ja", "ko", "pt-BR", "ru", "zh"] as const; export type Locale = (typeof ALL_LOCALES)[number]; export type BuildLocale = "en" | Locale; +export type DeployTarget = "combined" | "english" | "translations"; export interface RemotePreview { name: string; @@ -36,6 +36,8 @@ export interface RemotePreview { } export interface BuildScope { + /** Vercel artifact assembled by the top-level build orchestrator. */ + deployTarget: DeployTarget; /** Primary locale; non-English only inside a locale build shard. */ locale: BuildLocale; /** Non-English collections included in this artifact. */ @@ -44,6 +46,10 @@ export interface BuildScope { availableLocales: Locale[]; /** Whether this shard emits the English routes and English-only surfaces. */ emitEnglish: boolean; + /** Whether the English collection is loaded, including for locale fallbacks. */ + loadEnglish: boolean; + /** Whether this shard emits English pages missing from its locale collection. */ + emitLocaleFallbacks: boolean; /** Whether `reference/**` is part of the build. */ reference: boolean; /** Whether registered remote sources participate in this build. */ @@ -151,11 +157,28 @@ function parseRemotes(value: unknown, source: string): boolean { } } +function parseDeployTarget(value: unknown, source: string): DeployTarget { + if (typeof value !== "string") { + throw new Error(`${source} must be combined, english or translations`); + } + switch (value.trim().toLowerCase()) { + case "combined": + case "english": + case "translations": + return value.trim().toLowerCase() as DeployTarget; + default: + throw new Error(`${source} must be combined, english or translations`); + } +} + export function readScope(root = process.cwd()): BuildScope { + const envDeployTarget = (process.env.DOCS_DEPLOY_TARGET ?? "").trim(); 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 envLoadEnglish = (process.env.DOCS_LOAD_ENGLISH ?? "").trim(); + const envEmitLocaleFallbacks = (process.env.DOCS_EMIT_LOCALE_FALLBACKS ?? "").trim(); const envRemotes = (process.env.DOCS_REMOTES ?? "").trim(); const envReference = (process.env.DOCS_REFERENCE ?? "").trim().toLowerCase(); const envRemoteName = (process.env.DOCS_REMOTE_NAME ?? "").trim(); @@ -190,6 +213,9 @@ export function readScope(root = process.cwd()): BuildScope { .toLowerCase(); const isVercelProduction = process.env.VERCEL === "1" && vercelTarget === "production"; const isBuildShard = process.env.DOCS_BUILD_SHARD === "1"; + const deployTarget = envDeployTarget + ? parseDeployTarget(envDeployTarget, "DOCS_DEPLOY_TARGET") + : "combined"; const locale = envLocales ? "en" @@ -204,7 +230,7 @@ export function readScope(root = process.cwd()): BuildScope { ? [] : [locale]; - if (isVercelProduction && !isBuildShard) { + if (isVercelProduction && !isBuildShard && deployTarget === "combined") { if (locale !== "en") { throw new Error("Vercel production builds use DOCS_LOCALES=all, not DOCS_LOCALE"); } @@ -222,6 +248,18 @@ export function readScope(root = process.cwd()): BuildScope { if (!emitEnglish && locale === "en") { throw new Error("DOCS_EMIT_ENGLISH=false requires a non-English DOCS_LOCALE shard"); } + const loadEnglish = envLoadEnglish + ? parseReference(envLoadEnglish, "DOCS_LOAD_ENGLISH") + : emitEnglish; + const emitLocaleFallbacks = envEmitLocaleFallbacks + ? parseReference(envEmitLocaleFallbacks, "DOCS_EMIT_LOCALE_FALLBACKS") + : emitEnglish && availableLocales.length > 0; + if (emitEnglish && !loadEnglish) { + throw new Error("DOCS_EMIT_ENGLISH=true requires DOCS_LOAD_ENGLISH=true"); + } + if (emitLocaleFallbacks && !loadEnglish) { + throw new Error("DOCS_EMIT_LOCALE_FALLBACKS=true requires DOCS_LOAD_ENGLISH=true"); + } const reference = envReference ? parseReference(envReference, "DOCS_REFERENCE") : fileScope?.reference !== undefined @@ -253,12 +291,24 @@ export function readScope(root = process.cwd()): BuildScope { if (isVercelProduction && !remotes) { throw new Error("Vercel production builds require DOCS_REMOTES=all"); } - if (envLocale || envLocales || envAvailableLocales || envEmitEnglish || envRemotes || envReference || hasRemoteEnvironment) source = "env"; + if (!isBuildShard && deployTarget === "english" && locales.length > 0) { + throw new Error("DOCS_DEPLOY_TARGET=english requires DOCS_LOCALES=none"); + } + if (!isBuildShard && deployTarget === "translations" && locales.length === 0) { + throw new Error("DOCS_DEPLOY_TARGET=translations requires DOCS_LOCALES"); + } + if (remotePreview && deployTarget === "translations") { + throw new Error("Remote pull-request previews cannot target translations"); + } + if (envDeployTarget || envLocale || envLocales || envAvailableLocales || envEmitEnglish || envLoadEnglish || envEmitLocaleFallbacks || envRemotes || envReference || hasRemoteEnvironment) source = "env"; return { + deployTarget, locale, locales, availableLocales, emitEnglish, + loadEnglish, + emitLocaleFallbacks, reference, remotes, remotePreview, diff --git a/src/pages/[locale]/[...slug].astro b/src/pages/[locale]/[...slug].astro index 49d1a073e..a0686090f 100644 --- a/src/pages/[locale]/[...slug].astro +++ b/src/pages/[locale]/[...slug].astro @@ -24,6 +24,7 @@ import { ACTIVE_LOCALES, AVAILABLE_LOCALES, EMIT_ENGLISH, + EMIT_LOCALE_FALLBACKS, SECTIONS, localeCollectionName, type LocaleCollectionName, @@ -55,11 +56,12 @@ export async function getStaticPaths() { } } - // The English shard alone emits missing-translation fallbacks. Locale - // shards therefore compile only their translated collection, never English. - if (EMIT_ENGLISH && AVAILABLE_LOCALES.length) { + // A combined artifact emits every fallback from its English shard. A + // translations-only artifact emits fallbacks from each owning locale shard. + if (EMIT_LOCALE_FALLBACKS) { const english = await getCollection("docs"); - for (const sourceLocale of AVAILABLE_LOCALES) { + const fallbackLocales = EMIT_ENGLISH ? AVAILABLE_LOCALES : ACTIVE_LOCALES; + for (const sourceLocale of fallbackLocales) { const locale = localeRouteName(sourceLocale); const translated = translatedPageIds(sourceLocale, SECTIONS); const navigationDigest = loadGeneratedNavigation(sourceLocale).digest; diff --git a/src/pages/nav/[...key].astro b/src/pages/nav/[...key].astro index 53dbbe2e5..d4be38151 100644 --- a/src/pages/nav/[...key].astro +++ b/src/pages/nav/[...key].astro @@ -11,7 +11,7 @@ import { LAZY_MIN_CHILDREN, collectGroups, toRendered, type ConfigItem } from "@ import sidebarItems from "@/generated/sidebar.items.json"; import fs from "node:fs"; import nodePath from "node:path"; -import { AVAILABLE_LOCALES, EMIT_ENGLISH } from "@/content.config"; +import { ACTIVE_LOCALES, 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"; @@ -25,7 +25,7 @@ export function getStaticPaths() { ? [{ prefix: [], items: sidebarItems as ConfigItem[] }] : []; // Locale trees (generated by bin/gen-sidebar.ts --locale) are namespaced under /nav//. - for (const locale of EMIT_ENGLISH ? AVAILABLE_LOCALES : []) { + for (const locale of EMIT_ENGLISH ? AVAILABLE_LOCALES : ACTIVE_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[] }); } From ccabdaaf6ece6a9b5fbd002008ce9256013067cc Mon Sep 17 00:00:00 2001 From: Shaun Struwig <41984034+Blargian@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:58:56 +0200 Subject: [PATCH 3/6] Use the existing i18n Vercel project name --- microfrontends.json | 2 +- src/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/microfrontends.json b/microfrontends.json index f0bd3bcc8..1efb4bafb 100644 --- a/microfrontends.json +++ b/microfrontends.json @@ -8,7 +8,7 @@ "fallback": "https://clickhouse.com" } }, - "clickhouse-docs-translations": { + "clickhouse-docs-i18n": { "packageName": "clickhouse-docs", "routing": [ { diff --git a/src/README.md b/src/README.md index 4558bdb76..a99fb70fe 100644 --- a/src/README.md +++ b/src/README.md @@ -120,7 +120,7 @@ content—and the English deployment owns `/docs`, `/docs/_astro`, Vercel must be provisioned as follows: 1. Keep the existing `clickhouse-docs` project and create a second project - named exactly `clickhouse-docs-translations`. Connect both to + named exactly `clickhouse-docs-i18n`. Connect both to `ClickHouse/mintlify-docs-dev`; `vercel.json` disables automatic Git deployments because GitHub Actions creates the Git-backed deployments. 2. Give both projects the same build command (`pnpm run build:vercel`), output From 4f56651a1e1e2bca6ad7c209b3e5bcd1a34d2e87 Mon Sep 17 00:00:00 2001 From: Shaun Struwig <41984034+Blargian@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:18:31 +0200 Subject: [PATCH 4/6] Fix production deployment scope selection --- .github/workflows/site-production.yml | 35 +++++++++++++++++++-------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/.github/workflows/site-production.yml b/.github/workflows/site-production.yml index 96a8db70c..516216310 100644 --- a/.github/workflows/site-production.yml +++ b/.github/workflows/site-production.yml @@ -55,6 +55,15 @@ jobs: exit 1 fi + - name: Check out the production history for change classification + if: >- + github.repository == 'ClickHouse/mintlify-docs-dev' && + github.event_name == 'push' && + inputs.deployment_scope == '' + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Resolve the trusted documentation site revision id: site env: @@ -76,26 +85,28 @@ jobs: - name: Select production applications id: scope env: - GH_TOKEN: ${{ github.token }} REQUESTED_SCOPE: ${{ inputs.deployment_scope }} + SITE_REPOSITORY: ClickHouse/mintlify-docs-dev run: | scope="$REQUESTED_SCOPE" - if [[ "$GITHUB_EVENT_NAME" == "workflow_call" && -z "$scope" ]]; then - scope="english" - elif [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && -z "$scope" ]]; then - scope="all" - elif [[ "$GITHUB_EVENT_NAME" == "push" ]]; then - scope="" + if [[ -n "$scope" ]]; then + # Explicit workflow_dispatch and workflow_call inputs win. Reusable + # workflows inherit the caller's event name, so event_name cannot + # be used to identify workflow_call safely. + : + elif [[ "$GITHUB_REPOSITORY" == "$SITE_REPOSITORY" && "$GITHUB_EVENT_NAME" == "push" ]]; then deploy_english=false deploy_translations=false before="${{ github.event.before }}" if [[ "$before" =~ ^0+$ ]]; then scope="all" else - mapfile -t changed_files < <( - gh api --paginate "repos/$GITHUB_REPOSITORY/compare/$before...$GITHUB_SHA" --jq '.files[].filename' - ) + # The GitHub Compare API truncates the file list at 300 entries. + # A full checkout lets Git classify arbitrarily large pushes. + changed_file_list="$(git diff --name-only "$before" "$GITHUB_SHA")" + mapfile -t changed_files <<< "$changed_file_list" for file in "${changed_files[@]}"; do + [[ -z "$file" ]] && continue case "$file" in ar/*|es/*|fr/*|ja/*|ko/*|pt-BR/*|ru/*|zh/*) deploy_translations=true @@ -117,6 +128,10 @@ jobs: scope="english" fi fi + else + # Fail closed to the lightweight application for callers created + # before deployment_scope was added to the reusable interface. + scope="english" fi case "$scope" in From b9baa65b50c81bbdfe696265958df763480002cf Mon Sep 17 00:00:00 2001 From: Shaun Struwig <41984034+Blargian@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:34:31 +0200 Subject: [PATCH 5/6] Cancel superseded Vercel previews --- .github/workflows/remote-docs-preview.yml | 126 ++++++++++++++++++ .github/workflows/site-preview.yml | 149 +++++++++++++++++++++- 2 files changed, 268 insertions(+), 7 deletions(-) diff --git a/.github/workflows/remote-docs-preview.yml b/.github/workflows/remote-docs-preview.yml index 3fb5b92f0..260261022 100644 --- a/.github/workflows/remote-docs-preview.yml +++ b/.github/workflows/remote-docs-preview.yml @@ -166,6 +166,131 @@ jobs: VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} run: | + deployment_id="" + deployment_finished=false + + cancel_vercel_deployment() { + local deployment_id_to_cancel="$1" + local deployment_label="$2" + local cancel_response + local cancel_exit_code + local deployment_response + local ready_state + + if cancel_response="$( + curl --silent --show-error --fail-with-body \ + --request PATCH \ + --header "Authorization: Bearer $VERCEL_TOKEN" \ + "https://api.vercel.com/v12/deployments/$deployment_id_to_cancel/cancel?teamId=$VERCEL_ORG_ID" + )"; then + echo "Canceled $deployment_label Vercel deployment $deployment_id_to_cancel." + return 0 + else + cancel_exit_code=$? + fi + + if deployment_response="$( + curl --silent --show-error --fail-with-body \ + --header "Authorization: Bearer $VERCEL_TOKEN" \ + "https://api.vercel.com/v13/deployments/$deployment_id_to_cancel?teamId=$VERCEL_ORG_ID" + )"; then + ready_state="$(jq -er .readyState <<< "$deployment_response")" + case "$ready_state" in + READY|ERROR|CANCELED|DELETED|BLOCKED) + echo "$deployment_label Vercel deployment $deployment_id_to_cancel already reached $ready_state." + return 0 + ;; + esac + fi + + printf '%s\n' "$cancel_response" >&2 + echo "Unable to cancel $deployment_label Vercel deployment $deployment_id_to_cancel." >&2 + return "$cancel_exit_code" + } + + cancel_superseded_deployments() { + local ready_state + local until="" + local list_response + local next + local superseded_id + local -a request_args + local -a deployment_ids + + for ready_state in QUEUED INITIALIZING BUILDING; do + until="" + while true; do + request_args=( + --silent + --show-error + --fail-with-body + --get + --header "Authorization: Bearer $VERCEL_TOKEN" + --data-urlencode "projectId=$VERCEL_PROJECT_ID" + --data-urlencode "state=$ready_state" + --data-urlencode "limit=100" + --data-urlencode "teamId=$VERCEL_ORG_ID" + ) + if [[ -n "$until" ]]; then + request_args+=(--data-urlencode "until=$until") + fi + + if ! list_response="$( + curl "${request_args[@]}" "https://api.vercel.com/v7/deployments" + )"; then + printf '%s\n' "$list_response" >&2 + echo "Unable to list source-preview Vercel deployments." >&2 + return 1 + fi + + mapfile -t deployment_ids < <( + jq -r \ + --arg pull_request "$PULL_REQUEST_NUMBER" \ + --arg source_repository "$DOCS_REMOTE_REPOSITORY" \ + '.deployments[] + | select((.meta.buildScope? // "") == "source-preview") + | select((.meta.sourceRepository? // "") == $source_repository) + | select((.meta.pullRequest? // "") == $pull_request) + | (.uid // .id)' \ + <<< "$list_response" + ) + for superseded_id in "${deployment_ids[@]}"; do + cancel_vercel_deployment "$superseded_id" "superseded source-preview" || return 1 + done + + next="$(jq -er '.pagination.next // ""' <<< "$list_response")" + if [[ -z "$next" ]]; then + break + fi + if [[ "$next" == "$until" ]]; then + echo "Vercel returned a repeated pagination cursor while listing source-preview deployments." >&2 + return 1 + fi + until="$next" + done + done + } + + finalize_deployment() { + local exit_code=$? + trap - EXIT INT TERM + if [[ "$deployment_finished" != true && -n "$deployment_id" ]]; then + if ! cancel_vercel_deployment "$deployment_id" "source-preview"; then + echo "::warning::Unable to cancel the interrupted source-preview deployment." + if [[ "$exit_code" -eq 0 ]]; then + exit_code=1 + fi + fi + fi + exit "$exit_code" + } + + cancel_superseded_deployments + + trap finalize_deployment EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + if ! project_response="$( curl --silent --show-error --fail-with-body \ --header "Authorization: Bearer $VERCEL_TOKEN" \ @@ -274,6 +399,7 @@ jobs: echo "Vercel returned an invalid preview URL." >&2 exit 1 fi + deployment_finished=true - name: Add the preview link to the pull request if: github.repository == inputs.source_repository diff --git a/.github/workflows/site-preview.yml b/.github/workflows/site-preview.yml index f8e7a16f6..17c15e048 100644 --- a/.github/workflows/site-preview.yml +++ b/.github/workflows/site-preview.yml @@ -233,18 +233,158 @@ jobs: return 1 } + english_id="" + translations_id="" + preview_url="" + translations_url="" + english_state="Not started" + translations_state="Production fallback" preview_finished=false + + cancel_vercel_deployment() { + local deployment_id="$1" + local deployment_label="$2" + local cancel_response + local cancel_exit_code + local deployment_response + local ready_state + + if cancel_response="$( + curl --silent --show-error --fail-with-body \ + --request PATCH \ + --header "Authorization: Bearer $VERCEL_TOKEN" \ + "https://api.vercel.com/v12/deployments/$deployment_id/cancel?teamId=$VERCEL_ORG_ID" + )"; then + echo "Canceled $deployment_label Vercel deployment $deployment_id." + return 0 + else + cancel_exit_code=$? + fi + + # A deployment can finish between being listed and receiving the + # cancellation request. Vercel returns HTTP 400 in that case, so + # verify that it is terminal rather than hiding an API failure. + if deployment_response="$( + curl --silent --show-error --fail-with-body \ + --header "Authorization: Bearer $VERCEL_TOKEN" \ + "https://api.vercel.com/v13/deployments/$deployment_id?teamId=$VERCEL_ORG_ID" + )"; then + ready_state="$(jq -er .readyState <<< "$deployment_response")" + case "$ready_state" in + READY|ERROR|CANCELED|DELETED|BLOCKED) + echo "$deployment_label Vercel deployment $deployment_id already reached $ready_state." + return 0 + ;; + esac + fi + + printf '%s\n' "$cancel_response" >&2 + echo "Unable to cancel $deployment_label Vercel deployment $deployment_id." >&2 + return "$cancel_exit_code" + } + + cancel_superseded_deployments() { + local project_id="$1" + local build_scope="$2" + local deployment_label="$3" + local ready_state + local until="" + local list_response + local next + local deployment_id + local -a request_args + local -a deployment_ids + + if [[ -z "$project_id" ]]; then + return 0 + fi + + for ready_state in QUEUED INITIALIZING BUILDING; do + until="" + while true; do + request_args=( + --silent + --show-error + --fail-with-body + --get + --header "Authorization: Bearer $VERCEL_TOKEN" + --data-urlencode "projectId=$project_id" + --data-urlencode "state=$ready_state" + --data-urlencode "limit=100" + --data-urlencode "teamId=$VERCEL_ORG_ID" + ) + if [[ -n "$until" ]]; then + request_args+=(--data-urlencode "until=$until") + fi + + if ! list_response="$( + curl "${request_args[@]}" "https://api.vercel.com/v7/deployments" + )"; then + printf '%s\n' "$list_response" >&2 + echo "Unable to list $deployment_label Vercel deployments." >&2 + return 1 + fi + + mapfile -t deployment_ids < <( + jq -r \ + --arg build_scope "$build_scope" \ + --arg pull_request "$PULL_REQUEST_NUMBER" \ + '.deployments[] + | select((.meta.pullRequest? // "") == $pull_request) + | select((.meta.buildScope? // "") == $build_scope) + | (.uid // .id)' \ + <<< "$list_response" + ) + for deployment_id in "${deployment_ids[@]}"; do + cancel_vercel_deployment "$deployment_id" "superseded $deployment_label" || return 1 + done + + next="$(jq -er '.pagination.next // ""' <<< "$list_response")" + if [[ -z "$next" ]]; then + break + fi + if [[ "$next" == "$until" ]]; then + echo "Vercel returned a repeated pagination cursor while listing $deployment_label deployments." >&2 + return 1 + fi + until="$next" + done + done + } + finalize_preview_comment() { local exit_code=$? - trap - EXIT + local cleanup_failed=false + trap - EXIT INT TERM if [[ "$preview_finished" != true ]]; then - if ! update_preview_comment_with_retry "🔴 Failed"; then + if [[ -n "$english_id" ]] && ! cancel_vercel_deployment "$english_id" "English"; then + cleanup_failed=true + fi + if [[ -n "$translations_id" ]] && ! cancel_vercel_deployment "$translations_id" "translations"; then + cleanup_failed=true + fi + if [[ -n "$preview_url" ]] && ! update_preview_comment_with_retry "🔴 Failed"; then echo "::warning::Unable to mark the preview comment as failed." fi fi + if [[ "$cleanup_failed" == true && "$exit_code" -eq 0 ]]; then + exit_code=1 + fi exit "$exit_code" } + # GitHub's concurrency group stops the superseded Actions run, but + # Vercel continues any deployment already created by that runner. + # Cancel matching in-progress deployments before creating this run's + # replacements. This also covers abrupt runner termination where the + # old run's EXIT trap did not execute. + cancel_superseded_deployments "$VERCEL_PROJECT_ID" "english-preview" "English" + cancel_superseded_deployments "$VERCEL_TRANSLATIONS_PROJECT_ID" "translations-preview" "translations" + + trap finalize_preview_comment EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + if ! project_response="$( curl --silent --show-error --fail-with-body \ --header "Authorization: Bearer $VERCEL_TOKEN" \ @@ -312,8 +452,6 @@ jobs: preview_url="https://${deployment_url#https://}" english_state="🟡 Building" translations_state="Production fallback" - translations_id="" - translations_url="" echo "preview_url=$preview_url" >> "$GITHUB_OUTPUT" echo "English Vercel deployment: $preview_url" if [[ ! "$preview_url" =~ ^https://[^[:space:]]+$ ]]; then @@ -394,9 +532,6 @@ jobs: echo "Translations Vercel deployment: $translations_url" 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 From 56cfa0c8b412d774ab762f2366275c74be1a501c Mon Sep 17 00:00:00 2001 From: Shaun Struwig <41984034+Blargian@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:36:02 +0200 Subject: [PATCH 6/6] Deploy all applications after force pushes --- .github/workflows/site-production.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/site-production.yml b/.github/workflows/site-production.yml index 516216310..4516ac31b 100644 --- a/.github/workflows/site-production.yml +++ b/.github/workflows/site-production.yml @@ -85,6 +85,7 @@ jobs: - name: Select production applications id: scope env: + FORCED_PUSH: ${{ github.event.forced }} REQUESTED_SCOPE: ${{ inputs.deployment_scope }} SITE_REPOSITORY: ClickHouse/mintlify-docs-dev run: | @@ -98,7 +99,9 @@ jobs: deploy_english=false deploy_translations=false before="${{ github.event.before }}" - if [[ "$before" =~ ^0+$ ]]; then + if [[ "$before" =~ ^0+$ || "$FORCED_PUSH" == "true" ]]; then + # A new branch or rewritten history has no reliable path diff. + # Both applications must be refreshed from the new source tree. scope="all" else # The GitHub Compare API truncates the file list at 300 entries.