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&a/`);
+ writePage('blog/named&a', `${site}/blog/named&a/`);
+ writePage('blog/sol/target', `${site}/blog/sol/target/`);
+ writePage('blog/mixed&AmP;case', `${site}/blog/mixed&AmP;case/`);
+ writePage('blog/a&=b', `${site}/blog/a&=b/`);
+ writePage('external-entity-origin', 'https://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('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll('\'', ''');
+}
+
+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