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; }