From 33b37360650ee725b73f27f4ce614a0408fa856e Mon Sep 17 00:00:00 2001 From: Ming Wen Date: Mon, 13 Jul 2026 09:54:33 +0800 Subject: [PATCH 1/2] fix: canonicalize versioned doc pages to the version-less latest URL Versioned doc pages (/docs///...) self-canonicalize by default, so Google indexes each version as an independent page competing with the version-less latest URL. GSC shows the resulting traffic migration: e.g. /docs/apisix/plugins/cors/ clicks fell 69 -> 14 while /docs/apisix/3.10/plugins/cors/ rose 0 -> 38 in the last 28 days. Wrap theme LayoutHead to re-point canonical (and og:url) of versioned and /next/ doc pages at the version-less URL. Precedence is preserved by react-helmet's last-wins rule: a canonical embedded in the doc markdown itself (e.g. the docs.api7.ai links on latest pages) still overrides this wrapper, and version-less pages are untouched. Verified on a production build: - /docs/apisix/3.16/plugins/cors/ -> canonical https://apisix.apache.org/docs/apisix/plugins/cors/ - /zh/docs/apisix/3.16/plugins/cors/ -> canonical https://apisix.apache.org/zh/docs/apisix/plugins/cors/ - /docs/apisix/plugins/cors/ (latest) -> canonical https://docs.api7.ai/hub/cors (unchanged) - exactly one canonical link per page; og:url matches --- doc/src/theme/LayoutHead/index.tsx | 50 ++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 doc/src/theme/LayoutHead/index.tsx diff --git a/doc/src/theme/LayoutHead/index.tsx b/doc/src/theme/LayoutHead/index.tsx new file mode 100644 index 0000000000000..e2e5300a38d47 --- /dev/null +++ b/doc/src/theme/LayoutHead/index.tsx @@ -0,0 +1,50 @@ +/* eslint-disable import/no-extraneous-dependencies, import/no-unresolved */ +import type { FC } from 'react'; +import React from 'react'; +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 useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { useLocation } from '@docusaurus/router'; + +/** + * Matches the version segment of versioned doc URLs, e.g. + * /docs/apisix/3.10/plugins/cors/ -> 3.10 + * /docs/ingress-controller/2.0.0/... -> 2.0.0 + * /docs/docker/apisix-2.10.0/... -> apisix-2.10.0 + * /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)(\/.+)$/; + +/** + * Versioned doc pages (/docs///) 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: + * last wins): + * 1. default self-canonical (original LayoutHead) + * 2. this wrapper's latest-URL canonical (versioned pages only) + * 3. canonical embedded in the doc markdown itself, if any + */ +const LayoutHead: FC<{ [key: string]: unknown }> = (props) => { + const { siteConfig: { url: siteUrl } } = useDocusaurusContext(); + const { pathname } = useLocation(); + const match = pathname.match(versionedDocPath); + const latestUrl = match ? `${siteUrl}${match[1].replace(/\/$/, '')}${match[2]}` : null; + + return ( + <> + + {latestUrl && ( + + + + + )} + + ); +}; + +export default LayoutHead; From a6c1897ff0e0c9eb0a33ae8b9406f1cc1f40567a Mon Sep 17 00:00:00 2001 From: Ming Wen Date: Mon, 13 Jul 2026 11:59:13 +0800 Subject: [PATCH 2/2] ci: assert versioned-docs canonical contract after build Fails the deploy (before publishing) if a future Docusaurus or theme change silently breaks the one-canonical rule, the versioned->latest re-pointing, or the markdown-embedded canonical override. --- .github/workflows/deploy.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ba4e2b39827d1..b3a1f20b5dcbb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -134,6 +134,17 @@ jobs: retention-days: 7 if-no-files-found: warn + # 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, and canonicals + # embedded in the doc markdown itself must still win. + - name: Assert canonical contract + run: | + f=$(ls website/build/docs/apisix/3.*/plugins/cors/index.html | head -1) + 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 'rel="canonical" href="https://docs.api7.ai/hub/cors"' website/build/docs/apisix/plugins/cors/index.html + - name: Update sitemap.xml run: | yarn update-sitemap && git status