This is an automated email from the ASF dual-hosted git repository.

Yilialinn pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-website.git


The following commit(s) were added to refs/heads/master by this push:
     new 6aff5caf279 fix(seo): resolve APISIX crawl, indexing, and sitemap 
issues (#2100)
6aff5caf279 is described below

commit 6aff5caf279b4cdba50cac0c3c0c57305d06e326
Author: Yilia Lin <[email protected]>
AuthorDate: Thu Aug 20 09:06:05 2026 +0800

    fix(seo): resolve APISIX crawl, indexing, and sitemap issues (#2100)
---
 .github/workflows/deploy.yml                       |  64 ++++++++++-
 .github/workflows/lint.yml                         |  11 ++
 .htaccess                                          | 116 +++++++++++++++++++-
 .../blog/2023/03/10/release-apache-apisix-3.2.0.md |   1 +
 .../blog/2023/03/09/release-apache-apisix-3.2.0.md |   1 +
 doc/src/theme/LayoutHead/index.tsx                 |  39 +++++--
 next/package-lock.json                             |   1 +
 next/package.json                                  |   2 +-
 next/scripts/assert-local-canonical-targets.mjs    |  77 ++++++++++++++
 .../assert-local-canonical-targets.test.mjs        |  41 +++++++
 next/scripts/generate-sitemaps.mjs                 |  36 ++++++-
 next/scripts/generate-sitemaps.test.mjs            |  24 ++++-
 next/src/components/Header.astro                   |   6 +-
 next/src/layouts/Article.astro                     |  24 ++++-
 next/src/layouts/Base.astro                        |  31 ++++--
 next/src/layouts/DocPage.astro                     |   8 +-
 next/src/lib/blog-feed.ts                          |  63 +++++++++++
 next/src/lib/content.ts                            | 116 +++++++++++++-------
 next/src/pages/articles/[slug].astro               |   3 +-
 next/src/pages/blog/[...rest].astro                |  12 ++-
 next/src/pages/blog/atom.xml.ts                    |   7 ++
 next/src/pages/blog/rss.xml.ts                     |   7 ++
 next/src/pages/learning-center/[slug].astro        |   1 +
 next/src/pages/zh/articles/[slug].astro            |   5 +-
 next/src/pages/zh/blog/[...rest].astro             |  12 ++-
 next/src/pages/zh/blog/atom.xml.ts                 |   7 ++
 next/src/pages/zh/blog/rss.xml.ts                  |   7 ++
 next/tests/e2e/seo-signals.spec.mjs                |  90 ++++++++++++++++
 scripts/sync-docs.js                               |  22 +++-
 website/docusaurus.config.js                       |   8 --
 website/static/robots.txt                          | 118 +--------------------
 31 files changed, 754 insertions(+), 206 deletions(-)

diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index e1ab996cf38..ac63326af82 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -144,16 +144,53 @@ jobs:
 
       # Guards the canonical contract of versioned doc pages (see
       # doc/src/theme/LayoutHead/index.tsx): versioned pages must carry exactly
-      # one canonical pointing at the version-less latest URL.
+      # one canonical. It points to the version-less latest URL when that page
+      # exists; otherwise the historical/next page self-canonicalizes.
       #
       # The version-less page itself is no longer part of this build — wave 3
       # moved it to Astro — so the half of this check that covered it now runs
       # after the overlay, against the page that actually ships.
       - name: Assert canonical contract
         run: |
-          f=$(ls website/build/docs/apisix/3.*/plugins/cors/index.html | head 
-1)
+          set -euo pipefail
+          for f in \
+            website/build/docs/apisix/3.16/installation-guide/index.html \
+            website/build/zh/docs/apisix/3.16/installation-guide/index.html \
+            website/build/docs/apisix/next/installation-guide/index.html \
+            website/build/zh/docs/apisix/next/installation-guide/index.html; do
+            test -f "$f"
+            test "$(grep -o 'rel="canonical"' "$f" | wc -l)" -eq 1
+            grep -q 'name="robots" content="noindex,follow"' "$f"
+            case "$f" in
+              website/build/zh/*)
+                grep -q 'rel="canonical" 
href="https://apisix.apache.org/zh/docs/apisix/installation-guide/";' "$f"
+                ;;
+              *)
+                grep -q 'rel="canonical" 
href="https://apisix.apache.org/docs/apisix/installation-guide/";' "$f"
+                ;;
+            esac
+          done
+          # CORS gained the API7 hub canonical in 3.17. Keep that intentional
+          # cross-site contract separate from the APISIX-local fixture above.
+          while IFS='|' read -r f canonical; do
+            test -f "$f"
+            test "$(grep -o 'rel="canonical"' "$f" | wc -l)" -eq 1
+            grep -q 'name="robots" content="noindex,follow"' "$f"
+            grep -q "rel=\"canonical\" href=\"$canonical\"" "$f"
+          done <<'EOF'
+          
website/build/docs/apisix/3.17/plugins/cors/index.html|https://docs.api7.ai/hub/cors
+          
website/build/zh/docs/apisix/3.17/plugins/cors/index.html|https://docs.apiseven.com/hub/cors
+          
website/build/docs/apisix/next/plugins/cors/index.html|https://docs.api7.ai/hub/cors
+          
website/build/zh/docs/apisix/next/plugins/cors/index.html|https://docs.apiseven.com/hub/cors
+          EOF
+          # `mcp-bridge` exists on `next`, but not in the latest release. It
+          # must remain self-canonical instead of pointing at a missing local
+          # version-less page.
+          f=website/build/docs/apisix/next/plugins/mcp-bridge/index.html
+          test -f "$f"
           test "$(grep -o 'rel="canonical"' "$f" | wc -l)" -eq 1
-          grep -q 'rel="canonical" 
href="https://apisix.apache.org/docs/apisix/plugins/cors/";' "$f"
+          grep -q 'name="robots" content="noindex,follow"' "$f"
+          grep -q 'rel="canonical" 
href="https://apisix.apache.org/docs/apisix/next/plugins/mcp-bridge/";' "$f"
 
       - name: Update sitemap.xml
         run: |
@@ -408,6 +445,10 @@ jobs:
           grep -q 'rel="canonical" href="https://docs.apiseven.com/hub/cors";' 
"$f"
           grep -q 'property="og:url" 
content="https://apisix.apache.org/docs/apisix/plugins/cors/";' \
             website/build/docs/apisix/plugins/cors/index.html
+          # Every APISIX-local canonical in the final output must resolve to a
+          # real file. This catches missing latest-release targets that sample
+          # checks cannot cover, including docs that exist only on `next`.
+          node next/scripts/assert-local-canonical-targets.mjs --dist 
website/build
           # Fail the deploy if the landing pages are not the Astro build, or
           # if the homepage lost its stylesheet link.
           grep -q 'The same gateway, now for your LLM traffic' 
website/build/index.html
@@ -417,12 +458,17 @@ jobs:
           # Wave-2 sanity: key subtree landmarks exist.
           test -f website/build/blog/index.html
           test -f website/build/zh/blog/index.html
+          test -f website/build/blog/rss.xml
+          test -f website/build/blog/atom.xml
+          test -f website/build/zh/blog/rss.xml
+          test -f website/build/zh/blog/atom.xml
           test -f website/build/blog/tags/index.html
           test -f website/build/learning-center/index.html
           test -f website/build/learning-center/atom.xml
           test -f website/build/articles/index.html
           test -f website/build/zh/articles/rss.xml
           test -f website/build/events/archive/index.html
+          test -f website/build/docs/general/events/index.html
           # The comparisons hub (a single index.html in both locales) is 
Astro's.
           grep -q '/_astro/' website/build/comparisons/index.html
           grep -q '/_astro/' website/build/zh/comparisons/index.html
@@ -541,6 +587,18 @@ jobs:
             echo 'Low-value aggregation URLs remain in the final sitemap.'
             exit 1
           fi
+          if grep -q 
'<loc>https://apisix.apache.org/docs/apisix/plugins/cors/</loc>' 
website/build/sitemap.xml; then
+            echo 'A page canonicalized to API7 remains in the APISIX sitemap.'
+            exit 1
+          fi
+          if grep -Eq 
'<loc>https://apisix\.apache\.org/(zh/)?docs/[^/]+/(next|([[:alnum:]_-]+-)?[0-9]+\.[0-9]+(\.[0-9]+)?)/'
 website/build/sitemap.xml website/build/zh/sitemap.xml; then
+            echo 'Historical or next docs remain in the final sitemap.'
+            exit 1
+          fi
+          if grep -Eq '^Disallow: 
/(zh/)?docs/.+/(next|([[:alnum:]_-]+-)?[0-9]+\.[0-9]+(\.[0-9]+)?)/' 
website/build/robots.txt; then
+            echo 'robots.txt still blocks versioned docs from reading 
noindex/canonical.'
+            exit 1
+          fi
 
       - name: Test the final overlaid site on desktop and mobile
         working-directory: next
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 5483c08a8a5..47c3436999a 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -23,6 +23,17 @@ jobs:
       - name: Test Ingress documentation redirects
         working-directory: next
         run: npm run test:redirects
+      - name: Sync APISIX release docs for SEO tests
+        working-directory: next
+        run: |
+          set -euo pipefail
+          mkdir -p .sync
+          APISIX_SERIES=$(node -e "const 
v=require('../config/apisix-versions.js').versions;console.log(v[v.length-1])")
+          git clone --depth 1 --filter=blob:none --sparse -q \
+            -b "release/$APISIX_SERIES" \
+            https://github.com/apache/apisix.git \
+            .sync/apisix
+          (cd .sync/apisix && git sparse-checkout set docs)
       - name: Sync site content
         working-directory: next
         run: node scripts/sync-content.mjs
diff --git a/.htaccess b/.htaccess
index 378962b5a74..3f5bb18b608 100644
--- a/.htaccess
+++ b/.htaccess
@@ -41,6 +41,120 @@ Redirect 302 "/slack" 
"https://join.slack.com/t/the-asf/shared_invite/zt-1ugrg37
 Redirect 302 "/community-meeting-signup" 
"https://docs.google.com/forms/d/1C9bIJ3eh0bQrBdv4rPGxHDUvX4giNQ_IRCmDDOQ2mgE/";
 Redirect 302 "/contributor-workshop-signup" 
"https://docs.google.com/forms/d/1LUER3R9-aFsUm7MhjVd_CM1xAGnkuWIe62prFH5aqAE/";
 Redirect 302 "/guest-blog-post" "https://forms.gle/unQpSm7FyqkfaSSP8/";
+
+# Valid entry pages for former empty directories reported as 403 in GSC.
+RedirectMatch 301 "^(/zh)?/events/?$" "$1/docs/general/events/"
+RedirectMatch 301 "^(/zh)?/docs/python-plugin-runner/?$" 
"$1/docs/python-plugin-runner/getting-started/"
+RedirectMatch 301 "^(/zh)?/docs/ingress-controller/reference/?$" 
"$1/docs/ingress-controller/reference/apisix-ingress-controller/api-reference/"
+RedirectMatch 301 "^(/zh)?/docs/ingress-controller/references/?$" 
"$1/docs/ingress-controller/reference/apisix-ingress-controller/api-reference/"
+RedirectMatch 301 "^(/zh)?/docs/ingress-controller/concepts/?$" 
"$1/docs/ingress-controller/overview/"
+RedirectMatch 301 "^(/zh)?/docs/ingress-controller/deployments/?$" 
"$1/docs/ingress-controller/install/"
+RedirectMatch 301 "^(/zh)?/docs/ingress-controller/practices/?$" 
"$1/docs/ingress-controller/overview/"
+RedirectMatch 301 "^(/zh)?/docs/ingress-controller/aeps/?$" 
"$1/docs/ingress-controller/concepts/gateway-api/"
+RedirectMatch 301 "^(/zh)?/docs/ingress-controller/plugins/?$" 
"$1/docs/ingress-controller/overview/"
+RedirectMatch 301 
"^(/zh)?/docs/ingress-controller/[01]\.\d+\.\d+/(?:reference|references)/?$" 
"$1/docs/ingress-controller/reference/apisix-ingress-controller/api-reference/"
+RedirectMatch 301 "^(/zh)?/docs/ingress-controller/[01]\.\d+\.\d+/concepts/?$" 
"$1/docs/ingress-controller/overview/"
+RedirectMatch 301 
"^(/zh)?/docs/ingress-controller/[01]\.\d+\.\d+/deployments/?$" 
"$1/docs/ingress-controller/install/"
+RedirectMatch 301 
"^(/zh)?/docs/ingress-controller/[01]\.\d+\.\d+/practices/?$" 
"$1/docs/ingress-controller/overview/"
+RedirectMatch 301 "^(/zh)?/docs/ingress-controller/[01]\.\d+\.\d+/aeps/?$" 
"$1/docs/ingress-controller/concepts/gateway-api/"
+RedirectMatch 301 "^(/zh)?/docs/ingress-controller/[01]\.\d+\.\d+/plugins/?$" 
"$1/docs/ingress-controller/overview/"
+RedirectMatch 301 
"^(/zh)?/docs/docker/(?:apisix-(?:dashboard-)?)?\d+\.\d+(?:\.\d+)?/?$" 
"$1/docs/docker/build/"
+RedirectMatch 301 "^(/zh)?/docs/go-plugin-runner/\d+\.\d+(?:\.\d+)?/?$" 
"$1/docs/go-plugin-runner/getting-started/"
+
+# Confirmed moved or malformed current-document URLs from the GSC exports.
+RedirectMatch 301 "^(/zh)?/docs/ingress-controller/reference/api-reference/?$" 
"$1/docs/ingress-controller/reference/apisix-ingress-controller/api-reference/"
+RedirectMatch 301 
"^(/zh)?/docs/ingress-controller/concepts/apisix_plugin_config/?$" 
"$1/docs/ingress-controller/reference/apisix-ingress-controller/api-reference/"
+RedirectMatch 301 "^(/zh)?/docs/ingress-controller/development/?$" 
"$1/docs/ingress-controller/developer-guide/"
+RedirectMatch 301 
"^(/zh)?/docs/ingress-controller/practices/proxy-the-httpbin-service(?:-with-ingress)?/?$"
 "$1/docs/ingress-controller/getting-started/configure-routes/"
+RedirectMatch 301 "^(/zh)?/docs/apisix/architecture-design/upstream/?$" 
"$1/docs/apisix/terminology/upstream/"
+RedirectMatch 301 "^(/zh)?/docs/apisix/architecture-design/router/?$" 
"$1/docs/apisix/terminology/route/"
+RedirectMatch 301 "^(/zh)?/docs/apisix/architecture-design/plugin/?$" 
"$1/docs/apisix/terminology/plugin-config/"
+Redirect 301 "/zh/docs/apisix/plugin-develop。/" 
"/zh/docs/apisix/plugin-develop/"
+Redirect 301 "/zh/docs/apisix/plugins/plugin-develop.md/" 
"/zh/docs/apisix/plugin-develop/"
+Redirect 301 "/zh/docs/en/latest/deployment-modes.md/" 
"/zh/docs/apisix/deployment-modes/"
+RedirectMatch 301 "^/zh/docs/apisix/(?:plugins/)?batch-processor\.md/?$" 
"/zh/docs/apisix/batch-processor/"
+
+# These paths are malformed or empty directories with no equivalent page.
+# Return a truthful 404 instead of Apache's directory-level 403.
+RedirectMatch 404 "^(/zh)?/docs/apisix/2\.4/architecture-design/?$"
+RedirectMatch 404 "^/blog/2022/07/3/?$"
+
+# Invalid cross-language blog URLs found in the GSC 404/Soft 404 exports.
+# Each target is the only published page with that source slug.
+RedirectMatch 301 "^/blog/2021/08/09/apache-apisix-in-china-mobile-cloud/?$" 
"/zh/blog/2021/08/09/apache-apisix-in-china-mobile-cloud/"
+RedirectMatch 301 
"^/blog/2022/07/13/monitor-api-gateway-apisix-with-prometheus/?$" 
"/zh/blog/2022/07/13/monitor-api-gateway-apisix-with-prometheus/"
+RedirectMatch 301 
"^/blog/2022/07/22/exploration-of-apisix-in-api-and-microservices/?$" 
"/zh/blog/2022/07/22/exploration-of-apisix-in-api-and-microservices/"
+RedirectMatch 301 
"^/blog/2022/08/12/apache-apisix-runtime-dynamic-debugging/?$" 
"/zh/blog/2022/08/19/apache-apisix-runtime-dynamic-debugging/"
+RedirectMatch 301 
"^/blog/2022/08/17/apache-apisix-runtime-dynamic-debugging/?$" 
"/zh/blog/2022/08/19/apache-apisix-runtime-dynamic-debugging/"
+RedirectMatch 301 
"^/blog/2022/08/19/apache-apisix-runtime-dynamic-debugging/?$" 
"/zh/blog/2022/08/19/apache-apisix-runtime-dynamic-debugging/"
+RedirectMatch 301 
"^/blog/2022/09/15/apache-apisix-integrat-with-elasticsearch-for-logger/?$" 
"/zh/blog/2022/09/15/apache-apisix-integrat-with-elasticsearch-for-logger/"
+RedirectMatch 301 "^/blog/2022/09/28/apache-apisix-3\.0\.0-beta-release/?$" 
"/zh/blog/2022/09/28/apache-apisix-3.0.0-beta-release/"
+RedirectMatch 301 "^/blog/2022/09/30/huanbei-in-apache-apisix/?$" 
"/zh/blog/2022/09/30/huanbei-in-apache-apisix/"
+RedirectMatch 301 "^/blog/2022/11/05/tencent-blueking-with-apisix/?$" 
"/zh/blog/2022/11/05/tencent-blueking-with-apisix/"
+RedirectMatch 301 
"^/blog/2022/11/10/what-is-service-in-microservice-discovery/?$" 
"/zh/blog/2022/11/10/what-is-service-in-microservice-discovery/"
+RedirectMatch 301 "^/blog/2022/11/13/vivo-with-apache-apisix/?$" 
"/zh/blog/2022/11/13/vivo-with-apache-apisix/"
+RedirectMatch 301 "^/blog/2022/11/23/why-is-not-reload-hot-loaded-in-nginx/?$" 
"/zh/blog/2022/11/23/why-is-not-reload-hot-loaded-in-nginx/"
+RedirectMatch 301 "^/blog/2022/11/25/how-apisix-support-1000-pods/?$" 
"/zh/blog/2022/11/25/how-apisix-support-1000-pods/"
+RedirectMatch 301 "^/blog/2022/11/28/a-poor-man‘s-api/?$" 
"/zh/blog/2022/11/28/a-poor-man‘s-api/"
+RedirectMatch 301 "^/blog/2022/12/07/junrunrenli-with-apisix/?$" 
"/zh/blog/2022/12/07/junrunrenli-with-apisix/"
+RedirectMatch 301 "^/blog/2022/12/08/apisix-support-tongsuo/?$" 
"/zh/blog/2022/12/08/apisix-support-tongsuo/"
+RedirectMatch 301 "^/blog/2022/12/13/seewo-with-apache-apisix/?$" 
"/zh/blog/2022/12/13/seewo-with-apache-apisix/"
+RedirectMatch 301 "^/blog/2022/12/15/how-support-ingress-custom-plugins/?$" 
"/zh/blog/2022/12/15/how-support-ingress-custom-plugins/"
+RedirectMatch 301 "^/blog/2022/12/16/what-is-graphql/?$" 
"/zh/blog/2022/12/16/what-is-graphql/"
+RedirectMatch 301 "^/blog/2022/12/19/apisix-ingress-better-than-traefik/?$" 
"/zh/blog/2022/12/19/apisix-ingress-better-than-traefik/"
+RedirectMatch 301 "^/blog/2022/12/19/auth-apisix-gateway/?$" 
"/zh/blog/2022/12/19/auth-apisix-gateway/"
+RedirectMatch 301 "^/blog/2022/12/27/apisix-ingress-with-Flagger/?$" 
"/zh/blog/2022/12/27/apisix-ingress-with-Flagger/"
+RedirectMatch 301 "^/blog/2022/12/27/apisix-ingress-with-gatewayapi/?$" 
"/zh/blog/2022/12/27/apisix-ingress-with-gatewayapi/"
+RedirectMatch 301 "^/blog/2023/01/02/2022-summary/?$" 
"/zh/blog/2023/01/02/2022-summary/"
+RedirectMatch 301 "^/blog/2023/01/11/apisix-amesh-introduction/?$" 
"/zh/blog/2023/01/11/apisix-amesh-introduction/"
+RedirectMatch 301 "^/blog/2023/01/11/apisix-ingress-vs-ingress-nginx/?$" 
"/zh/blog/2023/01/11/apisix-ingress-vs-ingress-nginx/"
+RedirectMatch 301 "^/blog/2023/01/12/amesh-config-plugin/?$" 
"/zh/blog/2023/01/12/amesh-config-plugin/"
+RedirectMatch 301 "^/blog/2023/01/12/serverless-auth-type/?$" 
"/zh/blog/2023/01/12/serverless-auth-type/"
+RedirectMatch 301 "^/blog/2023/01/15/mafengwo-with-apisix/?$" 
"/zh/blog/2023/01/15/mafengwo-with-apisix/"
+RedirectMatch 301 "^/blog/2023/01/18/what-is-service-mesh/?$" 
"/zh/blog/2023/01/18/what-is-service-mesh/"
+RedirectMatch 301 "^/blog/2023/01/30/something-about-api-gateway-policy/?$" 
"/zh/blog/2023/01/30/something-about-api-gateway-policy/"
+RedirectMatch 301 "^/blog/2023/02/07/apisix-ingress-with-cert-mamager/?$" 
"/zh/blog/2023/02/07/apisix-ingress-with-cert-mamager/"
+RedirectMatch 301 "^/blog/2023/02/08/what-is-restful-api/?$" 
"/zh/blog/2023/02/08/what-is-restful-api/"
+RedirectMatch 301 "^/blog/2023/02/16/weekly-report-0216/?$" 
"/zh/blog/2023/02/16/weekly-report-0216/"
+RedirectMatch 301 
"^/blog/2023/02/21/how-to-scale-application-elastically-in-kubernetes/?$" 
"/zh/blog/2023/02/21/how-to-scale-application-elastically-in-kubernetes/"
+RedirectMatch 301 
"^/blog/2023/02/23/how-to-prevent-sensitive-data-from-leaking/?$" 
"/zh/blog/2023/02/23/how-to-prevent-sensitive-data-from-leaking/"
+RedirectMatch 301 
"^/blog/2023/02/28/transforming-logs-for-ingestion-into-your-observability-stack/?$"
 
"/zh/blog/2023/02/28/transforming-logs-for-ingestion-into-your-observability-stack/"
+RedirectMatch 301 "^/blog/2023/03/03/api-gateway-vs-load-balancer/?$" 
"/zh/blog/2023/03/03/api-gateway-vs-load-balancer/"
+RedirectMatch 301 
"^/blog/2023/03/06/the-mystery-of-prometheus-plugins-and-long-tail-requests/?$" 
"/zh/blog/2023/03/06/the-mystery-of-prometheus-plugins-and-long-tail-requests/"
+RedirectMatch 301 
"^/blog/2023/03/22/what-is-luajit-and-why-does-apisix-choose-luajit/?$" 
"/zh/blog/2023/03/22/what-is-luajit-and-why-does-apisix-choose-luajit/"
+RedirectMatch 301 
"^/blog/2023/03/30/what-is-wasm-and-how-does-apache-apisix-support-it/?$" 
"/zh/blog/2023/03/30/what-is-wasm-and-how-does-apache-apisix-support-it/"
+RedirectMatch 301 "^/blog/2023/04/03/10-api-management-trends/?$" 
"/zh/blog/2023/04/03/10-api-management-trends/"
+RedirectMatch 301 "^/blog/2023/11/30/migu-video-utilizes-apisix/?$" 
"/zh/blog/2023/11/30/migu-video-utilizes-apisix/"
+RedirectMatch 301 "^/blog/2023/12/15/high-availability-of-apisix-and-api7/?$" 
"/zh/blog/2023/12/15/high-availability-of-apisix-and-api7/"
+RedirectMatch 301 "^/blog/2025/01/13/apisix-2024-ospp/?$" 
"/zh/blog/2025/01/13/apisix-2024-ospp/"
+RedirectMatch 301 
"^/blog/2025/01/15/apisix-practice-in-soyoung-data-security-gateway/?$" 
"/zh/blog/2025/01/15/apisix-practice-in-soyoung-data-security-gateway/"
+RedirectMatch 301 "^/blog/2025/03/31/march-monthly-report/?$" 
"/zh/blog/2025/03/31/march-monthly-report/"
+RedirectMatch 301 
"^/en/blog/2022/11/23/why-is-not-reload-hot-loaded-in-nginx/?$" 
"/zh/blog/2022/11/23/why-is-not-reload-hot-loaded-in-nginx/"
+RedirectMatch 301 "^/zh/blog/2021/01/21/run-ingress-apisix-on-amazon-eks/?$" 
"/blog/2021/01/21/run-ingress-apisix-on-amazon-eks/"
+RedirectMatch 301 "^/zh/blog/2022/04/17/api-observability/?$" 
"/blog/2022/04/17/api-observability/"
+RedirectMatch 301 "^/zh/blog/2022/09/08/api-monetization-using-stack/?$" 
"/blog/2022/09/08/api-monetization-using-stack/"
+RedirectMatch 301 "^/zh/blog/2022/09/23/build-event-driven-api/?$" 
"/blog/2022/09/23/build-event-driven-api/"
+RedirectMatch 301 "^/zh/blog/2022/10/27/ten-use-cases-api-gateway/?$" 
"/blog/2022/10/27/ten-use-cases-api-gateway/"
+RedirectMatch 301 "^/zh/blog/2022/11/02/apache-apisix-v3-preview/?$" 
"/blog/2022/11/02/apache-apisix-v3-preview/"
+RedirectMatch 301 
"^/zh/blog/2022/11/07/webhook-api-gateway-event-driven-apis/?$" 
"/blog/2022/11/07/webhook-api-gateway-event-driven-apis/"
+RedirectMatch 301 
"^/zh/blog/2022/12/06/choose-the-right-api-style-technology/?$" 
"/blog/2022/12/06/choose-the-right-api-style-technology/"
+RedirectMatch 301 
"^/zh/blog/2023/01/02/accessing_apisix-dashboard_from_everywhere_with_keycloak_authentication/?$"
 
"/blog/2023/01/02/accessing_apisix-dashboard_from_everywhere_with_keycloak_authentication/"
+RedirectMatch 301 "^/zh/blog/2023/04/14/weekly-report-0414/?$" 
"/blog/2023/04/14/weekly-report-0414/"
+RedirectMatch 301 "^/zh/blog/2023/06/02/lenovo-uses-apisix/?$" 
"/blog/2023/06/02/lenovo-uses-apisix/"
+RedirectMatch 301 "^/zh/blog/2023/06/12/how-is-apisix-fast/?$" 
"/blog/2023/06/12/how-is-apisix-fast/"
+RedirectMatch 301 "^/zh/blog/2023/07/09/apisix-integrates-with-vault/?$" 
"/blog/2023/07/09/apisix-integrates-with-vault/"
+RedirectMatch 301 "^/zh/blog/2023/07/20/data-mask-plugin/?$" 
"/blog/2023/07/20/data-mask-plugin/"
+RedirectMatch 301 "^/zh/blog/2023/07/27/apisix-without-etcd/?$" 
"/blog/2023/07/27/apisix-without-etcd/"
+RedirectMatch 301 "^/zh/blog/2023/11/09/api-versioning/?$" 
"/blog/2023/11/09/api-versioning/"
+RedirectMatch 301 "^/zh/blog/2023/12/14/migu-video-adopts-apisix/?$" 
"/blog/2023/12/14/migu-video-adopts-apisix/"
+RedirectMatch 301 "^/zh/blog/2024/02/27/secure-api-practices-apisix-2/?$" 
"/blog/2024/02/27/secure-api-practices-apisix-2/"
+RedirectMatch 301 "^/zh/blog/2024/07/11/watermarking-infrastructure/?$" 
"/blog/2024/07/11/watermarking-infrastructure/"
+RedirectMatch 301 "^/zh/blog/2024/10/22/apisix-integrates-with-open-appsec/?$" 
"/blog/2024/10/22/apisix-integrates-with-open-appsec/"
+RedirectMatch 301 
"^/zh/blog/2025/02/06/analyzing-api-gateway-adoption-rates/?$" 
"/blog/2025/02/06/analyzing-api-gateway-adoption-rates/"
+RedirectMatch 301 
"^/zh/blog/2025/02/17/cloud-vs-open-source-vs-commercial-api-gateways/?$" 
"/blog/2025/02/17/cloud-vs-open-source-vs-commercial-api-gateways/"
+RedirectMatch 301 
"^/zh/blog/2025/06/18/ai-gateway-future-trend-of-ai-infrastructure/?$" 
"/blog/2025/06/18/ai-gateway-future-trend-of-ai-infrastructure/"
+RedirectMatch 301 
"^/zh/blog/2025/07/29/announcing-integration-of-apisix-and-ai-ml-api/?$" 
"/blog/2025/07/29/announcing-integration-of-apisix-and-ai-ml-api/"
+RedirectMatch 301 
"^/zh/blog/blog/2021/06/03/firsthand-experience-with-apache-apisix-from-ospp-2020-students/?$"
 
"/blog/blog/2021/06/03/firsthand-experience-with-apache-apisix-from-ospp-2020-students/"
 RedirectMatch 301 "^/docs/apisix/getting-started/$" 
"/docs/apisix/getting-started/README/"
 RedirectMatch 301 "^/zh/docs/apisix/getting-started/$" 
"/zh/docs/apisix/getting-started/README/"
 RedirectMatch 301 "^/docs/apisix/3\.2/plugins/(.*)$" 
"https://apache-apisix.netlify.app/docs/apisix/3.2/plugins/$1";
@@ -156,7 +270,7 @@ RedirectMatch 301 "^(/zh)?/docs/dashboard(/.*)?$" 
"$1/docs/apisix/dashboard/"
 # to the real entry pages instead
 RedirectMatch 301 "^(/zh)?/docs/apisix/$" 
"$1/docs/apisix/getting-started/README/"
 RedirectMatch 301 "^(/zh)?/docs/apisix/plugins/$" "$1/plugins/"
-RedirectMatch 301 "^(/zh)?/docs/apisix/3\.\d+/getting-started/$" 
"$1/docs/apisix/getting-started/README/"
+RedirectMatch 301 "^(/zh)?/docs/apisix/(3\.\d+)/getting-started/$" 
"$1/docs/apisix/$2/getting-started/README/"
 RedirectMatch 301 "^(/zh)?/docs/ingress-controller/$" 
"$1/docs/ingress-controller/overview/"
 RedirectMatch 301 
"^(/zh)?/docs/ingress-controller/(?:next/)?getting-started/$" 
"$1/docs/ingress-controller/getting-started/get-apisix-ingress-controller/"
 
diff --git a/blog/en/blog/2023/03/10/release-apache-apisix-3.2.0.md 
b/blog/en/blog/2023/03/10/release-apache-apisix-3.2.0.md
index 8d558e0bd02..6115e945a20 100644
--- a/blog/en/blog/2023/03/10/release-apache-apisix-3.2.0.md
+++ b/blog/en/blog/2023/03/10/release-apache-apisix-3.2.0.md
@@ -1,5 +1,6 @@
 ---
 title: "Release Apache APISIX 3.2.0"
+translationKey: release-apache-apisix-3.2.0
 authors:
   - name: "Zexuan Luo"
     title: "Author"
diff --git a/blog/zh/blog/2023/03/09/release-apache-apisix-3.2.0.md 
b/blog/zh/blog/2023/03/09/release-apache-apisix-3.2.0.md
index af1cd9d903b..309a269589e 100644
--- a/blog/zh/blog/2023/03/09/release-apache-apisix-3.2.0.md
+++ b/blog/zh/blog/2023/03/09/release-apache-apisix-3.2.0.md
@@ -1,5 +1,6 @@
 ---
 title: "Apache APISIX 3.2.0 正式发布"
+translationKey: release-apache-apisix-3.2.0
 authors:
   - name: "罗泽轩"
     title: "Author"
diff --git a/doc/src/theme/LayoutHead/index.tsx 
b/doc/src/theme/LayoutHead/index.tsx
index e2e5300a38d..96ca38a1ba7 100644
--- a/doc/src/theme/LayoutHead/index.tsx
+++ b/doc/src/theme/LayoutHead/index.tsx
@@ -5,6 +5,7 @@ import Head from '@docusaurus/Head';
 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 // @ts-ignore: swizzle-wrapper alias has no published types
 import OriginalLayoutHead from '@theme-original/LayoutHead';
+import { useActivePlugin } from '@theme/hooks/useDocs';
 import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
 import { useLocation } from '@docusaurus/router';
 
@@ -16,13 +17,18 @@ import { useLocation } from '@docusaurus/router';
  *   /docs/apisix/next/...                    -> next
  * Keeps the same version-segment pattern as scripts/update-sitemap-loc.js.
  */
-const versionedDocPath = 
/^((?:\/zh)?\/docs\/[\w-]+\/)(?:(?:[\w-]+-)?\d+\.\d+(?:\.\d+)?|next)(\/.+)$/;
+const versionedDocPath = 
/^((?:\/zh)?\/docs\/[\w-]+\/)(?:(?:[\w-]+-)?\d+\.\d+(?:\.\d+)?|next)(\/.*)?$/;
+
+const normalizePath = (value: string) => value.replace(/\/$/, '');
 
 /**
- * Versioned doc pages (/docs/<project>/<version>/) self-canonicalize by
- * default, so Google indexes them as independent pages competing with the
- * version-less "latest" URLs. This wrapper re-points their canonical to the
- * latest URL. Rendering order keeps the precedence right (react-helmet:
+ * Docusaurus gives versioned doc pages a version-less canonical by default,
+ * even when that route does not exist in the latest release. This wrapper
+ * marks historical/next pages noindex,follow and points their canonical to
+ * the latest URL only when that release contains the same document. Otherwise
+ * it explicitly self-canonicalizes the historical/next page. Rendering order
+ * keeps the precedence right
+ * (react-helmet:
  * last <Head> wins):
  *   1. default self-canonical (original LayoutHead)
  *   2. this wrapper's latest-URL canonical (versioned pages only)
@@ -31,16 +37,31 @@ const versionedDocPath = 
/^((?:\/zh)?\/docs\/[\w-]+\/)(?:(?:[\w-]+-)?\d+\.\d+(?:
 const LayoutHead: FC<{ [key: string]: unknown }> = (props) => {
   const { siteConfig: { url: siteUrl } } = useDocusaurusContext();
   const { pathname } = useLocation();
+  const activePlugin = useActivePlugin()?.pluginData;
   const match = pathname.match(versionedDocPath);
-  const latestUrl = match ? `${siteUrl}${match[1].replace(/\/$/, 
'')}${match[2]}` : null;
+  const activeDoc = match ? activePlugin?.versions
+    .flatMap(({ docs }) => docs)
+    .find(({ path }) => normalizePath(path) === normalizePath(pathname)) : 
undefined;
+  // Docusaurus returns versions newest-first. `current` is the unreleased
+  // `next` tree, so the first non-current entry is the latest release that
+  // actually owns the version-less Astro routes.
+  const latestRelease = activePlugin?.versions.find(({ name }) => name !== 
'current');
+  const latestDoc = latestRelease?.docs.find(({ id }) => id === activeDoc?.id);
+  const latestPath = activePlugin && latestRelease && latestDoc
+    && latestDoc.path.startsWith(latestRelease.path)
+    ? `${activePlugin.path}${latestDoc.path.slice(latestRelease.path.length)}`
+    : null;
+  const latestUrl = latestPath ? `${siteUrl}${latestPath.replace(/\/?$/, 
'/')}` : null;
+  const canonicalUrl = match ? latestUrl ?? `${siteUrl}${pathname}` : null;
 
   return (
     <>
       <OriginalLayoutHead {...props} />
-      {latestUrl && (
+      {match && (
         <Head>
-          <meta property="og:url" content={latestUrl} />
-          <link rel="canonical" href={latestUrl} />
+          <meta name="robots" content="noindex,follow" />
+          <meta property="og:url" content={canonicalUrl} />
+          <link rel="canonical" href={canonicalUrl} />
         </Head>
       )}
     </>
diff --git a/next/package-lock.json b/next/package-lock.json
index ec027ed01f8..c31fece5798 100644
--- a/next/package-lock.json
+++ b/next/package-lock.json
@@ -7,6 +7,7 @@
       "name": "apisix-website-astro",
       "dependencies": {
         "astro": "^6.0.5",
+        "entities": "^6.0.1",
         "remark-directive": "^4.0.0",
         "unist-util-visit": "^5.1.0"
       },
diff --git a/next/package.json b/next/package.json
index d0d33494adb..1e2152ea307 100644
--- a/next/package.json
+++ b/next/package.json
@@ -1 +1 @@
-{"name":"apisix-website-astro","type":"module","private":true,"dependencies":{"astro":"^6.0.5","remark-directive":"^4.0.0","unist-util-visit":"^5.1.0"},"devDependencies":{"@playwright/test":"1.61.1","sax":"^1.6.0"},"scripts":{"sync":"node
 scripts/sync-content.mjs && node scripts/lint-content.mjs","build":"astro 
build && node scripts/generate-sitemaps.mjs","dev":"astro dev","preview":"astro 
preview","test:e2e":"playwright test","test:redirects":"node 
scripts/check-ingress-redirects.mjs"," [...]
+{"name":"apisix-website-astro","type":"module","private":true,"dependencies":{"astro":"^6.0.5","entities":"^6.0.1","remark-directive":"^4.0.0","unist-util-visit":"^5.1.0"},"devDependencies":{"@playwright/test":"1.61.1","sax":"^1.6.0"},"scripts":{"sync":"node
 scripts/sync-content.mjs && node scripts/lint-content.mjs","build":"astro 
build && node scripts/generate-sitemaps.mjs","dev":"astro dev","preview":"astro 
preview","test:e2e":"playwright test","test:redirects":"node scripts/check-ingr 
[...]
diff --git a/next/scripts/assert-local-canonical-targets.mjs 
b/next/scripts/assert-local-canonical-targets.mjs
new file mode 100644
index 00000000000..8eb179811b3
--- /dev/null
+++ b/next/scripts/assert-local-canonical-targets.mjs
@@ -0,0 +1,77 @@
+/* eslint-disable no-console */
+import fs from 'node:fs';
+import path from 'node:path';
+import { decodeHTMLAttribute } from 'entities';
+
+const SITE_ORIGIN = 'https://apisix.apache.org';
+const canonicalTagPattern = /<link\b[^>]*\brel=["']canonical["'][^>]*>/gi;
+const hrefPattern = /\bhref=["']([^"']+)["']/i;
+
+function collectHtmlFiles(directory) {
+  return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => 
{
+    const entryPath = path.join(directory, entry.name);
+    if (entry.isDirectory()) return collectHtmlFiles(entryPath);
+    return entry.isFile() && entry.name.endsWith('.html') ? [entryPath] : [];
+  });
+}
+
+function targetCandidates(distDirectory, pathname) {
+  const relativePath = decodeURIComponent(pathname).replace(/^\/+/, '');
+  const directTarget = path.resolve(distDirectory, relativePath);
+  if (!directTarget.startsWith(`${path.resolve(distDirectory)}${path.sep}`)
+    && directTarget !== path.resolve(distDirectory)) {
+    return [];
+  }
+
+  if (pathname.endsWith('/')) return [path.join(directTarget, 'index.html')];
+  return [directTarget, path.join(directTarget, 'index.html')];
+}
+
+function isFile(filePath) {
+  try {
+    return fs.statSync(filePath).isFile();
+  } catch {
+    return false;
+  }
+}
+
+const distArgumentIndex = process.argv.indexOf('--dist');
+const distDirectory = path.resolve(
+  distArgumentIndex >= 0 ? process.argv[distArgumentIndex + 1] : 'dist',
+);
+
+if (!fs.existsSync(distDirectory)) {
+  throw new Error(`Build directory does not exist: ${distDirectory}`);
+}
+
+const failures = [];
+let checkedCanonicals = 0;
+const htmlFiles = collectHtmlFiles(distDirectory);
+
+htmlFiles.forEach((htmlFile) => {
+  const html = fs.readFileSync(htmlFile, 'utf8');
+  const canonicalTags = html.match(canonicalTagPattern) ?? [];
+  canonicalTags.forEach((tag) => {
+    const href = tag.match(hrefPattern)?.[1];
+    if (!href) return;
+
+    const canonical = new URL(decodeHTMLAttribute(href), SITE_ORIGIN);
+    if (canonical.origin !== SITE_ORIGIN) return;
+
+    checkedCanonicals += 1;
+    const candidates = targetCandidates(distDirectory, canonical.pathname);
+    if (!candidates.some(isFile)) {
+      failures.push(`${path.relative(distDirectory, htmlFile)} -> 
${canonical.href}`);
+    }
+  });
+});
+
+if (failures.length > 0) {
+  console.error('APISIX-local canonicals with missing build targets:');
+  failures.forEach((failure) => console.error(`- ${failure}`));
+  process.exit(1);
+}
+
+console.log(
+  `Validated ${checkedCanonicals} APISIX-local canonicals across 
${htmlFiles.length} HTML files.`,
+);
diff --git a/next/scripts/assert-local-canonical-targets.test.mjs 
b/next/scripts/assert-local-canonical-targets.test.mjs
new file mode 100644
index 00000000000..2fd622356f8
--- /dev/null
+++ b/next/scripts/assert-local-canonical-targets.test.mjs
@@ -0,0 +1,41 @@
+/* eslint-disable no-console */
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
+const validator = path.join(root, 
'scripts/assert-local-canonical-targets.mjs');
+const dist = fs.mkdtempSync(path.join(os.tmpdir(), 
'apisix-canonical-targets-'));
+const site = 'https://apisix.apache.org';
+
+function writePage(relativePath, canonical) {
+  const directory = path.join(dist, relativePath);
+  fs.mkdirSync(directory, { recursive: true });
+  fs.writeFileSync(
+    path.join(directory, 'index.html'),
+    `<!doctype html><head><link rel="canonical" href="${canonical}"></head>`,
+  );
+}
+
+try {
+  writePage('blog/q&a', `${site}/blog/q&#38;a/`);
+  writePage('blog/named&a', `${site}/blog/named&amp;a/`);
+  writePage('blog/sol/target', `${site}/blog/sol&sol;target/`);
+  writePage('blog/mixed&AmP;case', `${site}/blog/mixed&AmP;case/`);
+  writePage('blog/a&amp=b', `${site}/blog/a&amp=b/`);
+  writePage('external-entity-origin', 'https&colon;&sol;&sol;example.com/');
+
+  let result = spawnSync(process.execPath, [validator, '--dist', dist], { 
encoding: 'utf8' });
+  assert.equal(result.status, 0, result.stderr);
+
+  writePage('missing-target-source', `${site}/missing-target/`);
+  result = spawnSync(process.execPath, [validator, '--dist', dist], { 
encoding: 'utf8' });
+  assert.equal(result.status, 1);
+  assert.match(result.stderr, /missing-target-source\/index\.html -> 
.*\/missing-target\//);
+  console.log('Local canonical target validation passed.');
+} finally {
+  fs.rmSync(dist, { recursive: true, force: true });
+}
diff --git a/next/scripts/generate-sitemaps.mjs 
b/next/scripts/generate-sitemaps.mjs
index 54988d0a5b3..e2f46d5150e 100644
--- a/next/scripts/generate-sitemaps.mjs
+++ b/next/scripts/generate-sitemaps.mjs
@@ -32,14 +32,44 @@ function pagesUnder(dir) {
       const p = path.join(d, e.name);
       if (e.isDirectory()) walk(p);
       else if (e.name === 'index.html') {
-        out.push(`/${path.relative(dist, 
path.dirname(p)).split(path.sep).join('/')}/`.replace('/./', '/'));
+        const url = `/${path.relative(dist, 
path.dirname(p)).split(path.sep).join('/')}/`.replace('/./', '/');
+        out.push({ url: url === '//' ? '/' : url, file: p });
       }
     });
   }(dir));
-  return out.map((u) => (u === '//' ? '/' : u)).sort();
+  return out.sort((a, b) => a.url.localeCompare(b.url));
 }
 
-const all = pagesUnder(dist).filter((u) => !excludePatterns.some((pattern) => 
pattern.test(u)));
+function attr(tag, name) {
+  const match = tag.match(new RegExp(`\\s${name}\\s*=\\s*(["'])(.*?)\\1`, 
'i'));
+  return match?.[2];
+}
+
+function isSelfCanonicalPage(page) {
+  const html = fs.readFileSync(page.file, 'utf8');
+  const robots = [...html.matchAll(/<meta\b[^>]*>/gi)]
+    .filter(([tag]) => attr(tag, 'name')?.toLowerCase() === 'robots')
+    .flatMap(([tag]) => (attr(tag, 'content') ?? 
'').toLowerCase().split(/[\s,]+/));
+  if (robots.includes('noindex')) return false;
+
+  const canonical = [...html.matchAll(/<link\b[^>]*>/gi)]
+    .find(([tag]) => (attr(tag, 'rel') ?? 
'').toLowerCase().split(/\s+/).includes('canonical'));
+  const href = canonical ? attr(canonical[0], 'href') : undefined;
+  if (!href) return false;
+  try {
+    const resolved = new URL(href, SITE);
+    resolved.hash = '';
+    resolved.search = '';
+    return resolved.href === new URL(page.url, SITE).href;
+  } catch {
+    return false;
+  }
+}
+
+const all = pagesUnder(dist)
+  .filter((page) => !excludePatterns.some((pattern) => pattern.test(page.url)))
+  .filter(isSelfCanonicalPage)
+  .map((page) => page.url);
 const zh = all.filter((u) => u === '/zh/' || u.startsWith('/zh/'));
 const en = all.filter((u) => !zh.includes(u));
 
diff --git a/next/scripts/generate-sitemaps.test.mjs 
b/next/scripts/generate-sitemaps.test.mjs
index 8cf8937d9c5..0235bdfdd7f 100644
--- a/next/scripts/generate-sitemaps.test.mjs
+++ b/next/scripts/generate-sitemaps.test.mjs
@@ -11,11 +11,17 @@ import sax from 'sax';
 const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
 const generator = path.join(root, 'scripts/generate-sitemaps.mjs');
 const dist = fs.mkdtempSync(path.join(os.tmpdir(), 'apisix-sitemap-'));
+const SITE = 'https://apisix.apache.org';
 
-function writePage(url) {
+function writePage(url, { canonical, robots } = {}) {
   const dir = path.join(dist, url);
   fs.mkdirSync(dir, { recursive: true });
-  fs.writeFileSync(path.join(dir, 'index.html'), '<!doctype html>');
+  const pagePath = url ? `/${url}/` : '/';
+  const canonicalTag = canonical === false
+    ? ''
+    : `<link data-test="canonical" href="${canonical ?? `${SITE}${pagePath}`}" 
rel="canonical">`;
+  const robotsTag = robots ? `<meta content="${robots}" name="robots">` : '';
+  fs.writeFileSync(path.join(dir, 'index.html'), `<!doctype 
html><head>${canonicalTag}${robotsTag}</head>`);
 }
 
 const pages = [
@@ -35,8 +41,11 @@ const pages = [
   'blog/2026/07/28/apisix-unity-group-q&a',
   'blog/page/2',
   'blog/archive',
+  'docs/apisix/3.17',
   'docs/apisix/3.17/plugins/cors',
+  'docs/docker/apisix-3.17.0',
   'docs/docker/apisix-3.17.0/build',
+  'docs/apisix/next',
   'docs/apisix/next/plugins/cors',
   'docs/apisix/tags/plugins',
   'docs/apisix/upgrade-guide-from-2.15.x-to-3.0.0',
@@ -56,6 +65,10 @@ const pages = [
 
 try {
   pages.forEach(writePage);
+  writePage('external-canonical', { canonical: 'https://docs.api7.ai/hub/cors' 
});
+  writePage('zh/untranslated-doc', { canonical: 
`${SITE}/docs/untranslated-doc/` });
+  writePage('noindex-page', { robots: 'noindex,follow' });
+  writePage('missing-canonical', { canonical: false });
   const result = spawnSync(process.execPath, [generator, '--dist', dist], { 
encoding: 'utf8' });
   assert.equal(result.status, 0, result.stderr);
 
@@ -77,8 +90,11 @@ try {
     'zh/articles/page/2',
     'zh/events/archive',
     'zh/blog/page/2',
+    'docs/apisix/3.17',
     'docs/apisix/3.17/plugins/cors',
+    'docs/docker/apisix-3.17.0',
     'docs/docker/apisix-3.17.0/build',
+    'docs/apisix/next',
     'docs/apisix/next/plugins/cors',
     'docs/apisix/tags/plugins',
     'zh/docs/ingress-controller/2.1.0/overview',
@@ -86,6 +102,10 @@ try {
     'zh/docs/apisix/tags/plugins',
     '/search/',
     '/zh/search/',
+    'external-canonical',
+    'zh/untranslated-doc',
+    'noindex-page',
+    'missing-canonical',
   ];
 
   assert.match(en, /learning-center\/mcp-protocol-ai-gateway/);
diff --git a/next/src/components/Header.astro b/next/src/components/Header.astro
index 225f574ba0c..2c001811136 100644
--- a/next/src/components/Header.astro
+++ b/next/src/components/Header.astro
@@ -1,13 +1,13 @@
 ---
 import { NAV, LOGO, localePrefix, type Locale } from '../lib/site';
 
-interface Props { locale: Locale; path: string; search?: boolean }
-const { locale, path, search = false } = Astro.props;
+interface Props { locale: Locale; path: string; search?: boolean; switchPath?: 
string }
+const { locale, path, search = false, switchPath } = Astro.props;
 const prefix = localePrefix(locale);
 const label = (item: { label: string; labelZh?: string }) =>
   locale === 'zh' && item.labelZh ? item.labelZh : item.label;
 const href = (h: string) => (h.startsWith('http') ? h : `${prefix}${h}`);
-const switchUrl = locale === 'zh' ? path : `/zh${path}`;
+const switchUrl = switchPath ?? (locale === 'zh' ? path : `/zh${path}`);
 ---
 <div class="announcement">
   🤔 Introducing APISIX AI Gateway<span class="ann-detail"> – Built for LLMs 
and AI workloads</span>. <a href={`${prefix}/ai-gateway/`}>Learn More</a>
diff --git a/next/src/layouts/Article.astro b/next/src/layouts/Article.astro
index 314233bf508..ef6c72bcdb0 100644
--- a/next/src/layouts/Article.astro
+++ b/next/src/layouts/Article.astro
@@ -22,8 +22,16 @@ interface Props {
   /** chronological neighbours in the same section (newest-first arrays) */
   newer?: Post;
   older?: Post;
+  /** Reciprocal language routes, only when a real translated article exists. 
*/
+  alternatePaths?: { en: string; zh: string };
+  /** Used by locale fallback pages whose indexable source is another URL. */
+  canonicalOverride?: string;
+  languageSwitchPath?: string;
 }
-const { post, locale, path, tagBase, backUrl, schemaType = 'BlogPosting', 
jsonLdExtra = [], related = [], newer, older } = Astro.props;
+const {
+  post, locale, path, tagBase, backUrl, schemaType = 'BlogPosting', 
jsonLdExtra = [],
+  related = [], newer, older, alternatePaths, canonicalOverride, 
languageSwitchPath,
+} = Astro.props;
 const { Content } = post.mod;
 const minutes = readingMinutes(post.mod);
 const headings = post.mod.getHeadings().filter((h) => h.depth === 2 || h.depth 
=== 3);
@@ -42,7 +50,19 @@ const jsonLd = [{
   mainEntityOfPage: `${SITE}${locale === 'zh' ? '/zh' : ''}${path}`,
 }, ...jsonLdExtra];
 ---
-<Base title={post.title} description={post.description} locale={locale} 
path={path} image={post.image} jsonLd={jsonLd} ogType="article">
+<Base
+  title={post.title}
+  description={post.description}
+  locale={locale}
+  path={path}
+  image={post.image}
+  jsonLd={jsonLd}
+  ogType="article"
+  hasAlternate={Boolean(alternatePaths)}
+  alternatePaths={alternatePaths}
+  canonicalOverride={canonicalOverride}
+  languageSwitchPath={languageSwitchPath}
+>
   <div class={`container article-wrap${showToc || (tagBase && 
post.tags.length) ? ' with-rails' : ''}`}>
     <aside class="article-side">
       {tagBase && post.tags.length > 0 && (
diff --git a/next/src/layouts/Base.astro b/next/src/layouts/Base.astro
index 76494cd7675..2ee05dfc82b 100644
--- a/next/src/layouts/Base.astro
+++ b/next/src/layouts/Base.astro
@@ -22,6 +22,10 @@ interface Props {
   jsonLd?: object[];
   /** Set false for pages that exist in only one locale. */
   hasAlternate?: boolean;
+  /** Explicit locale paths when translated pages do not share one URL path. */
+  alternatePaths?: { en: string; zh: string };
+  /** Safe language-switch destination when no page-level translation exists. 
*/
+  languageSwitchPath?: string;
   /** Open Graph object type; article pages pass "article". */
   ogType?: 'website' | 'article';
   noindex?: boolean;
@@ -41,6 +45,8 @@ const {
   image,
   jsonLd = [],
   hasAlternate = true,
+  alternatePaths,
+  languageSwitchPath,
   noindex = false,
   canonicalOverride,
   titleSuffix = true,
@@ -48,9 +54,18 @@ const {
   search = false,
 } = Astro.props;
 
-const enUrl = `${SITE}${path}`;
-const zhUrl = `${SITE}/zh${path}`;
-const canonical = canonicalOverride ?? (locale === 'zh' ? zhUrl : enUrl);
+const currentEnUrl = `${SITE}${path}`;
+const currentZhUrl = `${SITE}/zh${path}`;
+const enUrl = alternatePaths ? `${SITE}${alternatePaths.en}` : currentEnUrl;
+const zhUrl = alternatePaths ? `${SITE}${alternatePaths.zh}` : currentZhUrl;
+const canonical = canonicalOverride ?? (locale === 'zh' ? currentZhUrl : 
currentEnUrl);
+// A cross-site canonical and APISIX-local hreflang targets send conflicting
+// ownership signals. Upstream plugin docs therefore keep their API7 canonical
+// without advertising APISIX copies as alternate canonical pages.
+const emitAlternates = hasAlternate && !canonicalOverride;
+const switchPath = alternatePaths
+  ? (locale === 'zh' ? alternatePaths.en : alternatePaths.zh)
+  : languageSwitchPath;
 // Same pattern as production: "<page title> | Apache APISIX".
 const fullTitle = titleSuffix && !title.includes(' | ') ? `${title} | 
${SITE_NAME}` : title;
 const desc = description?.trim() || DEFAULT_DESCRIPTION;
@@ -65,9 +80,9 @@ const allJsonLd = [...ORG_JSONLD, ...jsonLd];
   <meta name="description" content={desc} />
   {noindex ? <meta name="robots" content="noindex" /> : <meta name="robots" 
content="index,follow" />}
   <link rel="canonical" href={canonical} />
-  {hasAlternate && <link rel="alternate" hreflang="en" href={enUrl} />}
-  {hasAlternate && <link rel="alternate" hreflang="zh" href={zhUrl} />}
-  {hasAlternate && <link rel="alternate" hreflang="x-default" href={enUrl} />}
+  {emitAlternates && <link rel="alternate" hreflang="en" href={enUrl} />}
+  {emitAlternates && <link rel="alternate" hreflang="zh" href={zhUrl} />}
+  {emitAlternates && <link rel="alternate" hreflang="x-default" href={enUrl} 
/>}
   <link rel="icon" href={FAVICON} />
   <link rel="sitemap" type="application/xml" href={locale === 'zh' ? 
'/zh/sitemap.xml' : '/sitemap.xml'} />
   <meta property="og:type" content={ogType} />
@@ -76,7 +91,7 @@ const allJsonLd = [...ORG_JSONLD, ...jsonLd];
   {/* og:url stays on this page even when the canonical points off-site
       (upstream docs that canonicalise to docs.api7.ai) — matching production,
       so shares of a plugin doc still resolve to apisix.apache.org. */}
-  <meta property="og:url" content={locale === 'zh' ? zhUrl : enUrl} />
+  <meta property="og:url" content={locale === 'zh' ? currentZhUrl : 
currentEnUrl} />
   <meta property="og:site_name" content="Apache APISIX" />
   <meta property="og:image" content={socialImage} />
   <meta name="twitter:image" content={socialImage} />
@@ -91,7 +106,7 @@ const allJsonLd = [...ORG_JSONLD, ...jsonLd];
   )}
 </head>
 <body>
-  <Header locale={locale} path={path} search={search} />
+  <Header locale={locale} path={path} search={search} switchPath={switchPath} 
/>
   <main>
     <slot />
   </main>
diff --git a/next/src/layouts/DocPage.astro b/next/src/layouts/DocPage.astro
index b74f84a0bc4..18cc1f365f8 100644
--- a/next/src/layouts/DocPage.astro
+++ b/next/src/layouts/DocPage.astro
@@ -44,9 +44,12 @@ const jsonLd = [{
  */
 const ZH_HUB = 'https://docs.apiseven.com';
 const rawCanonical = entry.mod.frontmatter.canonical;
-const canonicalOverride = locale === 'zh' && 
rawCanonical?.startsWith('https://docs.api7.ai/')
-  ? rawCanonical.replace('https://docs.api7.ai', ZH_HUB)
+const canonicalOverride = locale === 'zh'
+  ? rawCanonical?.startsWith('https://docs.api7.ai/')
+    ? rawCanonical.replace('https://docs.api7.ai', ZH_HUB)
+    : entry.hasTranslation ? rawCanonical : `${SITE}${path}`
   : rawCanonical;
+const hasAlternate = entry.hasTranslation && !canonicalOverride;
 ---
 <Base
   title={entry.title}
@@ -55,6 +58,7 @@ const canonicalOverride = locale === 'zh' && 
rawCanonical?.startsWith('https://d
   path={path}
   jsonLd={jsonLd}
   canonicalOverride={canonicalOverride}
+  hasAlternate={hasAlternate}
   search
 >
   <div class="container docs-layout">
diff --git a/next/src/lib/blog-feed.ts b/next/src/lib/blog-feed.ts
new file mode 100644
index 00000000000..6d4c6b1734f
--- /dev/null
+++ b/next/src/lib/blog-feed.ts
@@ -0,0 +1,63 @@
+import { getBlogPosts } from './content';
+import { SITE, type Locale } from './site';
+
+type FeedFormat = 'rss' | 'atom';
+const FEED_ITEMS = 20;
+
+function xml(value: string): string {
+  return value
+    .replaceAll('&', '&amp;')
+    .replaceAll('<', '&lt;')
+    .replaceAll('>', '&gt;')
+    .replaceAll('"', '&quot;')
+    .replaceAll('\'', '&apos;');
+}
+
+function feedMeta(locale: Locale) {
+  const prefix = locale === 'zh' ? '/zh' : '';
+  return {
+    title: locale === 'zh' ? 'Apache APISIX 中文博客' : 'Apache APISIX Blog',
+    description: locale === 'zh'
+      ? 'Apache APISIX 的项目动态、技术实践与社区文章。'
+      : 'Project updates, technical practices, and community stories from 
Apache APISIX.',
+    language: locale === 'zh' ? 'zh-CN' : 'en',
+    home: `${SITE}${prefix}/blog/`,
+    prefix,
+  };
+}
+
+function renderRss(locale: Locale): string {
+  const posts = getBlogPosts(locale).slice(0, FEED_ITEMS);
+  const meta = feedMeta(locale);
+  const updated = posts[0]?.date ?? new Date(0);
+  const items = posts.map((post) => {
+    const url = `${SITE}${post.url}`;
+    return 
`<item><title>${xml(post.title)}</title><link>${xml(url)}</link><guid 
isPermaLink="true">${xml(url)}</guid><pubDate>${post.date.toUTCString()}</pubDate><description>${xml(post.description)}</description></item>`;
+  }).join('');
+
+  return `<?xml version="1.0" encoding="UTF-8"?>\n<rss 
version="2.0"><channel><title>${xml(meta.title)}</title><link>${xml(meta.home)}</link><description>${xml(meta.description)}</description><language>${meta.language}</language><lastBuildDate>${updated.toUTCString()}</lastBuildDate>${items}</channel></rss>\n`;
+}
+
+function renderAtom(locale: Locale): string {
+  const posts = getBlogPosts(locale).slice(0, FEED_ITEMS);
+  const meta = feedMeta(locale);
+  const updated = posts[0]?.date ?? new Date(0);
+  const self = `${SITE}${meta.prefix}/blog/atom.xml`;
+  const entries = posts.map((post) => {
+    const url = `${SITE}${post.url}`;
+    return `<entry><title>${xml(post.title)}</title><id>${xml(url)}</id><link 
href="${xml(url)}"/><updated>${post.date.toISOString()}</updated><summary>${xml(post.description)}</summary></entry>`;
+  }).join('');
+
+  return `<?xml version="1.0" encoding="UTF-8"?>\n<feed 
xmlns="http://www.w3.org/2005/Atom";><title>${xml(meta.title)}</title><id>${xml(meta.home)}</id><link
 href="${xml(self)}" rel="self" type="application/atom+xml"/><link 
href="${xml(meta.home)}"/><updated>${updated.toISOString()}</updated>${entries}</feed>\n`;
+}
+
+export default function blogFeedResponse(locale: Locale, format: FeedFormat): 
Response {
+  const body = format === 'rss' ? renderRss(locale) : renderAtom(locale);
+  const contentType = format === 'rss' ? 'application/rss+xml' : 
'application/atom+xml';
+  return new Response(body, {
+    headers: {
+      'Content-Type': `${contentType}; charset=utf-8`,
+      'Cache-Control': 'public, max-age=3600',
+    },
+  });
+}
diff --git a/next/src/lib/content.ts b/next/src/lib/content.ts
index 15b5dde09a0..94c5cebdcf2 100644
--- a/next/src/lib/content.ts
+++ b/next/src/lib/content.ts
@@ -1,7 +1,7 @@
 import { POSTS_PER_PAGE, localePrefix, type Locale } from './site';
 
 export interface MdModule {
-  frontmatter: Record<string, any>;
+  frontmatter: { [key: string]: any };
   file: string;
   Content: any;
   getHeadings: () => { depth: number; slug: string; text: string }[];
@@ -45,19 +45,18 @@ export function excerpt(mod: MdModule): string {
     .replace(/<!--[\s\S]*?-->/g, '')
     .replace(/```[\s\S]*?```/g, '')
     .replace(/^<head>[\s\S]*?<\/head>$/m, '');
-  for (const block of cleaned.split(/\n\s*\n/)) {
-    const t = block.trim();
-    // Skip headings, admonitions, tables, html, quotes, images, code, list
-    // items, and imports — but NOT prose that merely starts with a [link].
-    if (!t || /^(#|:{3}|\||<|>|!\[|`|\* |- |\d+\. |import )/.test(t)) continue;
-    const text = t
+  // Skip headings, admonitions, tables, html, quotes, images, code, list
+  // items, and imports — but NOT prose that merely starts with a [link].
+  return cleaned
+    .split(/\n\s*\n/)
+    .map((block) => block.trim())
+    .filter((block) => block && !/^(#|:{3}|\||<|>|!\[|`|\* |- |\d+\. |import 
)/.test(block))
+    .map((block) => block
       .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
       .replace(/[*_`]/g, '')
       .replace(/\s+/g, ' ')
-      .trim();
-    if (text.length >= 40) return text;
-  }
-  return '';
+      .trim())
+    .find((block) => block.length >= 40) ?? '';
 }
 
 export interface Post {
@@ -72,11 +71,13 @@ export interface Post {
   tags: string[];
   image?: string;
   author?: string;
+  /** Stable source identity used to pair real EN/ZH blog translations. */
+  translationKey?: string;
   mod: MdModule;
 }
 
 // NOTE: import.meta.glob patterns must be literals (Vite static analysis).
-type MdMap = Record<string, MdModule>;
+type MdMap = { [key: string]: MdModule };
 const blogEnModules = import.meta.glob('/content/blog-en/**/*.md', { eager: 
true }) as MdMap;
 const blogZhModules = import.meta.glob('/content/blog-zh/**/*.md', { eager: 
true }) as MdMap;
 const learningModules = import.meta.glob('/content/learning-center/*.md', { 
eager: true }) as MdMap;
@@ -93,12 +94,12 @@ const docsJavaEn = 
import.meta.glob('/content/docs-java-plugin-runner-en/**/*.md
 const docsGoEn = import.meta.glob('/content/docs-go-plugin-runner-en/**/*.md', 
{ eager: true }) as MdMap;
 const docsPythonEn = 
import.meta.glob('/content/docs-python-plugin-runner-en/**/*.md', { eager: true 
}) as MdMap;
 
-const sidebarConfigs = import.meta.glob('/content/docs-*/config.json', { 
eager: true }) as Record<string, any>;
+const sidebarConfigs = import.meta.glob('/content/docs-*/config.json', { 
eager: true }) as { [key: string]: any };
 
 /** Git ref each project's docs were synced from (written by 
sync-content.mjs). */
 const docRefs = (Object.values(
-  import.meta.glob('/content/doc-refs.json', { eager: true }) as 
Record<string, any>,
-)[0]?.default ?? {}) as Record<string, string>;
+  import.meta.glob('/content/doc-refs.json', { eager: true }) as { [key: 
string]: any },
+)[0]?.default ?? {}) as { [key: string]: string };
 
 /**
  * "Edit this page" URL for an upstream project doc. Uses the ref the content
@@ -112,7 +113,7 @@ export function docEditUrl(project: string, repo: string, 
entry: DocEntry): stri
 }
 
 /** Sub-projects served under /docs/<key>/ via the generic route. */
-export const SUBPROJECTS: Record<string, { en: MdMap; zh?: MdMap; repo: string 
}> = {
+export const SUBPROJECTS: { [key: string]: { en: MdMap; zh?: MdMap; repo: 
string } } = {
   'ingress-controller': { en: docsIngressEn, zh: docsIngressZh, repo: 
'apisix-ingress-controller' },
   'helm-chart': { en: docsHelmEn, repo: 'apisix-helm-chart' },
   docker: { en: docsDockerEn, zh: docsDockerZh, repo: 'apisix-docker' },
@@ -125,7 +126,7 @@ function baseName(file: string): string {
   return file.split('/').pop()!.replace(/\.md$/, '');
 }
 
-function toTags(fm: Record<string, any>): string[] {
+function toTags(fm: { [key: string]: any }): string[] {
   const tags = fm.tags ?? [];
   return Array.isArray(tags) ? tags.map(String) : [String(tags)];
 }
@@ -154,7 +155,13 @@ function blogPost(path: string, mod: MdModule, locale: 
Locale): Post | null {
     dateHuman: humanDate(new Date(`${y}-${mo}-${d}T00:00:00Z`), locale),
     tags: toTags(mod.frontmatter),
     image: mod.frontmatter.image,
-    author: mod.frontmatter.author ?? (Array.isArray(mod.frontmatter.authors) 
? mod.frontmatter.authors[0]?.name : undefined),
+    author: mod.frontmatter.author
+      ?? (Array.isArray(mod.frontmatter.authors) ? 
mod.frontmatter.authors[0]?.name : undefined),
+    translationKey: String(
+      mod.frontmatter.translationKey
+      ?? mod.frontmatter.translation_key
+      ?? `${y}/${mo}/${d}/${name}`,
+    ).toLowerCase(),
     mod,
   };
 }
@@ -178,7 +185,9 @@ function flatPost(path: string, mod: MdModule, urlBase: 
string, locale: Locale):
   };
 }
 
-const byDateDesc = (a: Post, b: Post) => b.date.getTime() - a.date.getTime() 
|| a.title.localeCompare(b.title);
+const byDateDesc = (a: Post, b: Post) => (
+  b.date.getTime() - a.date.getTime() || a.title.localeCompare(b.title)
+);
 
 export function getBlogPosts(locale: Locale): Post[] {
   const modules = locale === 'zh' ? blogZhModules : blogEnModules;
@@ -223,13 +232,13 @@ export function tagSlug(tag: string): string {
 
 export function groupByTag(posts: Post[]): Map<string, { label: string; posts: 
Post[] }> {
   const map = new Map<string, { label: string; posts: Post[] }>();
-  for (const post of posts) {
-    for (const tag of post.tags) {
+  posts.forEach((post) => {
+    post.tags.forEach((tag) => {
       const slug = tagSlug(tag);
       if (!map.has(slug)) map.set(slug, { label: tag, posts: [] });
       map.get(slug)!.posts.push(post);
-    }
-  }
+    });
+  });
   return map;
 }
 
@@ -246,13 +255,41 @@ export interface DocEntry {
    * does not exist upstream.
    */
   sourceLocale: Locale;
+  /** True only when the corresponding Chinese file contains translated prose. 
*/
+  hasTranslation: boolean;
   url: string;
   title: string;
   description: string;
   mod: MdModule;
 }
 
-function docId(path: string, root: string, fm?: Record<string, any>): string {
+function normalizedDocProse(mod: MdModule): string {
+  return (mod.rawContent?.() ?? '')
+    .replace(/^---[\s\S]*?---\s*/m, '')
+    .replace(/<!--[^]*?-->/g, ' ')
+    .replace(/```[^]*?```/g, ' ')
+    .replace(/`[^`]*`/g, ' ')
+    .replace(/<[^>]+>/g, ' ')
+    .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
+    .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
+    .replace(/[#*_>|{}()[\]-]/g, ' ')
+    .replace(/\s+/g, ' ')
+    .trim()
+    .toLowerCase();
+}
+
+/** A copied English file is not a translation merely because it exists in 
zh/. */
+export function isMeaningfulTranslation(en: MdModule, zh?: MdModule): boolean {
+  if (!zh) return false;
+  if (zh.frontmatter.translated === false) return false;
+  if (zh.frontmatter.translated === true) return true;
+  const enText = normalizedDocProse(en);
+  const zhText = normalizedDocProse(zh);
+  if (!zhText || zhText === enText) return false;
+  return (zhText.match(/[\u3400-\u9fff]/g) ?? []).length >= 20;
+}
+
+function docId(path: string, root: string, fm?: { [key: string]: any }): 
string {
   const rel = path.slice(path.indexOf(root) + root.length + 
1).replace(/\.md$/, '');
   const dir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/') + 1) : '';
   // Docusaurus URL precedence: frontmatter slug (root-relative if it starts
@@ -279,6 +316,7 @@ export function getGeneralDocs(locale: Locale): DocEntry[] {
         pathId: docId(p, 'docs-general'),
         // docs/general lives in this repo and has no per-locale source split.
         sourceLocale: 'en' as Locale,
+        hasTranslation: false,
         url: `${localePrefix(locale)}/docs/general/${id}/`,
         title: docTitle(mod, id),
         description: mod.frontmatter.description ?? excerpt(mod),
@@ -292,12 +330,16 @@ export function getApisixDocs(locale: Locale): DocEntry[] 
{
   const zh = new Map(Object.entries(docsApisixZh).map(([p, mod]) => [docId(p, 
'docs-apisix-zh'), mod]));
   return Object.entries(docsApisixEn).map(([p, mod]) => {
     const id = docId(p, 'docs-apisix-en');
-    const translated = locale === 'zh' ? zh.get(id) : undefined;
+    const pathId = docId(p, 'docs-apisix-en');
+    const zhMod = zh.get(pathId);
+    const hasTranslation = isMeaningfulTranslation(mod, zhMod);
+    const translated = locale === 'zh' && hasTranslation ? zhMod : undefined;
     const effective = translated ?? mod;
     return {
       id,
-      pathId: docId(p, 'docs-apisix-en'),
+      pathId,
       sourceLocale: translated ? 'zh' : 'en',
+      hasTranslation,
       url: `${localePrefix(locale)}/docs/apisix/${id}/`,
       title: docTitle(effective, id),
       description: effective.frontmatter.description ?? excerpt(effective),
@@ -311,17 +353,21 @@ export function getSubprojectDocs(project: string, 
locale: Locale): DocEntry[] {
   const { en, zh } = SUBPROJECTS[project];
   const rootName = `docs-${project}-`;
   const zhMap = new Map(Object.entries(zh ?? {}).filter(([p]) => 
!p.endsWith('config.json'))
-    .map(([p, mod]) => [docId(p, `${rootName}zh`, mod.frontmatter), mod]));
+    .map(([p, mod]) => [docId(p, `${rootName}zh`), mod]));
   return Object.entries(en)
     .filter(([p]) => !p.endsWith('config.json'))
     .map(([p, mod]) => {
       const id = docId(p, `${rootName}en`, mod.frontmatter);
-      const translated = locale === 'zh' ? zhMap.get(id) : undefined;
+      const pathId = docId(p, `${rootName}en`);
+      const zhMod = zhMap.get(pathId);
+      const hasTranslation = isMeaningfulTranslation(mod, zhMod);
+      const translated = locale === 'zh' && hasTranslation ? zhMod : undefined;
       const effective = translated ?? mod;
       return {
         id,
-        pathId: docId(p, `${rootName}en`),
+        pathId,
         sourceLocale: (translated ? 'zh' : 'en') as Locale,
+        hasTranslation,
         url: `${localePrefix(locale)}/docs/${project}/${id}/`,
         title: docTitle(effective, id),
         description: effective.frontmatter.description ?? excerpt(effective),
@@ -396,14 +442,10 @@ export interface VersionEntry {
  * apisix it yields the trap-free "getting-started/README".
  */
 export function sidebarLandingId(nodes: SidebarNode[]): string | undefined {
-  for (const node of nodes) {
-    if (node.id) return node.id;
-    if (node.items) {
-      const found = sidebarLandingId(node.items);
-      if (found) return found;
-    }
-  }
-  return undefined;
+  return nodes.reduce<string | undefined>((found, node) => {
+    if (found || node.id) return found ?? node.id;
+    return node.items ? sidebarLandingId(node.items) : undefined;
+  }, undefined);
 }
 
 /**
diff --git a/next/src/pages/articles/[slug].astro 
b/next/src/pages/articles/[slug].astro
index 66c0e7b4c33..3e3aeaa4f6d 100644
--- a/next/src/pages/articles/[slug].astro
+++ b/next/src/pages/articles/[slug].astro
@@ -13,5 +13,6 @@ const newer = idx > 0 ? pool[idx - 1] : undefined;
 const older = idx >= 0 && idx < pool.length - 1 ? pool[idx + 1] : undefined;
 ---
 <Article post={post} locale="en" path={`/articles/${post.slug}/`} 
schemaType="Article" backUrl="/articles/" related={related}
+  languageSwitchPath="/zh/articles/"
   newer={newer}
-  older={older} />
\ No newline at end of file
+  older={older} />
diff --git a/next/src/pages/blog/[...rest].astro 
b/next/src/pages/blog/[...rest].astro
index a125ee68a71..61de361ceac 100644
--- a/next/src/pages/blog/[...rest].astro
+++ b/next/src/pages/blog/[...rest].astro
@@ -3,12 +3,18 @@ import Article from '../../layouts/Article.astro';
 import { getBlogPosts, relatedPosts } from '../../lib/content';
 
 export function getStaticPaths() {
+  const zhByKey = new Map(getBlogPosts('zh').map((post) => 
[post.translationKey, post]));
   return getBlogPosts('en').map((post) => ({
     params: { rest: post.slug },
-    props: { post },
+    props: {
+      post,
+      alternatePaths: zhByKey.has(post.translationKey)
+        ? { en: post.url, zh: zhByKey.get(post.translationKey)!.url }
+        : undefined,
+    },
   }));
 }
-const { post } = Astro.props;
+const { post, alternatePaths } = Astro.props;
 const pool = getBlogPosts('en');
 const related = relatedPosts(post, pool);
 const idx = pool.findIndex((p) => p.url === post.url);
@@ -16,5 +22,7 @@ const newer = idx > 0 ? pool[idx - 1] : undefined;
 const older = idx >= 0 && idx < pool.length - 1 ? pool[idx + 1] : undefined;
 ---
 <Article post={post} locale="en" path={post.url} tagBase="/blog" 
schemaType="BlogPosting" related={related}
+  alternatePaths={alternatePaths}
+  languageSwitchPath="/zh/blog/"
   newer={newer}
   older={older} />
diff --git a/next/src/pages/blog/atom.xml.ts b/next/src/pages/blog/atom.xml.ts
new file mode 100644
index 00000000000..6cd296b17e2
--- /dev/null
+++ b/next/src/pages/blog/atom.xml.ts
@@ -0,0 +1,7 @@
+import blogFeedResponse from '../../lib/blog-feed';
+
+export const prerender = true;
+
+export function GET(): Response {
+  return blogFeedResponse('en', 'atom');
+}
diff --git a/next/src/pages/blog/rss.xml.ts b/next/src/pages/blog/rss.xml.ts
new file mode 100644
index 00000000000..b020b07fe12
--- /dev/null
+++ b/next/src/pages/blog/rss.xml.ts
@@ -0,0 +1,7 @@
+import blogFeedResponse from '../../lib/blog-feed';
+
+export const prerender = true;
+
+export function GET(): Response {
+  return blogFeedResponse('en', 'rss');
+}
diff --git a/next/src/pages/learning-center/[slug].astro 
b/next/src/pages/learning-center/[slug].astro
index 994203f469e..0451dd50185 100644
--- a/next/src/pages/learning-center/[slug].astro
+++ b/next/src/pages/learning-center/[slug].astro
@@ -36,6 +36,7 @@ const jsonLdExtra: object[] = Array.isArray(faq) && faq.length
   schemaType="TechArticle"
   jsonLdExtra={jsonLdExtra}
   related={related}
+  languageSwitchPath="/zh/learning-center/"
   newer={newer}
   older={older}
 >
diff --git a/next/src/pages/zh/articles/[slug].astro 
b/next/src/pages/zh/articles/[slug].astro
index 2ae7684fdc7..5e35d51b2ce 100644
--- a/next/src/pages/zh/articles/[slug].astro
+++ b/next/src/pages/zh/articles/[slug].astro
@@ -1,6 +1,7 @@
 ---
 import Article from '../../../layouts/Article.astro';
 import { getArticles, relatedPosts } from '../../../lib/content';
+import { SITE } from '../../../lib/site';
 
 export function getStaticPaths() {
   return getArticles('zh').map((post) => ({ params: { slug: post.slug }, 
props: { post } }));
@@ -13,5 +14,7 @@ const newer = idx > 0 ? pool[idx - 1] : undefined;
 const older = idx >= 0 && idx < pool.length - 1 ? pool[idx + 1] : undefined;
 ---
 <Article post={post} locale="zh" path={`/articles/${post.slug}/`} 
schemaType="Article" backUrl="/zh/articles/" related={related}
+  canonicalOverride={`${SITE}/articles/${post.slug}/`}
+  languageSwitchPath={`/articles/${post.slug}/`}
   newer={newer}
-  older={older} />
\ No newline at end of file
+  older={older} />
diff --git a/next/src/pages/zh/blog/[...rest].astro 
b/next/src/pages/zh/blog/[...rest].astro
index a2c8862be93..52281867989 100644
--- a/next/src/pages/zh/blog/[...rest].astro
+++ b/next/src/pages/zh/blog/[...rest].astro
@@ -3,12 +3,18 @@ import Article from '../../../layouts/Article.astro';
 import { getBlogPosts, relatedPosts } from '../../../lib/content';
 
 export function getStaticPaths() {
+  const enByKey = new Map(getBlogPosts('en').map((post) => 
[post.translationKey, post]));
   return getBlogPosts('zh').map((post) => ({
     params: { rest: post.slug },
-    props: { post },
+    props: {
+      post,
+      alternatePaths: enByKey.has(post.translationKey)
+        ? { en: enByKey.get(post.translationKey)!.url, zh: post.url }
+        : undefined,
+    },
   }));
 }
-const { post } = Astro.props;
+const { post, alternatePaths } = Astro.props;
 const pool = getBlogPosts('zh');
 const related = relatedPosts(post, pool);
 const idx = pool.findIndex((p) => p.url === post.url);
@@ -17,5 +23,7 @@ const older = idx >= 0 && idx < pool.length - 1 ? pool[idx + 
1] : undefined;
 const path = post.url.replace(/^\/zh/, '');
 ---
 <Article post={post} locale="zh" path={path} tagBase="/zh/blog" 
schemaType="BlogPosting" related={related}
+  alternatePaths={alternatePaths}
+  languageSwitchPath="/blog/"
   newer={newer}
   older={older} />
diff --git a/next/src/pages/zh/blog/atom.xml.ts 
b/next/src/pages/zh/blog/atom.xml.ts
new file mode 100644
index 00000000000..2774aba33e0
--- /dev/null
+++ b/next/src/pages/zh/blog/atom.xml.ts
@@ -0,0 +1,7 @@
+import blogFeedResponse from '../../../lib/blog-feed';
+
+export const prerender = true;
+
+export function GET(): Response {
+  return blogFeedResponse('zh', 'atom');
+}
diff --git a/next/src/pages/zh/blog/rss.xml.ts 
b/next/src/pages/zh/blog/rss.xml.ts
new file mode 100644
index 00000000000..4c94012df3b
--- /dev/null
+++ b/next/src/pages/zh/blog/rss.xml.ts
@@ -0,0 +1,7 @@
+import blogFeedResponse from '../../../lib/blog-feed';
+
+export const prerender = true;
+
+export function GET(): Response {
+  return blogFeedResponse('zh', 'rss');
+}
diff --git a/next/tests/e2e/seo-signals.spec.mjs 
b/next/tests/e2e/seo-signals.spec.mjs
new file mode 100644
index 00000000000..e107db1bfd8
--- /dev/null
+++ b/next/tests/e2e/seo-signals.spec.mjs
@@ -0,0 +1,90 @@
+import { expect, test } from '@playwright/test';
+
+async function alternateMap(page) {
+  return page.locator('link[rel="alternate"][hreflang]').evaluateAll((links) 
=> Object.fromEntries(
+    links.map((link) => [link.getAttribute('hreflang'), 
link.getAttribute('href')]),
+  ));
+}
+
+test('real document translations emit reciprocal hreflang', async ({ page }) 
=> {
+  const en = 'https://apisix.apache.org/docs/apisix/installation-guide/';
+  const zh = 'https://apisix.apache.org/zh/docs/apisix/installation-guide/';
+
+  await page.goto('/docs/apisix/installation-guide/');
+  await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', 
en);
+  expect(await alternateMap(page)).toEqual({ en, zh, 'x-default': en });
+
+  await page.goto('/zh/docs/apisix/installation-guide/');
+  await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', 
zh);
+  expect(await alternateMap(page)).toEqual({ en, zh, 'x-default': en });
+});
+
+test('untranslated Chinese fallback docs canonicalize to English without 
hreflang', async ({ page }) => {
+  await page.goto('/zh/docs/apisix/deployment-modes/');
+  await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
+    'href',
+    'https://apisix.apache.org/docs/apisix/deployment-modes/',
+  );
+  await expect(page.locator('link[rel="alternate"][hreflang]')).toHaveCount(0);
+});
+
+test('plugin docs retain their API7 canonical contract', async ({ page }) => {
+  await page.goto('/docs/apisix/plugins/jwt-auth/');
+  await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
+    'href',
+    'https://docs.api7.ai/hub/jwt-auth',
+  );
+  await expect(page.locator('link[rel="alternate"][hreflang]')).toHaveCount(0);
+
+  await page.goto('/zh/docs/apisix/plugins/jwt-auth/');
+  await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
+    'href',
+    'https://docs.apiseven.com/hub/jwt-auth',
+  );
+  await expect(page.locator('link[rel="alternate"][hreflang]')).toHaveCount(0);
+});
+
+test('blog hreflang exists only for verified source pairs', async ({ page }) 
=> {
+  const en = 
'https://apisix.apache.org/blog/2026/07/31/2026-jul-monthly-report/';
+  const zh = 
'https://apisix.apache.org/zh/blog/2026/07/31/2026-jul-monthly-report/';
+
+  await page.goto('/blog/2026/07/31/2026-jul-monthly-report/');
+  expect(await alternateMap(page)).toEqual({ en, zh, 'x-default': en });
+
+  await page.goto('/zh/blog/2022/11/25/how-apisix-support-1000-pods/');
+  await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
+    'href',
+    
'https://apisix.apache.org/zh/blog/2022/11/25/how-apisix-support-1000-pods/',
+  );
+  await expect(page.locator('link[rel="alternate"][hreflang]')).toHaveCount(0);
+  await expect(page.locator('header 
a[title="English"]')).toHaveAttribute('href', '/blog/');
+});
+
+test('explicit translation keys pair blog posts published on different dates', 
async ({ page }) => {
+  const en = 
'https://apisix.apache.org/blog/2023/03/10/release-apache-apisix-3.2.0/';
+  const zh = 
'https://apisix.apache.org/zh/blog/2023/03/09/release-apache-apisix-3.2.0/';
+
+  await page.goto('/blog/2023/03/10/release-apache-apisix-3.2.0/');
+  expect(await alternateMap(page)).toEqual({ en, zh, 'x-default': en });
+
+  await page.goto('/zh/blog/2023/03/09/release-apache-apisix-3.2.0/');
+  expect(await alternateMap(page)).toEqual({ en, zh, 'x-default': en });
+});
+
+test('English and Chinese blog feeds are published as valid RSS and Atom', 
async ({ request }) => {
+  for (const [path, type, root] of [
+    ['/blog/rss.xml', 'application/rss+xml', '<rss'],
+    ['/blog/atom.xml', 'application/atom+xml', '<feed'],
+    ['/zh/blog/rss.xml', 'application/rss+xml', '<rss'],
+    ['/zh/blog/atom.xml', 'application/atom+xml', '<feed'],
+  ]) {
+    const response = await request.get(path);
+    expect(response.ok(), path).toBe(true);
+    expect(response.headers()['content-type'], path).toMatch(
+      new RegExp(`(?:${type.replace('+', '\\+')}|application/xml|text/xml)`),
+    );
+    const body = await response.text();
+    expect(body, path).toContain(root);
+    expect(body, path).toContain('<title>Apache APISIX');
+  }
+});
diff --git a/scripts/sync-docs.js b/scripts/sync-docs.js
index 9974e876a7b..d765b8a7b41 100644
--- a/scripts/sync-docs.js
+++ b/scripts/sync-docs.js
@@ -182,7 +182,7 @@ function log(text) {
   // console.log(text);
 }
 
-async function replaceMDElements(project, path, branch = 'master') {
+async function replaceMDElements(project, path, branch = 'master', locale = 
'en') {
   const allMDFilePaths = path.map((p) => `${p}/**/*.md`);
 
   // replace the image urls inside markdown files
@@ -221,8 +221,18 @@ async function replaceMDElements(project, path, branch = 
'master') {
     },
   };
 
+  // Plugin docs intentionally canonicalize to API7's documentation hubs.
+  // Keep Chinese Docusaurus archives aligned with the Chinese hub, matching
+  // the current-version Astro pages generated by 
next/scripts/sync-content.mjs.
+  const chineseCanonicalOptions = {
+    files: allMDFilePaths,
+    from: 
/(<link\s+rel=["']canonical["']\s+href=["'])https:\/\/docs\.api7\.ai\//g,
+    to: '$1https://docs.apiseven.com/',
+  };
+
   await replace(imageOptions);
   await replace(markdownOptions);
+  if (locale === 'zh') await replace(chineseCanonicalOptions);
 }
 
 async function isFileExisted(p) {
@@ -388,14 +398,15 @@ function extractDocsVersionTasks(project, version) {
 
         await Promise.allSettled([
           copyDocs(enSrcDocs, enTargetDocs)
-            .then(() => replaceMDElements(projectName, [enTargetDocs], 
branchName))
+            .then(() => replaceMDElements(projectName, [enTargetDocs], 
branchName, 'en'))
             .then(() => handleConfig2Sidebar(
               enTargetDocs,
               enTargetDocs,
               displayVersionName,
               `${websitePath}/docs-${project.name}_versioned_sidebars`,
             )),
-          copyDocs(zhSrcDocs, zhTargetDocs).then(() => 
replaceMDElements(projectName, [zhTargetDocs], branchName)),
+          copyDocs(zhSrcDocs, zhTargetDocs)
+            .then(() => replaceMDElements(projectName, [zhTargetDocs], 
branchName, 'zh')),
         ]).catch(() => {
           /* ignore */
         });
@@ -432,9 +443,10 @@ function extractDocsNextVersionTasks(project, version) {
 
         await Promise.all([
           copyDocs(enSrcDocs, enTargetDocs)
-            .then(() => replaceMDElements(projectName, [enTargetDocs], 
branchName))
+            .then(() => replaceMDElements(projectName, [enTargetDocs], 
branchName, 'en'))
             .then(() => handleConfig2Sidebar(enTargetDocs, enTargetDocs)),
-          copyDocs(zhSrcDocs, zhTargetDocs).then(() => 
replaceMDElements(projectName, [zhTargetDocs], branchName)),
+          copyDocs(zhSrcDocs, zhTargetDocs)
+            .then(() => replaceMDElements(projectName, [zhTargetDocs], 
branchName, 'zh')),
         ]).catch(() => {
           /* ignore */
         });
diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js
index b8ca33fa092..2e8a2136ad6 100644
--- a/website/docusaurus.config.js
+++ b/website/docusaurus.config.js
@@ -76,14 +76,6 @@ module.exports = {
     ],
   ],
   plugins: [
-    [
-      '@docusaurus/plugin-content-blog',
-      {
-        id: 'events',
-        routeBasePath: 'events',
-        path: 'events',
-      },
-    ],
     [
       '@docusaurus/plugin-content-blog',
       {
diff --git a/website/static/robots.txt b/website/static/robots.txt
index bb355ad7d64..dffd002fb6f 100644
--- a/website/static/robots.txt
+++ b/website/static/robots.txt
@@ -10,122 +10,8 @@ Disallow: /zh/blog/page/
 Disallow: /search
 Disallow: /zh/search
 
-# Versioned docs — only the unversioned (latest) paths should be indexed.
-# e.g. /docs/apisix/ is the latest; /docs/apisix/3.14/ is a duplicate.
-Disallow: /docs/apisix/3.10/
-Disallow: /docs/apisix/3.11/
-Disallow: /docs/apisix/3.12/
-Disallow: /docs/apisix/3.13/
-Disallow: /docs/apisix/3.14/
-Disallow: /docs/apisix/3.15/
-Disallow: /docs/apisix/3.16/
-Disallow: /docs/apisix/next/
-Disallow: /docs/ingress-controller/3.10/
-Disallow: /docs/ingress-controller/3.11/
-Disallow: /docs/ingress-controller/3.12/
-Disallow: /docs/ingress-controller/3.13/
-Disallow: /docs/ingress-controller/3.14/
-Disallow: /docs/ingress-controller/3.15/
-Disallow: /docs/ingress-controller/3.16/
-Disallow: /docs/ingress-controller/next/
-Disallow: /docs/helm-chart/3.10/
-Disallow: /docs/helm-chart/3.11/
-Disallow: /docs/helm-chart/3.12/
-Disallow: /docs/helm-chart/3.13/
-Disallow: /docs/helm-chart/3.14/
-Disallow: /docs/helm-chart/3.15/
-Disallow: /docs/helm-chart/3.16/
-Disallow: /docs/helm-chart/next/
-Disallow: /docs/docker/3.10/
-Disallow: /docs/docker/3.11/
-Disallow: /docs/docker/3.12/
-Disallow: /docs/docker/3.13/
-Disallow: /docs/docker/3.14/
-Disallow: /docs/docker/3.15/
-Disallow: /docs/docker/3.16/
-Disallow: /docs/docker/next/
-Disallow: /docs/java-plugin-runner/3.10/
-Disallow: /docs/java-plugin-runner/3.11/
-Disallow: /docs/java-plugin-runner/3.12/
-Disallow: /docs/java-plugin-runner/3.13/
-Disallow: /docs/java-plugin-runner/3.14/
-Disallow: /docs/java-plugin-runner/3.15/
-Disallow: /docs/java-plugin-runner/3.16/
-Disallow: /docs/java-plugin-runner/next/
-Disallow: /docs/go-plugin-runner/3.10/
-Disallow: /docs/go-plugin-runner/3.11/
-Disallow: /docs/go-plugin-runner/3.12/
-Disallow: /docs/go-plugin-runner/3.13/
-Disallow: /docs/go-plugin-runner/3.14/
-Disallow: /docs/go-plugin-runner/3.15/
-Disallow: /docs/go-plugin-runner/3.16/
-Disallow: /docs/go-plugin-runner/next/
-Disallow: /docs/python-plugin-runner/3.10/
-Disallow: /docs/python-plugin-runner/3.11/
-Disallow: /docs/python-plugin-runner/3.12/
-Disallow: /docs/python-plugin-runner/3.13/
-Disallow: /docs/python-plugin-runner/3.14/
-Disallow: /docs/python-plugin-runner/3.15/
-Disallow: /docs/python-plugin-runner/3.16/
-Disallow: /docs/python-plugin-runner/next/
-
-# Chinese equivalents
-Disallow: /zh/docs/apisix/3.10/
-Disallow: /zh/docs/apisix/3.11/
-Disallow: /zh/docs/apisix/3.12/
-Disallow: /zh/docs/apisix/3.13/
-Disallow: /zh/docs/apisix/3.14/
-Disallow: /zh/docs/apisix/3.15/
-Disallow: /zh/docs/apisix/3.16/
-Disallow: /zh/docs/apisix/next/
-Disallow: /zh/docs/ingress-controller/3.10/
-Disallow: /zh/docs/ingress-controller/3.11/
-Disallow: /zh/docs/ingress-controller/3.12/
-Disallow: /zh/docs/ingress-controller/3.13/
-Disallow: /zh/docs/ingress-controller/3.14/
-Disallow: /zh/docs/ingress-controller/3.15/
-Disallow: /zh/docs/ingress-controller/3.16/
-Disallow: /zh/docs/ingress-controller/next/
-Disallow: /zh/docs/helm-chart/3.10/
-Disallow: /zh/docs/helm-chart/3.11/
-Disallow: /zh/docs/helm-chart/3.12/
-Disallow: /zh/docs/helm-chart/3.13/
-Disallow: /zh/docs/helm-chart/3.14/
-Disallow: /zh/docs/helm-chart/3.15/
-Disallow: /zh/docs/helm-chart/3.16/
-Disallow: /zh/docs/helm-chart/next/
-Disallow: /zh/docs/docker/3.10/
-Disallow: /zh/docs/docker/3.11/
-Disallow: /zh/docs/docker/3.12/
-Disallow: /zh/docs/docker/3.13/
-Disallow: /zh/docs/docker/3.14/
-Disallow: /zh/docs/docker/3.15/
-Disallow: /zh/docs/docker/3.16/
-Disallow: /zh/docs/docker/next/
-Disallow: /zh/docs/java-plugin-runner/3.10/
-Disallow: /zh/docs/java-plugin-runner/3.11/
-Disallow: /zh/docs/java-plugin-runner/3.12/
-Disallow: /zh/docs/java-plugin-runner/3.13/
-Disallow: /zh/docs/java-plugin-runner/3.14/
-Disallow: /zh/docs/java-plugin-runner/3.15/
-Disallow: /zh/docs/java-plugin-runner/3.16/
-Disallow: /zh/docs/java-plugin-runner/next/
-Disallow: /zh/docs/go-plugin-runner/3.10/
-Disallow: /zh/docs/go-plugin-runner/3.11/
-Disallow: /zh/docs/go-plugin-runner/3.12/
-Disallow: /zh/docs/go-plugin-runner/3.13/
-Disallow: /zh/docs/go-plugin-runner/3.14/
-Disallow: /zh/docs/go-plugin-runner/3.15/
-Disallow: /zh/docs/go-plugin-runner/3.16/
-Disallow: /zh/docs/go-plugin-runner/next/
-Disallow: /zh/docs/python-plugin-runner/3.10/
-Disallow: /zh/docs/python-plugin-runner/3.11/
-Disallow: /zh/docs/python-plugin-runner/3.12/
-Disallow: /zh/docs/python-plugin-runner/3.13/
-Disallow: /zh/docs/python-plugin-runner/3.14/
-Disallow: /zh/docs/python-plugin-runner/3.15/
-Disallow: /zh/docs/python-plugin-runner/3.16/
-Disallow: /zh/docs/python-plugin-runner/next/
+# Versioned and next docs remain crawlable so Google can process their
+# noindex,follow and canonical tags. They are excluded from both sitemaps.
 
 Sitemap: https://apisix.apache.org/sitemap.xml
 Sitemap: https://apisix.apache.org/zh/sitemap.xml

Reply via email to