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

moonming 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 ed400cdc90c feat(docs): serve the latest-version docs from the static 
build (wave 3) (#2078)
ed400cdc90c is described below

commit ed400cdc90c7decaca0f73991196e8677e603757
Author: Ming Wen <[email protected]>
AuthorDate: Tue Jul 28 10:21:12 2026 +0800

    feat(docs): serve the latest-version docs from the static build (wave 3) 
(#2078)
---
 .github/workflows/deploy.yml                   | 159 +++++++++++++++++-
 doc/docusaurus.config.js                       |  38 +++++
 next/.gitignore                                |   1 +
 next/scripts/generate-md-twins.mjs             | 215 +++++++++++++++++++++++++
 next/scripts/sync-content.mjs                  |  50 +++++-
 next/src/components/Header.astro               |  16 +-
 next/src/components/SidebarNodes.astro         |  32 ++++
 next/src/layouts/Base.astro                    |  16 +-
 next/src/layouts/DocPage.astro                 |  54 +++----
 next/src/lib/content.ts                        |  50 +++++-
 next/src/pages/docs/[project]/[...id].astro    |   8 +-
 next/src/pages/docs/apisix/[...id].astro       |   6 +-
 next/src/pages/zh/docs/[project]/[...id].astro |   8 +-
 next/src/pages/zh/docs/apisix/[...id].astro    |   6 +-
 next/src/styles/global.css                     |  21 +++
 15 files changed, 630 insertions(+), 50 deletions(-)

diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 02559c96675..d0092e2fde1 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -165,10 +165,76 @@ jobs:
         working-directory: next
         run: |
           npm ci
+          # Project docs live in the upstream Apache repos. Clone the SAME ref
+          # Docusaurus does (scripts/sync-docs.js): the newest *release* 
branch,
+          # never the default branch — master is what production publishes at
+          # /docs/<project>/next/, and shipping it at the version-less URLs
+          # would document unreleased behaviour as if it were the current
+          # release. Version lists come from config/, so bumping a release
+          # there moves both builds together.
+          # Sparse, depth-1 clones of just docs/ keep this to seconds each; the
+          # sync script skips any repo that is absent, so a fetch failure
+          # degrades to "no pages for that project" — which the parity gate
+          # below then catches.
+          mkdir -p .sync
+          # config/docs.js is the same source scripts/sync-docs.js reads, and
+          # the ref shape matches it too: ingress-controller tags releases as
+          # v<version>, everything else branches them as release/<version>.
+          # Resolve each project's newest release ref the same way
+          # scripts/sync-docs.js does: apisix takes the newest series from
+          # config/apisix-versions.js; every sub-project has its release refs
+          # DETECTED from the remote (ingress-controller tags them v<ver>,
+          # the rest branch them release/<ver>) and keeps the newest. A
+          # sub-project with no release refs falls back to its default branch,
+          # which is what Docusaurus publishes for it today.
+          # Version ordering goes through semver (the same library and compare
+          # that sync-docs.js uses) — `sort -V` picks the wrong ref here, e.g.
+          # ingress-controller v1.8.4 over v2.1.0.
+          pick_ref() {  # $1 = repo, $2/$3 = ref globs, $4 = sed expr 
stripping the prefix
+            git ls-remote --refs "https://github.com/apache/$1.git"; "$2" "$3" 
2>/dev/null \
+              | sed "$4" \
+              | node -e "
+                  const semver = require('semver');
+                  const all = require('fs').readFileSync(0,'utf8').split('\n')
+                    
.map(s=>s.trim()).filter(s=>semver.valid(semver.coerce(s)));
+                  
all.sort((a,b)=>semver.compare(semver.coerce(a),semver.coerce(b)));
+                  if (all.length) console.log(all[all.length-1]);
+                "
+          }
+          APISIX_SERIES=$(node -e "const 
v=require('$GITHUB_WORKSPACE/config/apisix-versions.js').versions;console.log(v[v.length-1])")
+          for repo in apisix apisix-ingress-controller apisix-helm-chart \
+                      apisix-docker apisix-java-plugin-runner \
+                      apisix-go-plugin-runner apisix-python-plugin-runner; do
+            url="https://github.com/apache/$repo.git";
+            case "$repo" in
+              apisix) ref="release/$APISIX_SERIES" ;;
+              apisix-ingress-controller)
+                # Releases are v<ver> — branches today, historically also tags.
+                v=$(pick_ref "$repo" 'refs/heads/v*' 'refs/tags/v*' 
's|.*refs/[a-z]*/v||')
+                ref=${v:+v$v} ;;
+              *)
+                v=$(pick_ref "$repo" 'refs/heads/release/*' 
'refs/tags/release/*' 's|.*refs/[a-z]*/release/||')
+                ref=${v:+release/$v} ;;
+            esac
+            if [ -n "$ref" ]; then
+              echo "$repo -> $ref"
+              git clone --depth 1 --filter=blob:none --sparse -q -b "$ref" 
"$url" ".sync/$repo" \
+                || echo "::warning::clone of apache/$repo@$ref failed"
+            else
+              echo "$repo -> (default branch: no release refs)"
+              git clone --depth 1 --filter=blob:none --sparse -q "$url" 
".sync/$repo" \
+                || echo "::warning::clone of apache/$repo failed"
+            fi
+            [ -d ".sync/$repo" ] && (cd ".sync/$repo" && git sparse-checkout 
set docs) || true
+          done
           # Blog/learning-center/articles markdown is synced from this very
-          # checkout (WEBSITE_REPO); project docs stay un-synced until wave 3.
+          # checkout (WEBSITE_REPO); project docs come from .sync/ above.
           WEBSITE_REPO="$GITHUB_WORKSPACE" node scripts/sync-content.mjs
           npx astro build
+          # Agent-readable surfaces: a Markdown twin beside every content page
+          # (<page>/index.md) plus /llms.txt indexing them. Agents and crawlers
+          # get clean prose instead of parsing markup.
+          node scripts/generate-md-twins.mjs
 
       - name: Assert URL parity for the wave-2 subtrees
         run: |
@@ -200,6 +266,28 @@ jobs:
               echo "NON-HTML files not in the Astro tree and not in the 
feed-carry list:"; echo "$unhandled"; fail=1
             fi
           done
+          # Latest-version docs: Docusaurus no longer builds them (see
+          # onlyIncludeVersions in doc/docusaurus.config.js), so the reference
+          # is the LIVE SITE. Every version-less docs URL currently published
+          # must exist in the Astro build, or this deploy would 404 a page that
+          # works today. Versioned dirs are excluded — they stay Docusaurus by
+          # design — as are docs/*/tags/ index pages, which Docusaurus emits
+          # empty (one self-link, absent from the sitemap, linked from no doc
+          # page) and which the per-page swap leaves in place.
+          git fetch --depth 1 origin asf-site
+          for docs_root in docs zh/docs; do
+            missing=$(git ls-tree -r FETCH_HEAD --name-only -- "$docs_root" \
+              | grep '/index\.html$' \
+              | sed "s|^$docs_root/||" \
+              | grep -vE '(^|/)([0-9]+\.[0-9]+|next|v[0-9][^/]*)/' \
+              | grep -vE '(^|/)tags/index\.html$' \
+              | sort | while read -r f; do
+                [ -f "$ROOT/next/dist/$docs_root/$f" ] || echo "$docs_root/$f"
+              done)
+            if [ -n "$missing" ]; then
+              echo "Published docs URLs missing from the Astro build:"; echo 
"$missing"; fail=1
+            fi
+          done
           exit $fail
 
       - name: Carry the Docusaurus RSS/atom feeds into the Astro tree
@@ -230,6 +318,69 @@ jobs:
             mkdir -p "website/build/$(dirname "$path")"
             cp -R "next/dist/$path" "website/build/$path"
           done
+          # Latest-version docs come from Astro; every versioned directory
+          # (3.16/, next/, v1.6/, …) stays exactly as Docusaurus built it.
+          # Replacement is per-page, not per-subtree: under docs/<project>/ the
+          # version dirs and the latest-version pages sit side by side, so a
+          # subtree swap would delete the archive. Only paths the Astro build
+          # actually produced are touched.
+          for locale_prefix in "" "zh/"; do
+            src="next/dist/${locale_prefix}docs"
+            [ -d "$src" ] || continue
+            (cd "$src" && find . -name index.html) | sed 's|^\./||' | while 
read -r rel; do
+              case "$rel" in
+                # Never touch an archived version directory.
+                */[0-9].[0-9]*/*|*/next/*|*/v[0-9]*/*) continue ;;
+              esac
+              dest="website/build/${locale_prefix}docs/$rel"
+              mkdir -p "$(dirname "$dest")"
+              cp "$src/$rel" "$dest"
+              # Ship the Markdown twin next to the page it mirrors.
+              twin="${rel%index.html}index.md"
+              [ -f "$src/$twin" ] && cp "$src/$twin" 
"website/build/${locale_prefix}docs/$twin"
+            done
+          done
+          # The version archives must survive untouched.
+          test -f website/build/docs/apisix/3.16/plugins/cors/index.html
+          test -f website/build/docs/apisix/next/plugins/cors/index.html
+          grep -q 'docusaurus' 
website/build/docs/apisix/3.16/plugins/cors/index.html
+          # …and the latest-version pages must now be the Astro build.
+          grep -q '/_astro/' website/build/docs/apisix/plugins/cors/index.html
+          grep -q '/_astro/' 
website/build/zh/docs/apisix/plugins/cors/index.html
+          # The archives remain a Docusaurus SPA whose version dropdown and
+          # "older version" banner point at the version-less URLs. Docusaurus
+          # is configured (doc/docusaurus.config.js, onlyIncludeVersions) to
+          # stop building the newest version, so those URLs are no longer in
+          # its route manifest and a click leaves the SPA for the Astro page.
+          # Assert that manifest property directly — patching the serialized
+          # HTML would not help, because React Router intercepts clicks after
+          # hydration regardless of the anchor's target.
+          if ! node -e '
+            const fs = require("fs"), path = require("path");
+            const dir = "website/build/assets/js";
+            const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) 
=> /^main\..*\.js$/.test(f)) : [];
+            if (!files.length) { console.error("no main bundle found"); 
process.exit(1); }
+            const bundle = fs.readFileSync(path.join(dir, files[0]), "utf8");
+            const claimed = ["/docs/apisix/plugins/cors/", 
"/docs/apisix/getting-started/"]
+              .filter((u) => bundle.includes(`"${u}"`));
+            if (claimed.length) {
+              console.error("Docusaurus still owns migrated routes:", 
claimed.join(", "));
+              process.exit(1);
+            }
+            console.log("route manifest: no version-less docs routes — archive 
links hard-navigate");
+          '; then
+            echo "The Docusaurus SPA still routes the migrated docs URLs 
client-side."
+            exit 1
+          fi
+          # The cross-site canonical contract asserted earlier in this job runs
+          # against the Docusaurus build; re-assert it on what actually ships.
+          for f in website/build/docs/apisix/plugins/cors/index.html \
+                   website/build/zh/docs/apisix/plugins/cors/index.html; do
+            test "$(grep -o 'rel="canonical"' "$f" | wc -l)" -eq 1
+            grep -q 'rel="canonical" href="https://docs.api7.ai/hub/cors";' "$f"
+          done
+          grep -q 'property="og:url" 
content="https://apisix.apache.org/docs/apisix/plugins/cors/";' \
+            website/build/docs/apisix/plugins/cors/index.html
           # 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
@@ -255,6 +406,12 @@ jobs:
           mkdir -p website/build/img
           cp -R next/dist/img/. website/build/img/
           test -f website/build/img/infographs/performance.svg
+          # The agent index lives at the site root (the wave-1/2 subtrees carry
+          # their own Markdown twins along with the cp -R above).
+          cp next/dist/llms.txt website/build/llms.txt
+          test -f website/build/blog/index.md
+          test -f website/build/docs/apisix/plugins/cors/index.md
+          grep -q 'index.md' website/build/llms.txt
           test -f website/build/img/integrations/icon-prometheus.svg
           test -f website/build/img/architecture.svg
 
diff --git a/doc/docusaurus.config.js b/doc/docusaurus.config.js
index bbf0fb1f887..b8a6e519e40 100644
--- a/doc/docusaurus.config.js
+++ b/doc/docusaurus.config.js
@@ -1,5 +1,36 @@
+const fs = require('fs');
+const path = require('path');
 const { ssrTemplate } = require('../config/ssrTemplate');
 
+/**
+ * Give the newest release an explicit versioned path so it stops owning the
+ * version-less URLs (/docs/apisix/plugins/cors/), which the Astro build now
+ * produces. Every version keeps building — this only moves the newest one from
+ * "" to "<version>/".
+ *
+ * Docusaurus assigns the empty path to whichever version is `lastVersion`, and
+ * defaults that to the first non-`current` entry of versions.json. Dropping 
the
+ * newest release from the build instead would just promote the next one into
+ * the same empty path, so the URLs would still be owned by Docusaurus — with
+ * older content — and the archive would lose a version.
+ *
+ * Returns undefined when the versions file is absent (nothing synced yet),
+ * leaving the default behaviour untouched.
+ */
+const pinNewestVersionPath = (projectName) => {
+  const file = path.join(__dirname, `docs-${projectName}_versions.json`);
+  if (!fs.existsSync(file)) return undefined;
+  try {
+    const all = JSON.parse(fs.readFileSync(file, 'utf8'));
+    const newest = Array.isArray(all) ? all.find((v) => v !== 'current') : 
undefined;
+    // path: '<newest>' keeps it reachable at its own URL; banner 'none' avoids
+    // telling readers the current release is an unmaintained old version.
+    return newest ? { [newest]: { path: newest, banner: 'none' } } : undefined;
+  } catch {
+    return undefined;
+  }
+};
+
 const getEditUrl = (props) => {
   const {
     projectName, version, locale, docPath, defaultBranch = 'master',
@@ -79,6 +110,7 @@ module.exports = {
       '@docusaurus/plugin-content-docs',
       {
         id: 'docs-apisix',
+        versions: pinNewestVersionPath('apisix'),
         path: 'docs/apisix',
         showLastUpdateAuthor: true,
         showLastUpdateTime: true,
@@ -96,6 +128,7 @@ module.exports = {
       '@docusaurus/plugin-content-docs',
       {
         id: 'docs-apisix-ingress-controller',
+        versions: pinNewestVersionPath('apisix-ingress-controller'),
         path: 'docs/apisix-ingress-controller',
         showLastUpdateAuthor: true,
         showLastUpdateTime: true,
@@ -115,6 +148,7 @@ module.exports = {
       '@docusaurus/plugin-content-docs',
       {
         id: 'docs-apisix-helm-chart',
+        versions: pinNewestVersionPath('apisix-helm-chart'),
         path: 'docs/apisix-helm-chart',
         showLastUpdateAuthor: true,
         showLastUpdateTime: true,
@@ -132,6 +166,7 @@ module.exports = {
       '@docusaurus/plugin-content-docs',
       {
         id: 'docs-apisix-docker',
+        versions: pinNewestVersionPath('apisix-docker'),
         path: 'docs/apisix-docker',
         showLastUpdateAuthor: true,
         showLastUpdateTime: true,
@@ -149,6 +184,7 @@ module.exports = {
       '@docusaurus/plugin-content-docs',
       {
         id: 'docs-apisix-java-plugin-runner',
+        versions: pinNewestVersionPath('apisix-java-plugin-runner'),
         path: 'docs/apisix-java-plugin-runner',
         showLastUpdateAuthor: true,
         showLastUpdateTime: true,
@@ -169,6 +205,7 @@ module.exports = {
       '@docusaurus/plugin-content-docs',
       {
         id: 'docs-apisix-go-plugin-runner',
+        versions: pinNewestVersionPath('apisix-go-plugin-runner'),
         path: 'docs/apisix-go-plugin-runner',
         showLastUpdateAuthor: true,
         showLastUpdateTime: true,
@@ -188,6 +225,7 @@ module.exports = {
       '@docusaurus/plugin-content-docs',
       {
         id: 'docs-apisix-python-plugin-runner',
+        versions: pinNewestVersionPath('apisix-python-plugin-runner'),
         path: 'docs/apisix-python-plugin-runner',
         showLastUpdateAuthor: true,
         showLastUpdateTime: true,
diff --git a/next/.gitignore b/next/.gitignore
index eb5a90df25f..d2b0fe9f85f 100644
--- a/next/.gitignore
+++ b/next/.gitignore
@@ -3,3 +3,4 @@ dist/
 .sync/
 content/
 .astro/
+.sync/
diff --git a/next/scripts/generate-md-twins.mjs 
b/next/scripts/generate-md-twins.mjs
new file mode 100644
index 00000000000..3d686917866
--- /dev/null
+++ b/next/scripts/generate-md-twins.mjs
@@ -0,0 +1,215 @@
+/**
+ * Post-build agent-readable surfaces.
+ *
+ * For every content page the build produced, emit a Markdown twin next to the
+ * HTML (`<page>/index.md`) and index them all in `/llms.txt`. Agents that read
+ * docs — and the crawlers behind them — get clean prose instead of parsing a
+ * page of markup.
+ *
+ * The twin is the *synced source* markdown, with frontmatter replaced by a
+ * title heading and a link back to the canonical HTML. Source markdown lives
+ * in content/ (written by sync-content.mjs), so this runs after `astro build`
+ * and needs no MDX evaluation.
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
+const args = process.argv.slice(2);
+const distFlag = args.indexOf('--dist');
+const dist = distFlag !== -1 ? path.resolve(args[distFlag + 1]) : 
path.join(root, 'dist');
+const content = path.join(root, 'content');
+const SITE = 'https://apisix.apache.org';
+
+/**
+ * content/ subdir -> every URL prefix its pages are published under. Several
+ * collections have one source that renders at both locales (the zh site falls
+ * back to the English text where no translation exists), so a source file can
+ * legitimately map to two URLs.
+ */
+const COLLECTIONS = [
+  ['blog-en', ['/blog']],
+  ['blog-zh', ['/zh/blog']],
+  ['learning-center', ['/learning-center', '/zh/learning-center']],
+  ['articles', ['/articles', '/zh/articles']],
+  ['docs-general', ['/docs/general', '/zh/docs/general']],
+  // The zh APISIX docs fall back to the English source where no translation
+  // exists, so the English collection is also offered the zh prefix. The zh
+  // collection is processed after and wins for pages that are translated —
+  // both the file and, because the index is keyed by URL, the index entry.
+  ['docs-apisix-en', ['/docs/apisix', '/zh/docs/apisix']],
+  ['docs-apisix-zh', ['/zh/docs/apisix']],
+];
+for (const p of ['ingress-controller', 'helm-chart', 'docker', 
'java-plugin-runner', 'go-plugin-runner', 'python-plugin-runner']) {
+  COLLECTIONS.push([`docs-${p}-en`, [`/docs/${p}`, `/zh/docs/${p}`]]);
+  COLLECTIONS.push([`docs-${p}-zh`, [`/zh/docs/${p}`]]);
+}
+
+const walk = (d) => (fs.existsSync(d)
+  ? fs.readdirSync(d, { withFileTypes: true }).flatMap((e) => {
+    const p = path.join(d, e.name);
+    return e.isDirectory() ? walk(p) : (e.name.endsWith('.md') ? [p] : []);
+  })
+  : []);
+
+/** Strip frontmatter, returning it parsed shallowly plus the body. */
+function splitFrontmatter(src) {
+  const m = src.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
+  if (!m) return { fm: {}, body: src };
+  const fm = {};
+  for (const line of m[1].split('\n')) {
+    const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
+    if (kv) fm[kv[1]] = kv[2].replace(/^["']|["']$/g, '').trim();
+  }
+  return { fm, body: src.slice(m[0].length) };
+}
+
+/**
+ * Map a source file to the URL its page was published at, by finding the built
+ * HTML. Slug/id frontmatter overrides and date-based blog paths mean the file
+ * path alone is not authoritative, so candidates are checked against dist/.
+ */
+function resolveUrl(file, collectionDir, urlPrefix) {
+  const rel = path.relative(path.join(content, collectionDir), file)
+    .replace(/\.md$/, '')
+    .split(path.sep).join('/');
+  const { fm } = splitFrontmatter(fs.readFileSync(file, 'utf8'));
+  const candidates = [];
+
+  if (collectionDir.startsWith('blog-')) {
+    // Blog: /blog/YYYY/MM/DD/<name>/, or a slug override replacing the path.
+    const m = rel.match(/^(\d{4})\/(\d{2})\/(\d{2})\/(.+)$/);
+    const slug = (fm.slug || '').replace(/^\/+/, '');
+    if (slug && slug.includes('/')) candidates.push(`${urlPrefix}/${slug}/`);
+    if (m) candidates.push(`${urlPrefix}/${m[1]}/${m[2]}/${m[3]}/${slug || 
m[4]}/`);
+  } else {
+    const id = (fm.slug || fm.id || '').replace(/^\/+/, '');
+    const dir = rel.includes('/') ? `${rel.slice(0, rel.lastIndexOf('/'))}/` : 
'';
+    if (id) candidates.push(`${urlPrefix}/${id.includes('/') ? id : dir + 
id}/`);
+    candidates.push(`${urlPrefix}/${rel}/`);
+  }
+
+  for (const url of candidates) {
+    if (fs.existsSync(path.join(dist, url, 'index.html'))) return url;
+  }
+  return null;
+}
+
+// Keyed by URL: a page can be reached by more than one collection (the zh docs
+// fall back to the English source), and the last write wins for the file, so
+// the index must record the same winner rather than one entry per attempt.
+const written = new Map();
+let skipped = 0;
+
+for (const [dir, urlPrefixes] of COLLECTIONS) {
+  for (const file of walk(path.join(content, dir))) {
+    const src = fs.readFileSync(file, 'utf8');
+    const { fm, body } = splitFrontmatter(src);
+    const title = fm.title || path.basename(file, '.md');
+    let matched = false;
+
+    for (const urlPrefix of urlPrefixes) {
+      const url = resolveUrl(file, dir, urlPrefix);
+      if (!url) continue;
+      matched = true;
+      // Upstream files open with the ASF licence header as an HTML comment;
+      // it is legal boilerplate, not content, and only wastes an agent's
+      // context window. The licence still ships with the source repo.
+      const prose = body.replace(/^\s*<!--[\s\S]*?-->\s*/, '').trim();
+      const doc = [
+        `# ${title}`,
+        '',
+        fm.description ? `> ${fm.description}` : null,
+        fm.description ? '' : null,
+        `Source: ${SITE}${url}`,
+        '',
+        prose,
+        '',
+      ].filter((l) => l !== null).join('\n');
+      fs.writeFileSync(path.join(dist, url, 'index.md'), doc);
+      written.set(url, { url, title, description: fm.description || '' });
+    }
+    if (!matched) skipped += 1;
+  }
+}
+
+// /llms.txt — the index agents fetch first.
+const pages = [...written.values()].sort((a, b) => a.url.localeCompare(b.url));
+const section = (label, items) => (items.length
+  ? [`## ${label}`, '', ...items.map((p) => `- 
[${p.title}](${SITE}${p.url}index.md)${p.description ? ` — ${p.description}` : 
''}`), '']
+  : []);
+
+const isZh = (p) => p.url.startsWith('/zh/');
+const en = pages.filter((p) => !isZh(p));
+const zh = pages.filter(isZh);
+// Match on the section prefix, not anywhere in the URL: /docs/general/blog/ is
+// a docs page, and a substring test would file it under both docs and blog.
+const group = (items, frag) => {
+  const prefix = frag === '/docs/' ? /^(\/zh)?\/docs\// : new 
RegExp(`^(\\/zh)?${frag.replace(/\//g, '\\/')}`);
+  return items.filter((p) => prefix.test(p.url));
+};
+
+const llms = [
+  '# Apache APISIX',
+  '',
+  '> Apache APISIX is a dynamic, real-time, high-performance API gateway and 
AI gateway.',
+  '',
+  'Every page below is available as Markdown — append `index.md` to any page 
URL.',
+  '',
+  ...section('Documentation', group(en, '/docs/')),
+  ...section('Learning center', group(en, '/learning-center/')),
+  ...section('Blog', group(en, '/blog/')),
+  ...section('Articles', group(en, '/articles/')),
+  ...section('中文文档', group(zh, '/docs/')),
+  ...section('中文学习中心', group(zh, '/learning-center/')),
+  ...section('中文博客', group(zh, '/blog/')),
+  ...section('中文技术文章', group(zh, '/articles/')),
+].join('\n');
+
+fs.writeFileSync(path.join(dist, 'llms.txt'), `${llms}\n`);
+
+console.log(`markdown twins: ${written.size} written, ${skipped} source files 
had no built page`);
+console.log(`llms.txt: ${en.length} en + ${zh.length} zh pages indexed`);
+
+// Every content page must have a twin, and every twin must be indexed —
+// otherwise the "append index.md to any page URL" promise is a lie for some
+// subset of pages. Fail the build rather than shipping a partial surface.
+const llmsText = fs.readFileSync(path.join(dist, 'llms.txt'), 'utf8');
+const indexedUrls = 
[...llmsText.matchAll(/\]\(https:\/\/apisix\.apache\.org([^)]*?)index\.md\)/g)].map((m)
 => m[1]);
+const indexed = new Set(indexedUrls);
+if (indexedUrls.length !== indexed.size) {
+  const seen = new Set();
+  const dupes = [...new Set(indexedUrls.filter((u) => (seen.has(u) ? true : 
(seen.add(u), false))))];
+  console.error(`\n/llms.txt lists ${indexedUrls.length - indexed.size} 
duplicate entries:`);
+  for (const u of dupes.slice(0, 10)) console.error(`  ${u}`);
+  process.exit(1);
+}
+const CONTENT_PREFIXES = ['/blog/', '/learning-center/', '/articles/', 
'/docs/',
+  '/zh/blog/', '/zh/learning-center/', '/zh/articles/', '/zh/docs/'];
+// Section landing pages (/blog/, /docs/, …) are component-rendered indexes
+// with no markdown source, as are listing, tag, and archive pages.
+const SECTION_INDEX = new Set([...CONTENT_PREFIXES,
+  '/docs/general/events/', '/zh/docs/general/events/']);
+const isContentPage = (url) => CONTENT_PREFIXES.some((p) => url.startsWith(p))
+  && !SECTION_INDEX.has(url)
+  && !/\/(page|tags|archive)\//.test(url) && !/\/(tags|archive)\/$/.test(url);
+
+const missing = [];
+(function scan(dir) {
+  for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+    const p = path.join(dir, e.name);
+    if (e.isDirectory()) { scan(p); continue; }
+    if (e.name !== 'index.html') continue;
+    const url = `/${path.relative(dist, dir).split(path.sep).join('/')}/`;
+    if (isContentPage(url) && !indexed.has(url)) missing.push(url);
+  }
+})(dist);
+
+if (missing.length) {
+  console.error(`\n${missing.length} content pages have no Markdown twin:`);
+  for (const u of missing.slice(0, 20)) console.error(`  ${u}`);
+  if (missing.length > 20) console.error(`  … and ${missing.length - 20} 
more`);
+  process.exit(1);
+}
+console.log('parity: every content page has a twin, and every twin is 
indexed');
diff --git a/next/scripts/sync-content.mjs b/next/scripts/sync-content.mjs
index d14b7dcf354..baa7beecbdf 100644
--- a/next/scripts/sync-content.mjs
+++ b/next/scripts/sync-content.mjs
@@ -13,6 +13,7 @@
  */
 import fs from 'node:fs';
 import path from 'node:path';
+import { execSync } from 'node:child_process';
 import { fileURLToPath } from 'node:url';
 
 const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
@@ -42,7 +43,9 @@ function flattenTabs(src) {
     .replace(/<\/TabItem>/g, '');
 }
 
-function transform(src, { docBase, blogBase, ghProject, ghRef = 'master' } = 
{}) {
+function transform(src, {
+  docBase, blogBase, ghProject, ghRef = 'master', relPath = '',
+} = {}) {
   let out = src;
   let canonicalUrl = null;
   // MDX imports cannot exist in plain markdown.
@@ -86,11 +89,27 @@ function transform(src, { docBase, blogBase, ghProject, 
ghRef = 'master' } = {})
       
`(https://raw.githubusercontent.com/apache/${ghProject}/${ghRef}/docs/assets/$2$3)`);
   }
   if (docBase) {
-    // Relative .md links -> absolute pretty URLs (mirrors current site 
behavior).
-    out = out.replace(/\]\((\.{1,2}\/)?([\w\-./]+)\.md(#[^)]*)?\)/g, (_m, 
_dot, p, hash) => {
-      const clean = p.replace(/^\.\//, '');
-      return `](${docBase}/${clean}/${hash || ''})`;
-    });
+    // Relative .md links -> absolute pretty URLs (mirrors current site
+    // behavior). Docusaurus resolves these against the linking document's own
+    // directory, so `../foo.md` from plugins/bar.md is /docs/<project>/foo/,
+    // while `foo.md` from the same file is /docs/<project>/plugins/foo/.
+    // A stray slash before the anchor (`../x.md/#y`) appears upstream too.
+    const dir = path.posix.dirname(relPath.split(path.sep).join('/'));
+    out = out.replace(
+      /\]\((\.{0,2}\/?[\w\-./]+)\.md\/?(#[^)]*)?\)/g,
+      (_m, p, hash) => {
+        const joined = p.startsWith('/')
+          ? p.replace(/^\//, '')
+          : path.posix.join(dir === '.' ? '' : dir, p);
+        // Some upstream links climb past the docs root and re-enter it, e.g.
+        // `../../../en/latest/plugins/x.md` — Docusaurus normalises that back
+        // to the project root, so drop any leading ../ and <locale>/latest/.
+        const clean = path.posix.normalize(joined)
+          .replace(/^(\.\.\/)+/, '')
+          .replace(/^(?:en|zh)\/latest\//, '');
+        return `](${docBase}/${clean}/${hash || ''})`;
+      },
+    );
   }
   if (blogBase) {
     // Blog cross-references: Docusaurus resolves `./YYYY-MM-DD-name.md` by
@@ -107,7 +126,7 @@ function copyTree(srcDir, outDir, opts = {}, filter = () => 
true) {
     const rel = path.relative(srcDir, f);
     const dest = path.join(outDir, rel);
     fs.mkdirSync(path.dirname(dest), { recursive: true });
-    fs.writeFileSync(dest, transform(fs.readFileSync(f, 'utf8'), opts));
+    fs.writeFileSync(dest, transform(fs.readFileSync(f, 'utf8'), { ...opts, 
relPath: rel }));
     stats.copied += 1;
   }
 }
@@ -150,4 +169,21 @@ for (const { key, repo } of PROJECTS) {
   }
 }
 
+// Record the ref each project was synced from, so "Edit this page" links point
+// at the revision the reader is actually looking at rather than a guessed
+// branch. Sub-projects are cloned at their newest release tag/branch, and the
+// default branch differs across repos (some `master`, some `main`).
+const refs = {};
+for (const { key, repo } of PROJECTS) {
+  const checkout = path.join(root, '.sync', repo);
+  if (!fs.existsSync(checkout)) continue;
+  try {
+    const head = execSync('git symbolic-ref --short -q HEAD || git describe 
--tags --exact-match 2>/dev/null || git rev-parse HEAD',
+      { cwd: checkout, encoding: 'utf8', shell: '/bin/bash' }).trim();
+    if (head) refs[key] = head;
+  } catch { /* leave unset; the page falls back to the default branch */ }
+}
+fs.writeFileSync(path.join(OUT, 'doc-refs.json'), `${JSON.stringify(refs, 
null, 2)}\n`);
+
 console.log('sync-content done:', JSON.stringify(stats));
+console.log('doc refs:', JSON.stringify(refs));
diff --git a/next/src/components/Header.astro b/next/src/components/Header.astro
index 30a0e51f6f8..225f574ba0c 100644
--- a/next/src/components/Header.astro
+++ b/next/src/components/Header.astro
@@ -1,8 +1,8 @@
 ---
 import { NAV, LOGO, localePrefix, type Locale } from '../lib/site';
 
-interface Props { locale: Locale; path: string }
-const { locale, path } = Astro.props;
+interface Props { locale: Locale; path: string; search?: boolean }
+const { locale, path, search = false } = Astro.props;
 const prefix = localePrefix(locale);
 const label = (item: { label: string; labelZh?: string }) =>
   locale === 'zh' && item.labelZh ? item.labelZh : item.label;
@@ -31,6 +31,18 @@ const switchUrl = locale === 'zh' ? path : `/zh${path}`;
       ))}
       <a href="https://github.com/apache/apisix"; title="GitHub">GitHub</a>
       <a href={switchUrl} title={locale === 'zh' ? 'English' : '简体中文'}>{locale 
=== 'zh' ? 'EN' : '中'}</a>
+      {search && <div id="docsearch" class="docsearch-slot" />}
+      {search && (
+        <script
+          src="https://cdn.jsdelivr.net/npm/@docsearch/js@3";
+          data-appid="38VC84A2WJ"
+          data-apikey="73248b6e5908d49bb7986c4aef5fd30d"
+          data-indexname="apache_apisix"
+          is:inline
+          defer
+          
onload="docsearch({container:'#docsearch',appId:this.dataset.appid,apiKey:this.dataset.apikey,indexName:this.dataset.indexname})"
+        />
+      )}
     </nav>
     <details class="mobile-toggle">
       <summary aria-label="Menu">☰</summary>
diff --git a/next/src/components/SidebarNodes.astro 
b/next/src/components/SidebarNodes.astro
new file mode 100644
index 00000000000..3ac471d0490
--- /dev/null
+++ b/next/src/components/SidebarNodes.astro
@@ -0,0 +1,32 @@
+---
+/**
+ * Renders a docs sidebar to any depth. The upstream config.json nests four
+ * levels in places (APISIX: Plugins > Observability > Loggers > 19 docs), so a
+ * fixed-depth renderer silently drops whole categories.
+ */
+import type { SidebarNode } from '../lib/content';
+
+interface Props {
+  nodes: SidebarNode[];
+  active?: string;
+  titleById?: Map<string, string>;
+  urlById: (id: string) => string;
+}
+const { nodes, active, titleById, urlById } = Astro.props;
+
+/** Keep anything that links somewhere or that still has renderable children. 
*/
+const renderable = (n: SidebarNode): boolean =>
+  Boolean(n.id) || (Array.isArray(n.items) && n.items.some(renderable));
+---
+<ul>
+  {nodes.filter(renderable).map((node) => (
+    <li class={node.items ? 'cat' : ''}>
+      {node.id
+        ? <a href={urlById(node.id)} class={node.id === active ? 'active' : 
''}>{titleById?.get(node.id) ?? node.id}</a>
+        : <span>{node.label}</span>}
+      {node.items && node.items.some(renderable) && (
+        <Astro.self nodes={node.items} active={active} titleById={titleById} 
urlById={urlById} />
+      )}
+    </li>
+  ))}
+</ul>
diff --git a/next/src/layouts/Base.astro b/next/src/layouts/Base.astro
index 82b5544f5d8..4210c998869 100644
--- a/next/src/layouts/Base.astro
+++ b/next/src/layouts/Base.astro
@@ -17,6 +17,8 @@ interface Props {
   /** Open Graph object type; article pages pass "article". */
   ogType?: 'website' | 'article';
   noindex?: boolean;
+  /** Load Algolia DocSearch (docs pages only — every other page stays 
zero-JS). */
+  search?: boolean;
   /** Cross-site canonical embedded in upstream docs (docs.api7.ai hub pages). 
*/
   canonicalOverride?: string;
   /** Set false when the title is already the complete tag (homepage). */
@@ -35,6 +37,7 @@ const {
   canonicalOverride,
   titleSuffix = true,
   ogType = 'website',
+  search = false,
 } = Astro.props;
 
 const enUrl = `${SITE}${path}`;
@@ -61,16 +64,25 @@ const allJsonLd = [...ORG_JSONLD, ...jsonLd];
   <meta property="og:type" content={ogType} />
   <meta property="og:title" content={fullTitle} />
   <meta property="og:description" content={desc} />
-  <meta property="og:url" content={canonical} />
+  {/* 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:site_name" content="Apache APISIX" />
   {image && <meta property="og:image" content={image} />}
   {image && <meta name="twitter:image" content={image} />}
   <meta name="twitter:card" content={image ? 'summary_large_image' : 
'summary'} />
   <meta name="twitter:site" content="@apacheapisix" />
   <script type="application/ld+json" set:html={JSON.stringify(allJsonLd)} />
+  {search && (
+    <>
+      <link rel="preconnect" href="https://38VC84A2WJ-dsn.algolia.net"; 
crossorigin />
+      <link rel="stylesheet" 
href="https://cdn.jsdelivr.net/npm/@docsearch/css@3"; />
+    </>
+  )}
 </head>
 <body>
-  <Header locale={locale} path={path} />
+  <Header locale={locale} path={path} search={search} />
   <main>
     <slot />
   </main>
diff --git a/next/src/layouts/DocPage.astro b/next/src/layouts/DocPage.astro
index 3d07ba5cf4a..aa9733694e4 100644
--- a/next/src/layouts/DocPage.astro
+++ b/next/src/layouts/DocPage.astro
@@ -1,5 +1,6 @@
 ---
 import Base from './Base.astro';
+import SidebarNodes from '../components/SidebarNodes.astro';
 import { SITE, type Locale } from '../lib/site';
 import type { DocEntry, SidebarNode } from '../lib/content';
 
@@ -12,10 +13,20 @@ interface Props {
   urlById?: (id: string) => string;
   editUrl?: string;
   versionLabel?: string;
+  /** Archived versions to offer alongside the current one, newest first. */
+  archivedVersions?: string[];
+  /** URL prefix the version dirs hang off, e.g. "/docs/apisix/". */
+  versionBase?: string;
+  /** Explicit link to the unreleased docs, for projects with no archives. */
+  nextUrl?: string;
   /** Sidebar id of the current page (path-derived); defaults to entry.id. */
   activeId?: string;
 }
-const { entry, locale, path, sidebar = [], titleById, urlById, editUrl, 
versionLabel } = Astro.props;
+const {
+  entry, locale, path, sidebar = [], titleById, urlById, editUrl, versionLabel,
+  archivedVersions = [], versionBase = '', nextUrl,
+} = Astro.props;
+const nextHref = nextUrl ?? (versionBase ? `${versionBase}next/` : undefined);
 const active = Astro.props.activeId ?? entry.id;
 const { Content } = entry.mod;
 
@@ -28,7 +39,6 @@ const jsonLd = [{
   mainEntityOfPage: `${SITE}${locale === 'zh' ? '/zh' : ''}${path}`,
 }];
 
-const renderable = (node: SidebarNode): boolean => !!node.id || !!(node.items 
&& node.items.length);
 ---
 <Base
   title={entry.title}
@@ -37,38 +47,26 @@ const renderable = (node: SidebarNode): boolean => 
!!node.id || !!(node.items &&
   path={path}
   jsonLd={jsonLd}
   canonicalOverride={entry.mod.frontmatter.canonical}
+  search
 >
   <div class="container docs-layout">
     {sidebar.length > 0 && (
       <nav class="docs-sidebar" aria-label="Docs sidebar">
-        {versionLabel && <p style="font-weight:700;padding:.2rem 
.5rem;margin:0">{versionLabel}</p>}
-        <ul>
-          {sidebar.filter(renderable).map((node) => (
-            <li class={node.items ? 'cat' : ''}>
-              {node.id
-                ? <a href={urlById!(node.id)} class={node.id === active ? 
'active' : ''}>{titleById?.get(node.id) ?? node.id}</a>
-                : <span>{node.label}</span>}
-              {node.items && (
+        {versionLabel && (
+          (archivedVersions.length > 0 || nextHref)
+            ? (
+              <details class="version-picker">
+                <summary>{versionLabel}</summary>
                 <ul>
-                  {node.items.filter(renderable).map((child) => (
-                    <li>
-                      {child.id
-                        ? <a href={urlById!(child.id)} class={child.id === 
active ? 'active' : ''}>{titleById?.get(child.id) ?? child.id}</a>
-                        : <span>{child.label}</span>}
-                      {child.items && (
-                        <ul>
-                          {child.items.filter((n) => n.id).map((g) => (
-                            <li><a href={urlById!(g.id!)} class={g.id === 
active ? 'active' : ''}>{titleById?.get(g.id!) ?? g.id}</a></li>
-                          ))}
-                        </ul>
-                      )}
-                    </li>
-                  ))}
+                  {versionBase && <li><a href={versionBase} 
aria-current="page">{versionLabel}</a></li>}
+                  {archivedVersions.map((v) => <li><a 
href={`${versionBase}${v}/`}>{v}</a></li>)}
+                  {nextHref && <li><a href={nextHref}>{locale === 'zh' ? '开发版 
(next)' : 'Next (unreleased)'}</a></li>}
                 </ul>
-              )}
-            </li>
-          ))}
-        </ul>
+              </details>
+            )
+            : <p style="font-weight:700;padding:.2rem 
.5rem;margin:0">{versionLabel}</p>
+        )}
+        <SidebarNodes nodes={sidebar} active={active} titleById={titleById} 
urlById={urlById!} />
       </nav>
     )}
     <article class="docs-content prose">
diff --git a/next/src/lib/content.ts b/next/src/lib/content.ts
index 28033b304be..14f477d0db1 100644
--- a/next/src/lib/content.ts
+++ b/next/src/lib/content.ts
@@ -95,6 +95,22 @@ const docsPythonEn = 
import.meta.glob('/content/docs-python-plugin-runner-en/**/
 
 const sidebarConfigs = import.meta.glob('/content/docs-*/config.json', { 
eager: true }) as Record<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>;
+
+/**
+ * "Edit this page" URL for an upstream project doc. Uses the ref the content
+ * was actually synced from — sub-projects are cloned at a release tag, and
+ * default branches differ (master vs main) — and `pathId`, the source-relative
+ * path, since a frontmatter slug can move the URL away from the filename.
+ */
+export function docEditUrl(project: string, repo: string, entry: DocEntry): 
string {
+  const ref = docRefs[project] ?? 'master';
+  return 
`https://github.com/apache/${repo}/edit/${ref}/docs/${entry.sourceLocale}/latest/${entry.pathId}.md`;
+}
+
 /** Sub-projects served under /docs/<key>/ via the generic route. */
 export const SUBPROJECTS: Record<string, { en: MdMap; zh?: MdMap; repo: string 
}> = {
   'ingress-controller': { en: docsIngressEn, zh: docsIngressZh, repo: 
'apisix-ingress-controller' },
@@ -214,6 +230,13 @@ export interface DocEntry {
   id: string; // URL id after frontmatter slug/id overrides (e.g. "FAQ", 
"plugins/limit-count")
   /** Pure path-derived id — the key sidebar config.json entries refer to. */
   pathId: string;
+  /**
+   * Locale of the file this page was actually rendered from. A zh page falls
+   * back to the English source when no translation exists, and an "Edit this
+   * page" link built from the rendered locale would then point at a file that
+   * does not exist upstream.
+   */
+  sourceLocale: Locale;
   url: string;
   title: string;
   description: string;
@@ -245,6 +268,8 @@ export function getGeneralDocs(locale: Locale): DocEntry[] {
       return {
         id,
         pathId: docId(p, 'docs-general'),
+        // docs/general lives in this repo and has no per-locale source split.
+        sourceLocale: 'en' as Locale,
         url: `${localePrefix(locale)}/docs/general/${id}/`,
         title: docTitle(mod, id),
         description: mod.frontmatter.description ?? excerpt(mod),
@@ -258,10 +283,12 @@ 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 effective = locale === 'zh' ? (zh.get(id) ?? mod) : mod;
+    const translated = locale === 'zh' ? zh.get(id) : undefined;
+    const effective = translated ?? mod;
     return {
       id,
       pathId: docId(p, 'docs-apisix-en'),
+      sourceLocale: translated ? 'zh' : 'en',
       url: `${localePrefix(locale)}/docs/apisix/${id}/`,
       title: docTitle(effective, id),
       description: effective.frontmatter.description ?? excerpt(effective),
@@ -280,10 +307,12 @@ export function getSubprojectDocs(project: string, 
locale: Locale): DocEntry[] {
     .filter(([p]) => !p.endsWith('config.json'))
     .map(([p, mod]) => {
       const id = docId(p, `${rootName}en`, mod.frontmatter);
-      const effective = locale === 'zh' ? (zhMap.get(id) ?? mod) : mod;
+      const translated = locale === 'zh' ? zhMap.get(id) : undefined;
+      const effective = translated ?? mod;
       return {
         id,
         pathId: docId(p, `${rootName}en`),
+        sourceLocale: (translated ? 'zh' : 'en') as Locale,
         url: `${localePrefix(locale)}/docs/${project}/${id}/`,
         title: docTitle(effective, id),
         description: effective.frontmatter.description ?? excerpt(effective),
@@ -304,6 +333,12 @@ export function getSubprojectSidebar(project: string): 
SidebarNode[] {
   return sidebar.map(normalize);
 }
 
+/** Version label a sub-project's docs were synced at, e.g. "0.5". */
+export function subprojectVersion(project: string): string | undefined {
+  const cfg = Object.entries(sidebarConfigs).find(([p]) => 
p.includes(`docs-${project}-en/config.json`))?.[1];
+  return (cfg?.default?.version ?? cfg?.version) as string | undefined;
+}
+
 export interface SidebarNode {
   label?: string;
   id?: string;
@@ -327,3 +362,14 @@ export function getApisixSidebar(): SidebarNode[] {
 }
 
 export const APISIX_DOCS_VERSION: string = apisixCfg.version ?? 'current';
+
+/**
+ * Archived APISIX versions, newest first. These stay on the Docusaurus build
+ * under /docs/apisix/<version>/; listing them keeps the archive reachable from
+ * the migrated current-version pages.
+ */
+export const APISIX_ARCHIVED_VERSIONS: string[] = (() => {
+  const cur = APISIX_DOCS_VERSION.replace(/^v/, '').split('.').slice(0, 
2).join('.');
+  const known = ['3.16', '3.15', '3.14', '3.13', '3.12', '3.11', '3.10'];
+  return known.filter((v) => v !== cur);
+})();
diff --git a/next/src/pages/docs/[project]/[...id].astro 
b/next/src/pages/docs/[project]/[...id].astro
index 87a06ade04c..6773c64786e 100644
--- a/next/src/pages/docs/[project]/[...id].astro
+++ b/next/src/pages/docs/[project]/[...id].astro
@@ -1,6 +1,6 @@
 ---
 import DocPage from '../../../layouts/DocPage.astro';
-import { SUBPROJECTS, getSubprojectDocs, getSubprojectSidebar } from 
'../../../lib/content';
+import { SUBPROJECTS, getSubprojectDocs, getSubprojectSidebar, docEditUrl, 
subprojectVersion } from '../../../lib/content';
 
 export function getStaticPaths() {
   return Object.keys(SUBPROJECTS).flatMap((project) => {
@@ -18,6 +18,7 @@ const { entry, titleById, urlByPathId, project } = 
Astro.props;
 const sidebar = getSubprojectSidebar(project);
 const urlById = (id: string) => urlByPathId.get(id) ?? 
`/docs/${project}/${id}/`;
 const repo = SUBPROJECTS[project].repo;
+const version = subprojectVersion(project);
 ---
 <DocPage
   entry={entry}
@@ -27,5 +28,8 @@ const repo = SUBPROJECTS[project].repo;
   sidebar={sidebar}
   titleById={titleById}
   urlById={urlById}
-  
editUrl={`https://github.com/apache/${repo}/edit/master/docs/en/latest/${entry.id}.md`}
+  editUrl={docEditUrl(project, repo, entry)}
+  versionLabel={version ? `v${version}` : undefined}
+  archivedVersions={[]}
+  nextUrl={`/docs/${project}/next/`}
 />
diff --git a/next/src/pages/docs/apisix/[...id].astro 
b/next/src/pages/docs/apisix/[...id].astro
index 3e36ec8863a..2812c08039c 100644
--- a/next/src/pages/docs/apisix/[...id].astro
+++ b/next/src/pages/docs/apisix/[...id].astro
@@ -1,6 +1,6 @@
 ---
 import DocPage from '../../../layouts/DocPage.astro';
-import { getApisixDocs, getApisixSidebar, APISIX_DOCS_VERSION } from 
'../../../lib/content';
+import { getApisixDocs, getApisixSidebar, docEditUrl, APISIX_DOCS_VERSION, 
APISIX_ARCHIVED_VERSIONS } from '../../../lib/content';
 
 export function getStaticPaths() {
   const docs = getApisixDocs('en');
@@ -15,7 +15,7 @@ export function getStaticPaths() {
 const { entry, titleById, urlByPathId } = Astro.props;
 const sidebar = getApisixSidebar();
 const urlById = (id: string) => urlByPathId.get(id) ?? `/docs/apisix/${id}/`;
-const editUrl = 
`https://github.com/apache/apisix/edit/master/docs/en/latest/${entry.id}.md`;
+const editUrl = docEditUrl('apisix', 'apisix', entry);
 ---
 <DocPage
   entry={entry}
@@ -27,4 +27,6 @@ const editUrl = 
`https://github.com/apache/apisix/edit/master/docs/en/latest/${e
   urlById={urlById}
   editUrl={editUrl}
   versionLabel={`APISIX ${APISIX_DOCS_VERSION}`}
+  archivedVersions={APISIX_ARCHIVED_VERSIONS}
+  versionBase="/docs/apisix/"
 />
diff --git a/next/src/pages/zh/docs/[project]/[...id].astro 
b/next/src/pages/zh/docs/[project]/[...id].astro
index c15ac5c24a8..cfb57250a25 100644
--- a/next/src/pages/zh/docs/[project]/[...id].astro
+++ b/next/src/pages/zh/docs/[project]/[...id].astro
@@ -1,6 +1,6 @@
 ---
 import DocPage from '../../../../layouts/DocPage.astro';
-import { SUBPROJECTS, getSubprojectDocs, getSubprojectSidebar } from 
'../../../../lib/content';
+import { SUBPROJECTS, getSubprojectDocs, getSubprojectSidebar, docEditUrl, 
subprojectVersion } from '../../../../lib/content';
 
 export function getStaticPaths() {
   return Object.keys(SUBPROJECTS).flatMap((project) => {
@@ -18,6 +18,7 @@ const { entry, titleById, urlByPathId, project } = 
Astro.props;
 const sidebar = getSubprojectSidebar(project);
 const urlById = (id: string) => urlByPathId.get(id) ?? 
`/zh/docs/${project}/${id}/`;
 const repo = SUBPROJECTS[project].repo;
+const version = subprojectVersion(project);
 ---
 <DocPage
   entry={entry}
@@ -27,5 +28,8 @@ const repo = SUBPROJECTS[project].repo;
   sidebar={sidebar}
   titleById={titleById}
   urlById={urlById}
-  
editUrl={`https://github.com/apache/${repo}/edit/master/docs/zh/latest/${entry.id}.md`}
+  editUrl={docEditUrl(project, repo, entry)}
+  versionLabel={version ? `v${version}` : undefined}
+  archivedVersions={[]}
+  nextUrl={`/zh/docs/${project}/next/`}
 />
diff --git a/next/src/pages/zh/docs/apisix/[...id].astro 
b/next/src/pages/zh/docs/apisix/[...id].astro
index e22ec1f5406..332ea8f054d 100644
--- a/next/src/pages/zh/docs/apisix/[...id].astro
+++ b/next/src/pages/zh/docs/apisix/[...id].astro
@@ -1,6 +1,6 @@
 ---
 import DocPage from '../../../../layouts/DocPage.astro';
-import { getApisixDocs, getApisixSidebar, APISIX_DOCS_VERSION } from 
'../../../../lib/content';
+import { getApisixDocs, getApisixSidebar, docEditUrl, APISIX_DOCS_VERSION, 
APISIX_ARCHIVED_VERSIONS } from '../../../../lib/content';
 
 export function getStaticPaths() {
   const docs = getApisixDocs('zh');
@@ -15,7 +15,7 @@ export function getStaticPaths() {
 const { entry, titleById, urlByPathId } = Astro.props;
 const sidebar = getApisixSidebar();
 const urlById = (id: string) => urlByPathId.get(id) ?? 
`/zh/docs/apisix/${id}/`;
-const editUrl = 
`https://github.com/apache/apisix/edit/master/docs/zh/latest/${entry.id}.md`;
+const editUrl = docEditUrl('apisix', 'apisix', entry);
 ---
 <DocPage
   entry={entry}
@@ -27,4 +27,6 @@ const editUrl = 
`https://github.com/apache/apisix/edit/master/docs/zh/latest/${e
   urlById={urlById}
   editUrl={editUrl}
   versionLabel={`APISIX ${APISIX_DOCS_VERSION}`}
+  archivedVersions={APISIX_ARCHIVED_VERSIONS}
+  versionBase="/zh/docs/apisix/"
 />
diff --git a/next/src/styles/global.css b/next/src/styles/global.css
index 00a1dff941d..ab89ffd5a30 100644
--- a/next/src/styles/global.css
+++ b/next/src/styles/global.css
@@ -428,3 +428,24 @@ section.endcta.alt { background: transparent; padding: 
4rem 1rem 0; }
 .anchor-link { opacity: 0; margin-left: .35rem; font-size: .8em; }
 h2:hover .anchor-link, h3:hover .anchor-link, .anchor-link:focus-visible { 
opacity: 1; }
 @media (hover: none) { .anchor-link { opacity: .55; } }
+
+/* ---------- docs search (Algolia DocSearch, docs pages only) ---------- */
+.docsearch-slot { margin-left: .5rem; }
+.docsearch-slot .DocSearch-Button { height: 36px; margin: 0; border-radius: 
8px; background: var(--color-surface-alt); }
+.docsearch-slot .DocSearch-Button:hover { box-shadow: none; border-color: 
var(--color-hover-border); }
+:root { --docsearch-primary-color: var(--color-primary); 
--docsearch-highlight-color: var(--color-primary); }
+@media (max-width: 996px) { .docsearch-slot { display: none; } }
+
+/* ---------- docs version picker ---------- */
+.version-picker { margin: 0 0 .5rem; }
+.version-picker > summary {
+  cursor: pointer; font-weight: 700; padding: .35rem .5rem; border-radius: 6px;
+  list-style: none; display: flex; align-items: center; gap: .35rem;
+}
+.version-picker > summary::-webkit-details-marker { display: none; }
+.version-picker > summary::after { content: '▾'; font-size: .8em; color: 
var(--color-text-soft); }
+.version-picker > summary:hover { background: var(--color-surface-alt); }
+.version-picker ul { list-style: none; margin: .25rem 0 0; padding: 0 0 0 
.5rem; }
+.version-picker li a { display: block; padding: .3rem .5rem; border-radius: 
6px; color: var(--color-text-soft); font-size: .9rem; }
+.version-picker li a:hover { background: var(--color-surface-alt); color: 
var(--color-primary); text-decoration: none; }
+.version-picker li a[aria-current] { color: var(--color-primary); font-weight: 
600; }

Reply via email to