This is an automated email from the ASF dual-hosted git repository.
guoqqqi 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 bd73dcaa7f5 fix(docs): link version picker entries to each version's
landing doc (#2095)
bd73dcaa7f5 is described below
commit bd73dcaa7f57e75b9411390796cbf6b4cbe59b75
Author: Yuhan <[email protected]>
AuthorDate: Thu Aug 6 09:04:37 2026 +0800
fix(docs): link version picker entries to each version's landing doc (#2095)
---
.github/workflows/deploy.yml | 65 +++++++++++++++++++++++
next/src/layouts/DocPage.astro | 21 +++-----
next/src/lib/content.ts | 53 +++++++++++++++++++
next/src/pages/docs/[project]/[...id].astro | 18 +++++--
next/src/pages/docs/apisix/[...id].astro | 16 ++++--
next/src/pages/zh/docs/[project]/[...id].astro | 18 +++++--
next/src/pages/zh/docs/apisix/[...id].astro | 16 ++++--
next/tests/e2e/docs-version-picker.spec.mjs | 72 ++++++++++++++++++++++++++
8 files changed, 254 insertions(+), 25 deletions(-)
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index eef527d1a04..e1ab996cf38 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -463,6 +463,71 @@ jobs:
test -f website/build/img/integrations/icon-prometheus.svg
test -f website/build/img/architecture.svg
+ - name: Assert every version-picker link resolves
+ run: |
+ # The picker used to link version roots (/docs/apisix/3.15/), which
+ # have no index.html — ASF httpd answers 403 via Options -Indexes.
+ # Check at the filesystem level: the e2e static server
directory-lists
+ # instead of 403ing, and only this step covers every emitted href
+ # rather than a handful of sampled pages.
+ # This is also the only layer that catches APISIX_ARCHIVED_VERSIONS
+ # drifting from what is actually published, because the Astro build
+ # never sees archived-version content.
+ node -e '
+ const fs = require("fs"), path = require("path");
+ const roots = ["website/build/docs", "website/build/zh/docs"];
+ const walk = (dir, out = []) => {
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const p = path.join(dir, e.name);
+ if (e.isDirectory()) walk(p, out);
+ else if (e.name === "index.html") out.push(p);
+ }
+ return out;
+ };
+ const seen = new Map();
+ for (const root of roots) {
+ if (!fs.existsSync(root)) continue;
+ for (const file of walk(root)) {
+ const block = fs.readFileSync(file, "utf8")
+ .match(/<details class="version-picker"[\s\S]*?<\/details>/);
+ if (!block) continue;
+ for (const m of block[0].matchAll(/href="([^"]+)"/g)) {
+ if (!seen.has(m[1])) seen.set(m[1], file);
+ }
+ }
+ }
+ if (!seen.size) { console.error("no version pickers found — the
docs overlay is broken"); process.exit(1); }
+ let fail = false;
+ for (const [href, from] of seen) {
+ if (!fs.existsSync(path.join("website/build", href,
"index.html"))) {
+ console.error(`dead version link ${href} (emitted by
${from})`);
+ fail = true;
+ }
+ }
+ if (fail) process.exit(1);
+ console.log(`version picker: ${seen.size} distinct links, all
resolve`);
+ '
+ # helm-chart is not version-published, so it must offer no next
entry.
+ # It also renders no <details class="version-picker"> at all (one
+ # version means a plain label), so the walk above cannot cover it.
+ #
+ # BOTH locales. zh/docs/helm-chart/ is Astro-built too and shipped
+ # the identical dead link (verified on asf-site: 5 pages, each with
+ # href="/zh/docs/helm-chart/next/"), so checking only English would
+ # leave the Chinese side unguarded against the same regression.
+ for prefix in "" "/zh"; do
+ dir="website/build${prefix}/docs/helm-chart"
+ # A missing tree must fail loudly rather than silently satisfy the
+ # assertion: `grep -r` on an absent path exits 2, which inside `if`
+ # reads as "no match", so a build that shipped no helm-chart docs
+ # at all would pass this check.
+ test -d "$dir"
+ if grep -rq "href=\"${prefix}/docs/helm-chart/next/" "$dir"; then
+ echo "helm-chart links to a next/ tree that does not exist:
${prefix}/docs/helm-chart"
+ exit 1
+ fi
+ done
+
- name: Generate final sitemaps
run: |
node next/scripts/generate-sitemaps.mjs --dist website/build
diff --git a/next/src/layouts/DocPage.astro b/next/src/layouts/DocPage.astro
index ed1daaeacb2..b74f84a0bc4 100644
--- a/next/src/layouts/DocPage.astro
+++ b/next/src/layouts/DocPage.astro
@@ -2,7 +2,7 @@
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';
+import type { DocEntry, SidebarNode, VersionEntry } from '../lib/content';
interface Props {
entry: DocEntry;
@@ -13,20 +13,15 @@ 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;
+ /** Version picker entries, current version first. Built by the caller. */
+ versions?: VersionEntry[];
/** Sidebar id of the current page (path-derived); defaults to entry.id. */
activeId?: string;
}
const {
entry, locale, path, sidebar = [], titleById, urlById, editUrl, versionLabel,
- archivedVersions = [], versionBase = '', nextUrl,
+ versions = [],
} = Astro.props;
-const nextHref = nextUrl ?? (versionBase ? `${versionBase}next/` : undefined);
const active = Astro.props.activeId ?? entry.id;
const { Content } = entry.mod;
@@ -66,14 +61,14 @@ const canonicalOverride = locale === 'zh' &&
rawCanonical?.startsWith('https://d
{sidebar.length > 0 && (
<nav class="docs-sidebar" aria-label="Docs sidebar">
{versionLabel && (
- (archivedVersions.length > 0 || nextHref)
+ versions.length > 1
? (
<details class="version-picker">
<summary>{versionLabel}</summary>
<ul>
- {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>}
+ {versions.map((v) => (
+ <li><a href={v.href} aria-current={v.current ? 'true' :
undefined}>{v.label}</a></li>
+ ))}
</ul>
</details>
)
diff --git a/next/src/lib/content.ts b/next/src/lib/content.ts
index 2cd6adb79e4..15b5dde09a0 100644
--- a/next/src/lib/content.ts
+++ b/next/src/lib/content.ts
@@ -382,3 +382,56 @@ export const APISIX_ARCHIVED_VERSIONS: string[] = (() => {
const known = ['3.16', '3.15', '3.14', '3.13', '3.12', '3.11', '3.10'];
return known.filter((v) => v !== cur);
})();
+
+/** An entry in the docs version picker. */
+export interface VersionEntry {
+ label: string;
+ href: string;
+ current?: boolean;
+}
+
+/**
+ * First leaf of a sidebar tree — the project's landing doc id. Verified to
+ * reproduce config/docs.js's firstDocPath for all seven projects, and for
+ * apisix it yields the trap-free "getting-started/README".
+ */
+export function sidebarLandingId(nodes: SidebarNode[]): string | undefined {
+ for (const node of nodes) {
+ if (node.id) return node.id;
+ if (node.items) {
+ const found = sidebarLandingId(node.items);
+ if (found) return found;
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Insert a version segment into the landing doc's version-less URL.
+ *
+ * This replaces linking the version ROOT (".../3.15/"), which has no
+ * index.html — ASF httpd answers 403 through Options -Indexes. Linking
+ * ".../3.15/getting-started/" is equally wrong: .htaccess:160 301s it to the
+ * LATEST version, silently defeating the switch. Only the full landing path
+ * is safe.
+ *
+ * Returns undefined when landingUrl does not sit under versionBase; the
+ * caller drops that entry. src/ degrades and never throws — the deploy.yml
+ * assertion is what fails loudly.
+ */
+export function versionedLandingHref(
+ versionBase: string,
+ version: string,
+ landingUrl: string,
+): string | undefined {
+ if (!landingUrl.startsWith(versionBase)) return undefined;
+ return `${versionBase}${version}/${landingUrl.slice(versionBase.length)}`;
+}
+
+/**
+ * Projects Docusaurus does not version-publish. With no versions.json it
+ * serves their docs straight from /docs/<project>/ and emits no next/ tree,
+ * so an unreleased entry would link to a 403. Astro syncs only the latest
+ * release and cannot observe this, hence the explicit list.
+ */
+export const NO_NEXT_DOCS = new Set(['helm-chart']);
diff --git a/next/src/pages/docs/[project]/[...id].astro
b/next/src/pages/docs/[project]/[...id].astro
index 6773c64786e..4aa867aaf45 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, docEditUrl,
subprojectVersion } from '../../../lib/content';
+import { SUBPROJECTS, getSubprojectDocs, getSubprojectSidebar, docEditUrl,
subprojectVersion, sidebarLandingId, versionedLandingHref, NO_NEXT_DOCS, type
VersionEntry } from '../../../lib/content';
export function getStaticPaths() {
return Object.keys(SUBPROJECTS).flatMap((project) => {
@@ -19,6 +19,19 @@ const sidebar = getSubprojectSidebar(project);
const urlById = (id: string) => urlByPathId.get(id) ??
`/docs/${project}/${id}/`;
const repo = SUBPROJECTS[project].repo;
const version = subprojectVersion(project);
+
+const versionBase = `/docs/${project}/`;
+const landingId = sidebarLandingId(sidebar);
+const landingUrl = landingId ? urlById(landingId) : undefined;
+const nextHref = landingUrl && !NO_NEXT_DOCS.has(project)
+ ? versionedLandingHref(versionBase, 'next', landingUrl)
+ : undefined;
+const versions: VersionEntry[] = version && landingUrl
+ ? [
+ { label: `v${version}`, href: landingUrl, current: true },
+ ...(nextHref ? [{ label: 'Next (unreleased)', href: nextHref }] : []),
+ ]
+ : [];
---
<DocPage
entry={entry}
@@ -30,6 +43,5 @@ const version = subprojectVersion(project);
urlById={urlById}
editUrl={docEditUrl(project, repo, entry)}
versionLabel={version ? `v${version}` : undefined}
- archivedVersions={[]}
- nextUrl={`/docs/${project}/next/`}
+ versions={versions}
/>
diff --git a/next/src/pages/docs/apisix/[...id].astro
b/next/src/pages/docs/apisix/[...id].astro
index 2812c08039c..47eb938daa4 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, docEditUrl, APISIX_DOCS_VERSION,
APISIX_ARCHIVED_VERSIONS } from '../../../lib/content';
+import { getApisixDocs, getApisixSidebar, docEditUrl, APISIX_DOCS_VERSION,
APISIX_ARCHIVED_VERSIONS, sidebarLandingId, versionedLandingHref, type
VersionEntry } from '../../../lib/content';
export function getStaticPaths() {
const docs = getApisixDocs('en');
@@ -16,6 +16,17 @@ const { entry, titleById, urlByPathId } = Astro.props;
const sidebar = getApisixSidebar();
const urlById = (id: string) => urlByPathId.get(id) ?? `/docs/apisix/${id}/`;
const editUrl = docEditUrl('apisix', 'apisix', entry);
+
+const versionBase = '/docs/apisix/';
+const landingId = sidebarLandingId(sidebar);
+const landingUrl = landingId ? urlById(landingId) : undefined;
+const versions: VersionEntry[] = landingUrl
+ ? [
+ { label: `APISIX ${APISIX_DOCS_VERSION}`, href: landingUrl, current:
true },
+ ...APISIX_ARCHIVED_VERSIONS.map((v) => ({ label: v, href:
versionedLandingHref(versionBase, v, landingUrl) })),
+ { label: 'Next (unreleased)', href: versionedLandingHref(versionBase,
'next', landingUrl) },
+ ].flatMap((e) => (e.href ? [{ ...e, href: e.href }] : []))
+ : [];
---
<DocPage
entry={entry}
@@ -27,6 +38,5 @@ const editUrl = docEditUrl('apisix', 'apisix', entry);
urlById={urlById}
editUrl={editUrl}
versionLabel={`APISIX ${APISIX_DOCS_VERSION}`}
- archivedVersions={APISIX_ARCHIVED_VERSIONS}
- versionBase="/docs/apisix/"
+ versions={versions}
/>
diff --git a/next/src/pages/zh/docs/[project]/[...id].astro
b/next/src/pages/zh/docs/[project]/[...id].astro
index cfb57250a25..345b17dfcc2 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, docEditUrl,
subprojectVersion } from '../../../../lib/content';
+import { SUBPROJECTS, getSubprojectDocs, getSubprojectSidebar, docEditUrl,
subprojectVersion, sidebarLandingId, versionedLandingHref, NO_NEXT_DOCS, type
VersionEntry } from '../../../../lib/content';
export function getStaticPaths() {
return Object.keys(SUBPROJECTS).flatMap((project) => {
@@ -19,6 +19,19 @@ const sidebar = getSubprojectSidebar(project);
const urlById = (id: string) => urlByPathId.get(id) ??
`/zh/docs/${project}/${id}/`;
const repo = SUBPROJECTS[project].repo;
const version = subprojectVersion(project);
+
+const versionBase = `/zh/docs/${project}/`;
+const landingId = sidebarLandingId(sidebar);
+const landingUrl = landingId ? urlById(landingId) : undefined;
+const nextHref = landingUrl && !NO_NEXT_DOCS.has(project)
+ ? versionedLandingHref(versionBase, 'next', landingUrl)
+ : undefined;
+const versions: VersionEntry[] = version && landingUrl
+ ? [
+ { label: `v${version}`, href: landingUrl, current: true },
+ ...(nextHref ? [{ label: '开发版 (next)', href: nextHref }] : []),
+ ]
+ : [];
---
<DocPage
entry={entry}
@@ -30,6 +43,5 @@ const version = subprojectVersion(project);
urlById={urlById}
editUrl={docEditUrl(project, repo, entry)}
versionLabel={version ? `v${version}` : undefined}
- archivedVersions={[]}
- nextUrl={`/zh/docs/${project}/next/`}
+ versions={versions}
/>
diff --git a/next/src/pages/zh/docs/apisix/[...id].astro
b/next/src/pages/zh/docs/apisix/[...id].astro
index 332ea8f054d..494afc4af63 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, docEditUrl, APISIX_DOCS_VERSION,
APISIX_ARCHIVED_VERSIONS } from '../../../../lib/content';
+import { getApisixDocs, getApisixSidebar, docEditUrl, APISIX_DOCS_VERSION,
APISIX_ARCHIVED_VERSIONS, sidebarLandingId, versionedLandingHref, type
VersionEntry } from '../../../../lib/content';
export function getStaticPaths() {
const docs = getApisixDocs('zh');
@@ -16,6 +16,17 @@ const { entry, titleById, urlByPathId } = Astro.props;
const sidebar = getApisixSidebar();
const urlById = (id: string) => urlByPathId.get(id) ??
`/zh/docs/apisix/${id}/`;
const editUrl = docEditUrl('apisix', 'apisix', entry);
+
+const versionBase = '/zh/docs/apisix/';
+const landingId = sidebarLandingId(sidebar);
+const landingUrl = landingId ? urlById(landingId) : undefined;
+const versions: VersionEntry[] = landingUrl
+ ? [
+ { label: `APISIX ${APISIX_DOCS_VERSION}`, href: landingUrl, current:
true },
+ ...APISIX_ARCHIVED_VERSIONS.map((v) => ({ label: v, href:
versionedLandingHref(versionBase, v, landingUrl) })),
+ { label: '开发版 (next)', href: versionedLandingHref(versionBase, 'next',
landingUrl) },
+ ].flatMap((e) => (e.href ? [{ ...e, href: e.href }] : []))
+ : [];
---
<DocPage
entry={entry}
@@ -27,6 +38,5 @@ const editUrl = docEditUrl('apisix', 'apisix', entry);
urlById={urlById}
editUrl={editUrl}
versionLabel={`APISIX ${APISIX_DOCS_VERSION}`}
- archivedVersions={APISIX_ARCHIVED_VERSIONS}
- versionBase="/zh/docs/apisix/"
+ versions={versions}
/>
diff --git a/next/tests/e2e/docs-version-picker.spec.mjs
b/next/tests/e2e/docs-version-picker.spec.mjs
new file mode 100644
index 00000000000..95a2e1c1569
--- /dev/null
+++ b/next/tests/e2e/docs-version-picker.spec.mjs
@@ -0,0 +1,72 @@
+import { expect, test } from '@playwright/test';
+
+// One representative page per project and locale. A project's version picker
+// markup is identical on every one of its pages, so walking all 249 pages
+// would only make the suite slow.
+const DOC_PAGES = [
+ '/docs/apisix/getting-started/README/',
+ '/zh/docs/apisix/getting-started/README/',
+ '/docs/ingress-controller/overview/',
+ '/docs/docker/build/',
+];
+
+for (const pagePath of DOC_PAGES) {
+ test(`version links on ${pagePath} open real docs pages`, async ({ page })
=> {
+ test.setTimeout(120_000);
+ test.skip(
+ process.env.EXPECT_DOCUSARUS_ROUTES !== 'true',
+ 'Archived versions only exist in the final overlaid tree',
+ );
+
+ await page.goto(pagePath);
+ const hrefs = await page
+ .locator('.version-picker a')
+ .evaluateAll((anchors) => anchors.map((a) => a.getAttribute('href')));
+
+ expect(hrefs.length, `${pagePath} should render a version
picker`).toBeGreaterThan(0);
+
+ for (const href of hrefs) {
+ await page.goto(href);
+ // Assert on CONTENT, never on HTTP status. The e2e static server
+ // (python3 -m http.server) answers an index-less directory with 200
+ // plus a directory listing, so a status assertion would pass on the
+ // exact build this test exists to reject. Production returns 403.
+ //
+ // Both selectors are required: current-version pages are Astro-built
+ // and wrap content in .docs-content, while archived versions are still
+ // Docusaurus-built and use .theme-doc-markdown instead. Do NOT relax
+ // this to a bare `h1` — the 404 page that a 403 renders has exactly
+ // one h1 (text "404"), so a bare h1 would pass on precisely the URLs
+ // this test exists to reject.
+ //
+ // .first() is required, not cosmetic: some docs pages legitimately
+ // render multiple h1s (python-plugin-runner's next/getting-started
+ // already does), and toBeVisible() on a multi-element locator is a
+ // Playwright strict-mode violation. Without it, an upstream doc gaining
+ // a second `#` heading would fail the site deploy. A zero-match locator
+ // still fails toBeVisible, so 404 pages and listings stay red.
+ await expect(
+ page.locator('.docs-content h1, .theme-doc-markdown h1').first(),
+ `${href} must be a docs page, not a directory listing`,
+ ).toBeVisible();
+ }
+ });
+}
+
+test('helm-chart offers no unreleased entry', async ({ page }) => {
+ test.skip(
+ process.env.EXPECT_DOCUSARUS_ROUTES !== 'true',
+ 'Sub-project docs only exist in the final overlaid tree',
+ );
+
+ // Docusaurus does not version-publish helm-chart, so /docs/helm-chart/next/
+ // has never existed and any link to it is a 403 by construction.
+ await page.goto('/docs/helm-chart/apisix/');
+ // Positive anchor first: a 404, a redirect, or a blank page would all
+ // satisfy toHaveCount(0), letting the test pass vacuously. Requiring the
+ // page's own heading proves the docs page actually rendered before we
+ // assert the unreleased link is absent. This page is Astro-built, so
+ // .docs-content is the right container here.
+ await expect(page.locator('.docs-content h1').first()).toBeVisible();
+ await
expect(page.locator('a[href^="/docs/helm-chart/next/"]')).toHaveCount(0);
+});