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

kayx23 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 a6f5807aa30 feat(docs): render Control API references with Scalar 
(#2148)
a6f5807aa30 is described below

commit a6f5807aa30c67a880e2496eac91ee9802cfb179
Author: Traky Deng <[email protected]>
AuthorDate: Mon Nov 2 18:16:53 2026 +0800

    feat(docs): render Control API references with Scalar (#2148)
    
    * feat(docs): render Control API references with Scalar
    
    * test(docs): pin Control API source fixtures
---
 doc/src/components/AdminApiReference/index.tsx     |  41 ++---
 doc/src/components/AdminApiReference/styles.css    |   4 +-
 doc/src/theme/DocPage/index.tsx                    |   6 +-
 next/scripts/check-admin-api-fixtures.mjs          |  20 ++-
 next/scripts/check-admin-api-sources.mjs           |  24 +--
 next/scripts/sync-content.mjs                      |   6 +-
 next/src/layouts/AdminApiPage.astro                |  41 ++---
 next/src/layouts/DocPage.astro                     |   2 +-
 next/src/pages/docs/apisix/[...id].astro           |   2 +-
 next/src/pages/docs/apisix/admin-api.astro         |   2 +-
 .../apisix/{admin-api.astro => control-api.astro}  |   2 +-
 next/src/pages/zh/docs/apisix/[...id].astro        |   2 +-
 next/src/pages/zh/docs/apisix/admin-api.astro      |   2 +-
 .../apisix/{admin-api.astro => control-api.astro}  |   2 +-
 next/tests/admin-api/README.md                     |  30 ++--
 next/tests/admin-api/admin-api.spec.mjs            | 178 +++++++++++----------
 next/tests/admin-api/source-fixtures.json          |  31 ++++
 scripts/lib/admin-api.js                           |  26 ++-
 scripts/lib/admin-api.test.js                      |  23 ++-
 scripts/sync-docs.js                               |   6 +-
 shared/admin-api-scalar.css                        |  14 +-
 shared/admin-api-spec.test.mjs                     |  18 ++-
 shared/admin-api-spec.ts                           |   8 +-
 shared/admin-api-toolbar.css                       |   8 +-
 shared/admin-api.ts                                |  29 ++--
 25 files changed, 313 insertions(+), 214 deletions(-)

diff --git a/doc/src/components/AdminApiReference/index.tsx 
b/doc/src/components/AdminApiReference/index.tsx
index 5240145fb65..1024a0c9c61 100644
--- a/doc/src/components/AdminApiReference/index.tsx
+++ b/doc/src/components/AdminApiReference/index.tsx
@@ -2,7 +2,7 @@ import React, { useEffect, useRef } from 'react';
 // eslint-disable-next-line import/no-webpack-loader-syntax, 
import/no-extraneous-dependencies
 import scalarTheme from '!!raw-loader!../../../../shared/admin-api-scalar.css';
 import { versions as releaseVersions } from 
'../../../../config/apisix-versions';
-import { mountAdminApi } from '../../../../shared/admin-api';
+import { mountApiReference } from '../../../../shared/admin-api';
 import '../../../../shared/admin-api-toolbar.css';
 import './styles.css';
 
@@ -12,25 +12,28 @@ interface Props {
 }
 
 // The route owns the version, including during client-side version switching.
-const AdminApiReference = ({ path, children }: Props): JSX.Element => {
+const ApiReference = ({ path, children }: Props): JSX.Element => {
   const root = useRef<HTMLDivElement>(null);
-  const match = path.match(/^(\/zh)?\/docs\/apisix\/([^/]+)\/admin-api\/?$/);
+  const match = 
path.match(/^(\/zh)?\/docs\/apisix\/([^/]+)\/(admin-api|control-api)\/?$/);
   const prefix = match?.[1] ?? '';
-  // Enable only versions with a merged, branch-specific Admin API contract.
+  // Enable only versions with merged, branch-specific API contracts.
   // Other archives keep their existing Markdown reference.
   const supported = ['3.16', '3.17', '3.18', 'next'];
   const version = supported.includes(match?.[2]) ? match?.[2] : undefined;
+  const apiId = match?.[3];
   const zh = prefix === '/zh';
-  const specUrl = `/assets/openapi/apisix/${version}/admin-api.json`;
+  const pageName = apiId === 'admin-api' ? 'Admin API' : 'Control API';
+  const specUrl = `/assets/openapi/apisix/${version}/${apiId}.json`;
   useEffect(() => {
-    if (!version || !root.current) return undefined;
-    // SSR leaves Markdown readable without JavaScript. Hide it only while the
-    // reference is loading; every failure restores it through the shared code.
+    if (!version || !apiId || !root.current) return undefined;
+    // SSR leaves Markdown readable without JavaScript. Hide it immediately
+    // when the client starts so the full reference does not flash before the
+    // loading shell. Every failure restores it through the shared renderer.
     const markdown = 
root.current.querySelector<HTMLElement>('[data-docs-content]');
     if (markdown) markdown.hidden = true;
-    return mountAdminApi(root.current, scalarTheme, version);
+    return mountApiReference(root.current, scalarTheme, version);
   }, [path]);
-  if (!version) return children as JSX.Element;
+  if (!version || !apiId) return children as JSX.Element;
   const versions = [...releaseVersions].reverse().concat('next');
   const latest = releaseVersions[releaseVersions.length - 1];
   const versionLabel = (v: string) => {
@@ -38,34 +41,34 @@ const AdminApiReference = ({ path, children }: Props): 
JSX.Element => {
     return zh ? '开发版 (next)' : 'Next (unreleased)';
   };
   return (
-    <div ref={root} className="archived-admin-api">
-      <section id="admin-api-openapi">
+    <div ref={root} className="archived-api-reference">
+      <section id="api-reference-openapi">
         <nav className="api-toolbar" aria-label={zh ? 'API 文档导航' : 'API 
reference navigation'}>
           <div className="api-location">
             <a className="api-back" 
href={`${prefix}/docs/apisix/${version}/getting-started/README/`}>{zh ? '文档' : 
'Docs'}</a>
             <span className="api-separator" aria-hidden="true">/</span>
-            <span className="api-page-name" aria-current="page">Admin 
API</span>
+            <span className="api-page-name" 
aria-current="page">{pageName}</span>
             <details className="api-version">
               <summary aria-label={zh ? `切换版本,当前 ${version}` : `Switch 
version, current ${version}`}>{version}</summary>
               <ul>
                 {versions.map((v) => (
-                  <li key={v}><a href={`${prefix}/docs/apisix/${v === latest ? 
'' : `${v}/`}admin-api/`} aria-current={v === version ? 'page' : 
undefined}>{versionLabel(v)}</a></li>
+                  <li key={v}><a href={`${prefix}/docs/apisix/${v === latest ? 
'' : `${v}/`}${apiId}/`} aria-current={v === version ? 'page' : 
undefined}>{versionLabel(v)}</a></li>
                 ))}
               </ul>
             </details>
           </div>
-          <a className="api-action api-download" href={specUrl} 
download="apisix-admin-api.json" aria-label={zh ? '下载 OpenAPI' : 'Download 
OpenAPI'}>
+          <a className="api-action api-download" href={specUrl} 
download={`apisix-${apiId}.json`} aria-label={zh ? '下载 OpenAPI' : 'Download 
OpenAPI'}>
             <span aria-hidden="true">↓</span>
             <span className="api-action-label">{zh ? '下载 OpenAPI' : 'Download 
OpenAPI'}</span>
           </a>
         </nav>
         <p id="api-reference-loading" className="container" role="status">{zh 
? '正在加载 API 参考…' : 'Loading API reference…'}</p>
-        <div id="admin-api-reference" data-spec-url={specUrl} hidden />
+        <div id="api-reference-container" data-api-id={apiId} 
data-spec-url={specUrl} hidden />
       </section>
-      <noscript><style>{'#admin-api-openapi { display: none; 
}'}</style></noscript>
-      <div id="admin-api-markdown" data-docs-content>{children}</div>
+      <noscript><style>{'#api-reference-openapi { display: none; 
}'}</style></noscript>
+      <div id="api-reference-markdown" data-docs-content>{children}</div>
     </div>
   );
 };
 
-export default AdminApiReference;
+export default ApiReference;
diff --git a/doc/src/components/AdminApiReference/styles.css 
b/doc/src/components/AdminApiReference/styles.css
index 91876b2172b..fa2b27c147b 100644
--- a/doc/src/components/AdminApiReference/styles.css
+++ b/doc/src/components/AdminApiReference/styles.css
@@ -1,4 +1,4 @@
-.archived-admin-api {
+.archived-api-reference {
   --header-height: var(--ifm-navbar-height, 60px);
   --font-sans: var(--ifm-font-family-base);
   --font-mono: var(--ifm-font-family-monospace);
@@ -13,6 +13,6 @@
   min-width: 0;
 }
 
-.archived-admin-api #admin-api-markdown:not([hidden]) {
+.archived-api-reference #api-reference-markdown:not([hidden]) {
   display: block;
 }
diff --git a/doc/src/theme/DocPage/index.tsx b/doc/src/theme/DocPage/index.tsx
index d4f0b46b5e1..19410ff21be 100644
--- a/doc/src/theme/DocPage/index.tsx
+++ b/doc/src/theme/DocPage/index.tsx
@@ -34,7 +34,7 @@ import type { ImageProps } from 'rc-image';
 import { LazyLoadImage } from 'react-lazy-load-image-component';
 import Link from '@docusaurus/Link';
 import NotFound from '../NotFound';
-import AdminApiReference from '../../components/AdminApiReference';
+import ApiReference from '../../components/AdminApiReference';
 
 import styles from './styles.module.css';
 import 'react-lazy-load-image-component/src/effects/blur.css';
@@ -409,7 +409,7 @@ const DocPageContent = ({
         tag: docVersionSearchTag(pluginId, version),
       }}
     >
-      <AdminApiReference key={currentDocRoute.path} 
path={currentDocRoute.path}>
+      <ApiReference key={currentDocRoute.path} path={currentDocRoute.path}>
         <div className={styles.docPage}>
           <BackToTopButton />
 
@@ -484,7 +484,7 @@ const DocPageContent = ({
             </div>
           </main>
         </div>
-      </AdminApiReference>
+      </ApiReference>
     </Layout>
   );
 };
diff --git a/next/scripts/check-admin-api-fixtures.mjs 
b/next/scripts/check-admin-api-fixtures.mjs
index c1bb2281fa1..840286d2f7c 100644
--- a/next/scripts/check-admin-api-fixtures.mjs
+++ b/next/scripts/check-admin-api-fixtures.mjs
@@ -4,7 +4,8 @@ import fs from 'node:fs';
 import path from 'node:path';
 import { execFileSync } from 'node:child_process';
 import { createHash } from 'node:crypto';
-import { isAdminApiSpec } from '../../shared/admin-api-spec.ts';
+import { fileURLToPath } from 'node:url';
+import { isApiReferenceSpec } from '../../shared/admin-api-spec.ts';
 
 // Read-only, explicit validation inputs; never called by sync or deployment.
 const [fixtureRepository, generatedAssetRoot] = process.argv.slice(2);
@@ -13,13 +14,18 @@ assert.ok(
   'Usage: node next/scripts/check-admin-api-fixtures.mjs <fixture-git-repo> 
<generated-assets/openapi/apisix>',
 );
 const fixtures = JSON.parse(fs.readFileSync(new 
URL('../tests/admin-api/source-fixtures.json', import.meta.url)));
+const siteRoot = fileURLToPath(new URL('../..', import.meta.url));
 console.log('Canonical apache/apisix fixture parity.');
-fixtures.forEach(({ version, commit, canonicalRef }) => {
-  const source = execFileSync('git', ['-C', fixtureRepository, 'show', 
`${commit}:docs/en/latest/admin-api.json`]);
-  const generated = fs.readFileSync(path.join(generatedAssetRoot, version, 
'admin-api.json'));
+fixtures.forEach(({
+  version, commit, canonicalRef, apiId = 'admin-api', generatedPath,
+}) => {
+  const source = execFileSync('git', ['-C', fixtureRepository, 'show', 
`${commit}:docs/en/latest/${apiId}.json`]);
+  const generated = fs.readFileSync(generatedPath
+    ? path.join(siteRoot, generatedPath)
+    : path.join(generatedAssetRoot, version, `${apiId}.json`));
   const spec = JSON.parse(source);
-  assert.ok(isAdminApiSpec(spec, version), `Invalid ${version} fixture`);
-  assert.deepEqual(generated, source, `${version} generated asset differs from 
immutable fixture`);
+  assert.ok(isApiReferenceSpec(spec, version, apiId), `Invalid ${version} 
${apiId} fixture`);
+  assert.deepEqual(generated, source, `${version} ${apiId} generated asset 
differs from immutable fixture`);
   const digest = createHash('sha256').update(source).digest('hex');
-  console.log(`PASS ${version}: apache/apisix@${commit}; canonical ref 
${canonicalRef}; ${Object.keys(spec.paths).length} paths; sha256=${digest}`);
+  console.log(`PASS ${version} ${apiId}: apache/apisix@${commit}; canonical 
ref ${canonicalRef}; ${Object.keys(spec.paths).length} paths; 
sha256=${digest}`);
 });
diff --git a/next/scripts/check-admin-api-sources.mjs 
b/next/scripts/check-admin-api-sources.mjs
index abc2ce58c65..9c93edb0a13 100644
--- a/next/scripts/check-admin-api-sources.mjs
+++ b/next/scripts/check-admin-api-sources.mjs
@@ -3,22 +3,24 @@ import fs from 'node:fs';
 import path from 'node:path';
 import { createHash } from 'node:crypto';
 import { fileURLToPath } from 'node:url';
-import { isAdminApiSpec } from '../../shared/admin-api-spec.ts';
+import { isApiReferenceSpec } from '../../shared/admin-api-spec.ts';
 
 const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 
'../..');
 const assets = process.argv[2] ?? path.join(root, 
'doc/static/assets/openapi/apisix');
 const required = ['3.16', '3.17', '3.18', 'next'];
 let failed = false;
 required.forEach((version) => {
-  const ref = version === 'next' ? 'master' : `release/${version}`;
-  const source = `apache/apisix@${ref}/docs/en/latest/admin-api.json`;
-  try {
-    const bytes = fs.readFileSync(path.join(assets, version, 
'admin-api.json'));
-    if (!isAdminApiSpec(JSON.parse(bytes), version)) throw new Error('invalid 
or mismatched OpenAPI');
-    console.log(`OK ${source} 
sha256=${createHash('sha256').update(bytes).digest('hex')}`);
-  } catch (error) {
-    failed = true;
-    console.error(`BLOCKED ${source}: ${error.code === 'ENOENT' ? 'missing 
synced source' : error.message}`);
-  }
+  ['admin-api', 'control-api'].forEach((apiId) => {
+    const ref = version === 'next' ? 'master' : `release/${version}`;
+    const source = `apache/apisix@${ref}/docs/en/latest/${apiId}.json`;
+    try {
+      const bytes = fs.readFileSync(path.join(assets, version, 
`${apiId}.json`));
+      if (!isApiReferenceSpec(JSON.parse(bytes), version, apiId)) throw new 
Error('invalid or mismatched OpenAPI');
+      console.log(`OK ${source} 
sha256=${createHash('sha256').update(bytes).digest('hex')}`);
+    } catch (error) {
+      failed = true;
+      console.error(`BLOCKED ${source}: ${error.code === 'ENOENT' ? 'missing 
synced source' : error.message}`);
+    }
+  });
 });
 if (failed) process.exitCode = 1;
diff --git a/next/scripts/sync-content.mjs b/next/scripts/sync-content.mjs
index 762c9a919af..6623bf8a5cf 100644
--- a/next/scripts/sync-content.mjs
+++ b/next/scripts/sync-content.mjs
@@ -195,8 +195,10 @@ for (const { key, repo } of PROJECTS) {
     const cfg = path.join(src, 'config.json');
     if (fs.existsSync(cfg)) fs.copyFileSync(cfg, path.join(outDir, 
'config.json'));
     if (key === 'apisix' && loc === 'en') {
-      const spec = path.join(src, 'admin-api.json');
-      if (fs.existsSync(spec)) fs.copyFileSync(spec, path.join(outDir, 
'admin-api.json'));
+      ['admin-api', 'control-api'].forEach((apiId) => {
+        const spec = path.join(src, `${apiId}.json`);
+        if (fs.existsSync(spec)) fs.copyFileSync(spec, path.join(outDir, 
`${apiId}.json`));
+      });
     }
   }
 }
diff --git a/next/src/layouts/AdminApiPage.astro 
b/next/src/layouts/AdminApiPage.astro
index bb4c293840c..549678be797 100644
--- a/next/src/layouts/AdminApiPage.astro
+++ b/next/src/layouts/AdminApiPage.astro
@@ -1,18 +1,19 @@
 ---
 import DocPage from './DocPage.astro';
 import '../../../shared/admin-api-toolbar.css';
-import { isAdminApiSpec } from '../../../shared/admin-api-spec';
+import { isApiReferenceSpec } from '../../../shared/admin-api-spec';
 import { getApisixDocs, getApisixSidebar, sidebarLandingId, docEditUrl, 
APISIX_DOCS_VERSION, APISIX_ARCHIVED_VERSIONS, versionedLandingHref, type 
VersionEntry } from '../lib/content';
 import type { Locale } from '../lib/site';
 
 interface Props {
   locale: Locale;
+  apiId: 'admin-api' | 'control-api';
 }
-const { locale } = Astro.props;
+const { locale, apiId } = Astro.props;
 const zh = locale === 'zh';
 const docs = getApisixDocs(locale);
-const entry = docs.find((doc) => doc.pathId === 'admin-api');
-if (!entry) throw new Error('Sync APISIX documentation before building the 
Admin API reference');
+const entry = docs.find((doc) => doc.pathId === apiId);
+if (!entry) throw new Error(`Sync APISIX documentation before building the 
${apiId} reference`);
 const prefix = zh ? '/zh' : '';
 const sidebar = getApisixSidebar();
 const titleById = new Map(docs.map((doc) => [doc.pathId, doc.title]));
@@ -20,7 +21,7 @@ const urlByPathId = new Map(docs.map((doc) => [doc.pathId, 
doc.url]));
 const urlById = (id: string) => urlByPathId.get(id) ?? 
`${prefix}/docs/apisix/${id}/`;
 const docsHref = docs.find((doc) => doc.pathId === 
sidebarLandingId(sidebar))?.url ?? `${prefix}/docs/`;
 const editUrl = docEditUrl('apisix', 'apisix', entry);
-const currentHref = `${prefix}/docs/apisix/admin-api/`;
+const currentHref = `${prefix}/docs/apisix/${apiId}/`;
 const versionsFor = (href: string): VersionEntry[] => [
   { label: `APISIX ${APISIX_DOCS_VERSION}`, href, current: true },
   ...[...APISIX_ARCHIVED_VERSIONS, 'next'].map((version) => ({
@@ -31,19 +32,21 @@ const versionsFor = (href: string): VersionEntry[] => [
 const versions = versionsFor(currentHref);
 // The upstream JSON is optional until it is available on the synced release.
 // Vite emits it under /_astro/, which the existing deployment already ships.
-const sources = import.meta.glob('/content/docs-apisix-en/admin-api.json', { 
eager: true, query: '?raw', import: 'default' }) as Record<string, string>;
-const assets = import.meta.glob('/content/docs-apisix-en/admin-api.json', { 
eager: true, query: '?url', import: 'default' }) as Record<string, string>;
+const allSources = import.meta.glob('/content/docs-apisix-en/*.json', { eager: 
true, query: '?raw', import: 'default' }) as Record<string, string>;
+const allAssets = import.meta.glob('/content/docs-apisix-en/*.json', { eager: 
true, query: '?url', import: 'default' }) as Record<string, string>;
+const sourceKey = Object.keys(allSources).find((key) => 
key.endsWith(`/${apiId}.json`));
 let specUrl: string | undefined;
 try {
-  const spec = JSON.parse(Object.values(sources)[0]);
-  if (isAdminApiSpec(spec, APISIX_DOCS_VERSION)) {
-    specUrl = Object.values(assets)[0];
+  const spec = JSON.parse(sourceKey ? allSources[sourceKey] : '');
+  if (isApiReferenceSpec(spec, APISIX_DOCS_VERSION, apiId)) {
+    specUrl = sourceKey ? allAssets[sourceKey] : undefined;
   }
 } catch { /* Missing or invalid JSON keeps the existing Markdown renderer. */ }
+const pageName = apiId === 'admin-api' ? 'Admin API' : 'Control API';
 ---
-<DocPage entry={entry} activeId={entry.pathId} locale={locale} 
path="/docs/apisix/admin-api/" sidebar={sidebar} titleById={titleById} 
urlById={urlById} editUrl={editUrl} versionLabel={`APISIX 
${APISIX_DOCS_VERSION}`} versions={versionsFor(docsHref)} 
hideContent={Boolean(specUrl)}>
+<DocPage entry={entry} activeId={entry.pathId} locale={locale} 
path={`/docs/apisix/${apiId}/`} sidebar={sidebar} titleById={titleById} 
urlById={urlById} editUrl={editUrl} versionLabel={`APISIX 
${APISIX_DOCS_VERSION}`} versions={versionsFor(docsHref)} 
hideContent={Boolean(specUrl)}>
   <Fragment slot="reference">
-    {specUrl && <section id="admin-api-openapi">
+    {specUrl && <section id="api-reference-openapi">
       <nav class="api-toolbar" aria-label={zh ? 'API 文档导航' : 'API reference 
navigation'}>
         <div class="api-location">
           <a class="api-back" href={docsHref}>
@@ -51,7 +54,7 @@ try {
             {zh ? '文档' : 'Docs'}
           </a>
           <span class="api-separator" aria-hidden="true">/</span>
-          <span class="api-page-name" aria-current="page">Admin API</span>
+          <span class="api-page-name" aria-current="page">{pageName}</span>
           <details class="api-version">
             <summary aria-label={zh ? `切换版本,当前 ${APISIX_DOCS_VERSION}` : 
`Switch version, current ${APISIX_DOCS_VERSION}`}>
               <span>{APISIX_DOCS_VERSION}</span>
@@ -60,25 +63,25 @@ try {
             <ul>{versions.map((version) => <li><a href={version.href} 
aria-current={version.current ? 'page' : 
undefined}>{version.label}</a></li>)}</ul>
           </details>
         </div>
-        <a class="api-action api-download" href={specUrl} 
download="apisix-admin-api.json" aria-label={zh ? '下载 OpenAPI' : 'Download 
OpenAPI'} title={zh ? '下载 OpenAPI' : 'Download OpenAPI'}>
+        <a class="api-action api-download" href={specUrl} 
download={`apisix-${apiId}.json`} aria-label={zh ? '下载 OpenAPI' : 'Download 
OpenAPI'} title={zh ? '下载 OpenAPI' : 'Download OpenAPI'}>
           <svg width="16" height="16" viewBox="0 0 24 24" fill="none" 
stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="M12 
3v12m-5-5 5 5 5-5M4 16v5h16v-5" /></svg>
           <span class="api-action-label">{zh ? '下载 OpenAPI' : 'Download 
OpenAPI'}</span>
         </a>
       </nav>
       <p id="api-reference-loading" class="container" role="status">{zh ? 
'正在加载 API 参考…' : 'Loading API reference…'}</p>
-      <div id="admin-api-reference" data-spec-url={specUrl} 
data-spec-version={APISIX_DOCS_VERSION} hidden></div>
+      <div id="api-reference-container" data-api-id={apiId} 
data-spec-url={specUrl} data-spec-version={APISIX_DOCS_VERSION} hidden></div>
     </section>}
     {specUrl && <noscript>
       <style is:inline>
-        #admin-api-openapi { display: none; }
-        #admin-api-markdown[hidden] { display: grid; }
+        #api-reference-openapi { display: none; }
+        #api-reference-markdown[hidden] { display: grid; }
       </style>
     </noscript>}
     <script>
       import scalarTheme from '../../../shared/admin-api-scalar.css?inline';
 
-      import { mountAdminApi } from '../../../shared/admin-api';
-      mountAdminApi(document, scalarTheme);
+      import { mountApiReference } from '../../../shared/admin-api';
+      mountApiReference(document, scalarTheme);
     </script>
   </Fragment>
 </DocPage>
diff --git a/next/src/layouts/DocPage.astro b/next/src/layouts/DocPage.astro
index 6aa5f84dac9..a5c9506622d 100644
--- a/next/src/layouts/DocPage.astro
+++ b/next/src/layouts/DocPage.astro
@@ -63,7 +63,7 @@ const hasAlternate = entry.hasTranslation && 
!canonicalOverride;
   search
 >
   <slot name="reference" />
-  <div class="container docs-layout" id={Astro.slots.has('reference') ? 
'admin-api-markdown' : undefined} hidden={Astro.props.hideContent} 
data-docs-content>
+  <div class="container docs-layout" id={Astro.slots.has('reference') ? 
'api-reference-markdown' : undefined} hidden={Astro.props.hideContent} 
data-docs-content>
     {sidebar.length > 0 && (
       <nav class="docs-sidebar" aria-label="Docs sidebar">
         {versionLabel && (
diff --git a/next/src/pages/docs/apisix/[...id].astro 
b/next/src/pages/docs/apisix/[...id].astro
index c701ec57c4e..bf8bbda2093 100644
--- a/next/src/pages/docs/apisix/[...id].astro
+++ b/next/src/pages/docs/apisix/[...id].astro
@@ -7,7 +7,7 @@ export function getStaticPaths() {
   // Sidebar config ids are path-derived; map them to post-override 
titles/urls.
   const titleById = new Map(docs.map((d) => [d.pathId, d.title]));
   const urlByPathId = new Map(docs.map((d) => [d.pathId, d.url]));
-  return docs.filter((entry) => entry.pathId !== 'admin-api').map((entry) => ({
+  return docs.filter((entry) => !['admin-api', 
'control-api'].includes(entry.pathId)).map((entry) => ({
     params: { id: entry.id },
     props: { entry, titleById, urlByPathId },
   }));
diff --git a/next/src/pages/docs/apisix/admin-api.astro 
b/next/src/pages/docs/apisix/admin-api.astro
index db5c4140e2f..68e23b1eb5b 100644
--- a/next/src/pages/docs/apisix/admin-api.astro
+++ b/next/src/pages/docs/apisix/admin-api.astro
@@ -1,4 +1,4 @@
 ---
 import AdminApiPage from '../../../layouts/AdminApiPage.astro';
 ---
-<AdminApiPage locale="en" />
+<AdminApiPage locale="en" apiId="admin-api" />
diff --git a/next/src/pages/docs/apisix/admin-api.astro 
b/next/src/pages/docs/apisix/control-api.astro
similarity index 59%
copy from next/src/pages/docs/apisix/admin-api.astro
copy to next/src/pages/docs/apisix/control-api.astro
index db5c4140e2f..88393f0d296 100644
--- a/next/src/pages/docs/apisix/admin-api.astro
+++ b/next/src/pages/docs/apisix/control-api.astro
@@ -1,4 +1,4 @@
 ---
 import AdminApiPage from '../../../layouts/AdminApiPage.astro';
 ---
-<AdminApiPage locale="en" />
+<AdminApiPage locale="en" apiId="control-api" />
diff --git a/next/src/pages/zh/docs/apisix/[...id].astro 
b/next/src/pages/zh/docs/apisix/[...id].astro
index f91d148789e..06b35fece41 100644
--- a/next/src/pages/zh/docs/apisix/[...id].astro
+++ b/next/src/pages/zh/docs/apisix/[...id].astro
@@ -7,7 +7,7 @@ export function getStaticPaths() {
   // Sidebar config ids are path-derived; map them to post-override 
titles/urls.
   const titleById = new Map(docs.map((d) => [d.pathId, d.title]));
   const urlByPathId = new Map(docs.map((d) => [d.pathId, d.url]));
-  return docs.filter((entry) => entry.pathId !== 'admin-api').map((entry) => ({
+  return docs.filter((entry) => !['admin-api', 
'control-api'].includes(entry.pathId)).map((entry) => ({
     params: { id: entry.id },
     props: { entry, titleById, urlByPathId },
   }));
diff --git a/next/src/pages/zh/docs/apisix/admin-api.astro 
b/next/src/pages/zh/docs/apisix/admin-api.astro
index 8e10a07387b..9480c917750 100644
--- a/next/src/pages/zh/docs/apisix/admin-api.astro
+++ b/next/src/pages/zh/docs/apisix/admin-api.astro
@@ -1,4 +1,4 @@
 ---
 import AdminApiPage from '../../../../layouts/AdminApiPage.astro';
 ---
-<AdminApiPage locale="zh" />
+<AdminApiPage locale="zh" apiId="admin-api" />
diff --git a/next/src/pages/zh/docs/apisix/admin-api.astro 
b/next/src/pages/zh/docs/apisix/control-api.astro
similarity index 60%
copy from next/src/pages/zh/docs/apisix/admin-api.astro
copy to next/src/pages/zh/docs/apisix/control-api.astro
index 8e10a07387b..aadacdcdb62 100644
--- a/next/src/pages/zh/docs/apisix/admin-api.astro
+++ b/next/src/pages/zh/docs/apisix/control-api.astro
@@ -1,4 +1,4 @@
 ---
 import AdminApiPage from '../../../../layouts/AdminApiPage.astro';
 ---
-<AdminApiPage locale="zh" />
+<AdminApiPage locale="zh" apiId="control-api" />
diff --git a/next/tests/admin-api/README.md b/next/tests/admin-api/README.md
index e8a7e6421ee..3ac68cf91b7 100644
--- a/next/tests/admin-api/README.md
+++ b/next/tests/admin-api/README.md
@@ -1,4 +1,4 @@
-# Admin API production checks
+# API reference production checks
 
 This suite needs the assembled production site: the current page is Astro,
 while the release archives and Next are Docusaurus. Serve that output, then 
run:
@@ -9,9 +9,9 @@ ADMIN_API_BASE_URL=http://127.0.0.1:4322 npm run test:admin-api
 ```
 
 The existing Playwright configuration runs desktop and mobile Chromium. The
-suite checks both locales for the current 3.19 page, 3.16–3.18 archives, and
-Next. Success tests
-require their actual synced canonical English specifications; they must not be
+suite checks both locales for the Admin API and Control API on the current 3.19
+page, 3.16–3.18 archives, and Next. Success tests require their actual synced
+canonical English specifications; they must not be
 reported as passed by substituting another release's JSON. Failure tests
 intercept requests to exercise missing, malformed, failed and timed-out JSON,
 and a failed Scalar script. A separate context checks disabled JavaScript.
@@ -25,9 +25,10 @@ source is absent or invalid. Sync and build from these 
apache/apisix refs:
 - archives: `release/3.16`, `release/3.17`, and `release/3.18`
 - Next: `master`
 
-The archive and Next syncs copy `docs/en/latest/admin-api.json` byte-for-byte 
to
-`doc/static/assets/openapi/apisix/<version>/admin-api.json`. Both locales use
-the same asset for each version. Missing sources remove stale assets and retain
+The archive and Next syncs copy `docs/en/latest/admin-api.json` and
+`docs/en/latest/control-api.json` byte-for-byte to
+`doc/static/assets/openapi/apisix/<version>/<api-id>.json`. Both locales use
+the same assets for each version. Missing sources remove stale assets and 
retain
 Markdown. Other release archives continue using Markdown.
 The assembled deployment already ships `doc/build/assets`, so no external
 specification request or additional deployment copy step is needed.
@@ -40,9 +41,13 @@ node --test shared/admin-api-spec.test.mjs 
scripts/lib/admin-api.test.js
 
 ## Immutable canonical validation fixtures
 
-`source-fixtures.json` pins the merged release and master specifications in
-`apache/apisix` for reproducible tests. Production synchronization continues
-to use the corresponding Apache branches.
+`source-fixtures.json` pins the merged Admin and Control API specifications in
+`apache/apisix` for reproducible tests. Each entry may identify an `apiId`;
+entries without one retain the Admin API default. Archive and Next fixtures
+validate the published same-origin assets; the current 3.19 Control API fixture
+validates the English source consumed by the Astro build. Do not pin 
contributor
+branches or forks. Production synchronization continues to use the 
corresponding
+Apache branches.
 
 After preparing generated static assets from those exact commits, check them
 without fetching or changing any content:
@@ -57,6 +62,11 @@ node next/scripts/check-admin-api-fixtures.mjs 
/path/to/apisix-fixture-checkout
 | `release/3.17` | 
https://github.com/apache/apisix/commit/9fae9b644357281976463626c624064c0464b5a1
 |
 | `release/3.18` | 
https://github.com/apache/apisix/commit/8d7e45597a065472ba33672581fb7fbafff523ca
 |
 | `master` | 
https://github.com/apache/apisix/commit/88a41b7f30da10072294ae48b1d1d7369452b1ac
 |
+| `release/3.16` Control API | 
https://github.com/apache/apisix/commit/0f0fef3304579dc495c1131f09c98d947aea9abc
 |
+| `release/3.17` Control API | 
https://github.com/apache/apisix/commit/36079fe60f55d88ffb27f513ac912f5b1729ff20
 |
+| `release/3.18` Control API | 
https://github.com/apache/apisix/commit/687d89c68f754d7192761ea55b38d276d1b85e82
 |
+| `release/3.19` Control API | 
https://github.com/apache/apisix/commit/8e4a79346658d93de8e5bed7f4fcbb6cf8fd4979
 |
+| `master` Control API | 
https://github.com/apache/apisix/commit/6205e4a568106eea8277f61ae6ef942bbff62d97
 |
 
 Run the fixture check against an `apache/apisix` checkout containing the pinned
 commit. Report fixture, canonical-source, build, and browser results 
separately.
diff --git a/next/tests/admin-api/admin-api.spec.mjs 
b/next/tests/admin-api/admin-api.spec.mjs
index f7c24ca6f39..a98024b8d0b 100644
--- a/next/tests/admin-api/admin-api.spec.mjs
+++ b/next/tests/admin-api/admin-api.spec.mjs
@@ -20,99 +20,109 @@ test.beforeEach(async ({ page }, testInfo) => {
 });
 
 const versions = ['', '3.16/', '3.17/', '3.18/', 'next/'];
+const apiIds = ['admin-api', 'control-api'];
 ['', '/zh'].forEach((locale) => {
-  versions.forEach((version) => {
-    const route = `${locale}/docs/apisix/${version}admin-api/`;
-    test(`${route} renders, downloads, switches versions and owns the search 
shortcut`, async ({ page }) => {
-      test.setTimeout(60000);
-      await page.goto(route, { waitUntil: 'domcontentloaded' });
-      const reference = page.locator('#admin-api-reference');
-      await expect(reference).toBeVisible({ timeout: 16000 });
-      await expect(page.locator('#api-reference-loading')).toHaveCount(0);
-      await expect(page.locator('#admin-api-markdown')).toBeHidden();
-      const download = page.locator('.api-download');
-      const href = await download.getAttribute('href');
-      expect(new URL(href, page.url()).origin).toBe(new 
URL(page.url()).origin);
-      const response = await page.request.get(href);
-      expect(response.ok()).toBeTruthy();
-      const spec = await response.json();
-      expect(spec.openapi).toMatch(/^3\./);
-      if (version === 'next/') {
-        await expect(page.locator('.scalar-api-reference .badge', { hasText: 
'Next' })).toBeVisible();
-        expect(spec.info.version).not.toBe('Next');
-      } else if (version) expect(spec.info.version).toMatch(new 
RegExp(`^${version.slice(0, -1).replace('.', '\\.')}([.]|$)`));
-      const downloaded = page.waitForEvent('download');
-      await download.click();
-      expect((await 
downloaded).suggestedFilename()).toBe('apisix-admin-api.json');
-      await page.locator('.api-version summary').click();
-      await expect(page.locator('.api-version 
a[aria-current="page"]')).toHaveCount(1);
-      await expect(page.locator('.api-version a', { hasText: '3.18' 
})).toHaveAttribute('href', `${locale}/docs/apisix/3.18/admin-api/`);
-      await page.keyboard.press('Escape');
-      await expect(page.locator('.api-version')).not.toHaveAttribute('open', 
'');
-      // Search dialogs must be opened and closed sequentially.
-      // eslint-disable-next-line no-restricted-syntax
-      for (const shortcut of ['Control+k', 'Meta+k']) {
-        await page.keyboard.press(shortcut);
-        await expect(page.locator('.scalar-modal-layout')).toHaveCount(1);
-        await expect(page.locator('.DocSearch-Container')).toHaveCount(0);
-        await page.keyboard.press(shortcut);
-        await expect(page.locator('.scalar-modal-layout')).toHaveCount(1);
-        await page.keyboard.press('Escape');
-      }
-      const hasNoOverflow = await page.evaluate(
-        () => document.documentElement.scrollWidth <= window.innerWidth + 1,
-      );
-      expect(hasNoOverflow).toBeTruthy();
-      const destination = version === 'next/' ? '' : 'next';
-      const destinationHref = `${locale}/docs/apisix/${destination ? 
`${destination}/` : ''}admin-api/`;
-      await page.locator('.api-version summary').click();
-      await page.locator(`.api-version a[href="${destinationHref}"]`).click();
-      await expect(page).toHaveURL(new RegExp(`/apisix/${destination ? 
`${destination}/` : ''}admin-api/$`));
-      await expect(page.locator('#admin-api-reference')).toBeVisible({ 
timeout: 16000 });
-    });
-
-    test(`${route} is readable without JavaScript`, async ({ browser, baseURL 
}) => {
-      const context = await browser.newContext({ javaScriptEnabled: false });
-      const page = await context.newPage();
-      await page.goto(`${baseURL}${route}`, { waitUntil: 'domcontentloaded' });
-      await expect(page.locator('#admin-api-markdown')).toBeVisible();
-      await expect(page.locator('#admin-api-openapi')).toBeHidden();
-      await context.close();
-    });
-
-    ['missing', 'invalid-json', 'invalid-spec', 'network', 'timeout', 
'scalar-script'].forEach((mode) => {
-      test(`${route} restores Markdown on ${mode}`, async ({ page }) => {
-        if (mode === 'timeout') await page.clock.install();
-        if (mode === 'scalar-script') {
-          await page.route('**/standalone.js', (request) => request.abort());
-        } else {
-          await page.route('**/*admin-api*.json', (request) => {
-            if (mode === 'missing') return request.fulfill({ status: 404, 
body: '' });
-            if (mode === 'network') return request.abort();
-            if (mode === 'timeout') return new Promise(() => {});
-            return request.fulfill({ contentType: 'application/json', body: 
mode === 'invalid-json' ? '{' : '{"openapi":3,"paths":null}' });
-          });
-        }
-        const requested = mode === 'timeout' ? 
page.waitForRequest('**/*admin-api*.json') : undefined;
+  apiIds.forEach((apiId) => {
+    versions.forEach((version) => {
+      const route = `${locale}/docs/apisix/${version}${apiId}/`;
+      test(`${route} renders, downloads, switches versions and owns the search 
shortcut`, async ({ page }) => {
+        test.setTimeout(60000);
         await page.goto(route, { waitUntil: 'domcontentloaded' });
-        if (mode === 'timeout') {
-          await requested;
-          await expect(page.locator('#api-reference-loading')).toBeVisible();
-          await page.clock.fastForward(16000);
+        const reference = page.locator('#api-reference-container');
+        await expect(reference).toBeVisible({ timeout: 16000 });
+        await expect(page.locator('#api-reference-loading')).toHaveCount(0);
+        await expect(page.locator('#api-reference-markdown')).toBeHidden();
+        const download = page.locator('.api-download');
+        const href = await download.getAttribute('href');
+        expect(new URL(href, page.url()).origin).toBe(new 
URL(page.url()).origin);
+        const response = await page.request.get(href);
+        expect(response.ok()).toBeTruthy();
+        const spec = await response.json();
+        expect(spec.openapi).toMatch(/^3\./);
+        if (version === 'next/') {
+          await expect(page.locator('.scalar-api-reference .badge', { hasText: 
'Next' })).toBeVisible();
+          expect(spec.info.version).not.toBe('Next');
+        } else if (version) expect(spec.info.version).toMatch(new 
RegExp(`^${version.slice(0, -1).replace('.', '\\.')}([.]|$)`));
+        const downloaded = page.waitForEvent('download');
+        await download.click();
+        expect((await 
downloaded).suggestedFilename()).toBe(`apisix-${apiId}.json`);
+        await page.locator('.api-version summary').click();
+        await expect(page.locator('.api-version 
a[aria-current="page"]')).toHaveCount(1);
+        await expect(page.locator('.api-version a', { hasText: '3.18' 
})).toHaveAttribute('href', `${locale}/docs/apisix/3.18/${apiId}/`);
+        await page.keyboard.press('Escape');
+        await expect(page.locator('.api-version')).not.toHaveAttribute('open', 
'');
+        // eslint-disable-next-line no-restricted-syntax
+        for (const shortcut of ['Control+k', 'Meta+k']) {
+          await page.keyboard.press(shortcut);
+          await expect(page.locator('.scalar-modal-layout')).toHaveCount(1);
+          await expect(page.locator('.DocSearch-Container')).toHaveCount(0);
+          await page.keyboard.press(shortcut);
+          await expect(page.locator('.scalar-modal-layout')).toHaveCount(1);
+          await page.keyboard.press('Escape');
         }
-        await expect(page.locator('#admin-api-markdown')).toBeVisible({ 
timeout: 16000 });
-        await expect(page.locator('#admin-api-openapi')).toBeHidden();
         const hasNoOverflow = await page.evaluate(
           () => document.documentElement.scrollWidth <= window.innerWidth + 1,
         );
         expect(hasNoOverflow).toBeTruthy();
+        const destination = version === 'next/' ? '' : 'next';
+        const destinationHref = `${locale}/docs/apisix/${destination ? 
`${destination}/` : ''}${apiId}/`;
+        await page.locator('.api-version summary').click();
+        await page.locator(`.api-version 
a[href="${destinationHref}"]`).click();
+        await expect(page).toHaveURL(new RegExp(`/apisix/${destination ? 
`${destination}/` : ''}${apiId}/$`));
+        await expect(page.locator('#api-reference-container')).toBeVisible({ 
timeout: 16000 });
+      });
+
+      test(`${route} is readable without JavaScript`, async ({ browser, 
baseURL }) => {
+        const context = await browser.newContext({ javaScriptEnabled: false });
+        const page = await context.newPage();
+        await page.goto(`${baseURL}${route}`, { waitUntil: 'domcontentloaded' 
});
+        await expect(page.locator('#api-reference-markdown')).toBeVisible();
+        await expect(page.locator('#api-reference-openapi')).toBeHidden();
+        await context.close();
+      });
+
+      ['missing', 'invalid-json', 'invalid-spec', 'network', 'timeout', 
'scalar-script'].forEach((mode) => {
+        test(`${route} restores Markdown on ${mode}`, async ({ page }) => {
+          if (mode === 'timeout') await page.clock.install();
+          if (mode === 'scalar-script') {
+            await page.route('**/standalone.js', (request) => request.abort());
+          } else {
+            await page.route(`**/*${apiId}*.json`, (request) => {
+              if (mode === 'missing') return request.fulfill({ status: 404, 
body: '' });
+              if (mode === 'network') return request.abort();
+              if (mode === 'timeout') return new Promise(() => {});
+              return request.fulfill({ contentType: 'application/json', body: 
mode === 'invalid-json' ? '{' : '{"openapi":3,"paths":null}' });
+            });
+          }
+          const requested = mode === 'timeout' ? 
page.waitForRequest(`**/*${apiId}*.json`) : undefined;
+          await page.goto(route, { waitUntil: 'domcontentloaded' });
+          if (mode === 'timeout') {
+            await requested;
+            await expect(page.locator('#api-reference-loading')).toBeVisible();
+            await expect(page.locator('#api-reference-markdown')).toBeHidden();
+            await page.clock.fastForward(16000);
+          }
+          await expect(page.locator('#api-reference-markdown')).toBeVisible({ 
timeout: 16000 });
+          await expect(page.locator('#api-reference-openapi')).toBeHidden();
+          const hasNoOverflow = await page.evaluate(
+            () => document.documentElement.scrollWidth <= window.innerWidth + 
1,
+          );
+          expect(hasNoOverflow).toBeTruthy();
+        });
       });
     });
-  });
 
-  test(`${locale}/docs/apisix/3.15/admin-api/ remains Markdown-only`, async ({ 
page }) => {
-    await page.goto(`${locale}/docs/apisix/3.15/admin-api/`, { waitUntil: 
'domcontentloaded' });
-    await expect(page.locator('#admin-api-openapi')).toHaveCount(0);
-    await expect(page.locator('article')).toBeVisible();
+    test(`${locale}/docs/apisix/3.18/${apiId}/ shows loading without flashing 
Markdown`, async ({ page }) => {
+      await page.route(`**/*${apiId}*.json`, () => new Promise(() => {}));
+      await page.goto(`${locale}/docs/apisix/3.18/${apiId}/`, { waitUntil: 
'domcontentloaded' });
+      await expect(page.locator('#api-reference-loading')).toBeVisible();
+      await expect(page.locator('#api-reference-markdown')).toBeHidden();
+    });
+
+    test(`${locale}/docs/apisix/3.15/${apiId}/ remains Markdown-only`, async 
({ page }) => {
+      await page.goto(`${locale}/docs/apisix/3.15/${apiId}/`, { waitUntil: 
'domcontentloaded' });
+      await expect(page.locator('#api-reference-openapi')).toHaveCount(0);
+      await expect(page.locator('article')).toBeVisible();
+    });
   });
 });
diff --git a/next/tests/admin-api/source-fixtures.json 
b/next/tests/admin-api/source-fixtures.json
index 92a8143b1d0..4cdf775eb59 100644
--- a/next/tests/admin-api/source-fixtures.json
+++ b/next/tests/admin-api/source-fixtures.json
@@ -18,5 +18,36 @@
     "version": "next",
     "canonicalRef": "master",
     "commit": "88a41b7f30da10072294ae48b1d1d7369452b1ac"
+  },
+  {
+    "version": "3.16",
+    "apiId": "control-api",
+    "canonicalRef": "release/3.16",
+    "commit": "0f0fef3304579dc495c1131f09c98d947aea9abc"
+  },
+  {
+    "version": "3.17",
+    "apiId": "control-api",
+    "canonicalRef": "release/3.17",
+    "commit": "36079fe60f55d88ffb27f513ac912f5b1729ff20"
+  },
+  {
+    "version": "3.18",
+    "apiId": "control-api",
+    "canonicalRef": "release/3.18",
+    "commit": "687d89c68f754d7192761ea55b38d276d1b85e82"
+  },
+  {
+    "version": "3.19.0",
+    "apiId": "control-api",
+    "canonicalRef": "release/3.19",
+    "commit": "8e4a79346658d93de8e5bed7f4fcbb6cf8fd4979",
+    "generatedPath": "next/content/docs-apisix-en/control-api.json"
+  },
+  {
+    "version": "next",
+    "apiId": "control-api",
+    "canonicalRef": "master",
+    "commit": "6205e4a568106eea8277f61ae6ef942bbff62d97"
   }
 ]
diff --git a/scripts/lib/admin-api.js b/scripts/lib/admin-api.js
index 44b30e26538..97c2abab3ad 100644
--- a/scripts/lib/admin-api.js
+++ b/scripts/lib/admin-api.js
@@ -2,15 +2,27 @@ const fs = require('fs/promises');
 
 // Specs always come from the English tree of the checked-out apache/apisix
 // ref. Both locales request this same-origin asset, never a docs-site copy.
-async function publishAdminApiSpec(source, version, staticRoot) {
+const API_REFERENCES = ['admin-api', 'control-api'];
+
+async function publishApiReferenceSpecs(source, version, staticRoot) {
   const target = `${staticRoot}/assets/openapi/apisix/${version}`;
   await fs.rm(target, { recursive: true, force: true });
-  try { await fs.access(`${source}/admin-api.json`); } catch (error) {
-    if (error.code === 'ENOENT') return;
-    throw error;
+  const available = (await Promise.all(API_REFERENCES.map(async (apiId) => {
+    try {
+      await fs.access(`${source}/${apiId}.json`);
+      return apiId;
+    } catch (error) {
+      if (error.code === 'ENOENT') return undefined;
+      throw error;
+    }
+  }))).filter(Boolean);
+  if (available.length > 0) {
+    await fs.mkdir(target, { recursive: true });
+    await Promise.all(available.map((apiId) => (
+      fs.copyFile(`${source}/${apiId}.json`, `${target}/${apiId}.json`)
+    )));
   }
-  await fs.mkdir(target, { recursive: true });
-  await fs.copyFile(`${source}/admin-api.json`, `${target}/admin-api.json`);
+  return available;
 }
 
-module.exports = { publishAdminApiSpec };
+module.exports = { API_REFERENCES, publishApiReferenceSpecs };
diff --git a/scripts/lib/admin-api.test.js b/scripts/lib/admin-api.test.js
index a2f066ca4cd..433e53cf14d 100644
--- a/scripts/lib/admin-api.test.js
+++ b/scripts/lib/admin-api.test.js
@@ -3,7 +3,7 @@ const fs = require('node:fs/promises');
 const os = require('node:os');
 const path = require('node:path');
 const { test } = require('node:test');
-const { publishAdminApiSpec } = require('./admin-api');
+const { publishApiReferenceSpecs } = require('./admin-api');
 
 test('publishes the selected English spec byte-for-byte and removes stale 
assets', async () => {
   const root = await fs.mkdtemp(path.join(os.tmpdir(), 'apisix-openapi-'));
@@ -12,13 +12,22 @@ test('publishes the selected English spec byte-for-byte and 
removes stale assets
     const output = path.join(root, 'static');
     await fs.mkdir(source);
     const spec = 
'{"openapi":"3.1.0","info":{"version":"3.19.0"},"paths":{}}\n';
-    await fs.writeFile(path.join(source, 'admin-api.json'), spec);
-    await publishAdminApiSpec(source, 'next', output);
-    const asset = path.join(output, 
'assets/openapi/apisix/next/admin-api.json');
-    assert.equal(await fs.readFile(asset, 'utf8'), spec);
+    const controlSpec = spec.replace('3.19.0', '3.19.1');
+    await Promise.all([
+      fs.writeFile(path.join(source, 'admin-api.json'), spec),
+      fs.writeFile(path.join(source, 'control-api.json'), controlSpec),
+    ]);
+    assert.deepEqual(
+      await publishApiReferenceSpecs(source, 'next', output),
+      ['admin-api', 'control-api'],
+    );
+    const target = path.join(output, 'assets/openapi/apisix/next');
+    assert.equal(await fs.readFile(path.join(target, 'admin-api.json'), 
'utf8'), spec);
+    assert.equal(await fs.readFile(path.join(target, 'control-api.json'), 
'utf8'), controlSpec);
     await fs.rm(path.join(source, 'admin-api.json'));
-    await publishAdminApiSpec(source, 'next', output);
-    await assert.rejects(fs.access(asset), { code: 'ENOENT' });
+    assert.deepEqual(await publishApiReferenceSpecs(source, 'next', output), 
['control-api']);
+    await assert.rejects(fs.access(path.join(target, 'admin-api.json')), { 
code: 'ENOENT' });
+    assert.equal(await fs.readFile(path.join(target, 'control-api.json'), 
'utf8'), controlSpec);
   } finally {
     await fs.rm(root, { recursive: true, force: true });
   }
diff --git a/scripts/sync-docs.js b/scripts/sync-docs.js
index a80afcb757c..386d115fcb4 100644
--- a/scripts/sync-docs.js
+++ b/scripts/sync-docs.js
@@ -10,7 +10,7 @@ const semver = require('semver');
 const replace = require('replace-in-file');
 
 const common = require('./common.js');
-const { publishAdminApiSpec } = require('./lib/admin-api.js');
+const { publishApiReferenceSpecs } = require('./lib/admin-api.js');
 const { versions, versionMap } = require('../config/apisix-versions.js');
 
 const { projects, languages, projectPaths } = common;
@@ -399,7 +399,7 @@ function extractDocsVersionTasks(project, version) {
         const zhTargetDocs = 
`${websitePath}/i18n/zh/docusaurus-plugin-content-docs-docs-${projectName}/version-${displayVersionName}`;
 
         if (projectName === 'apisix' && APISIX_SCALAR_ARCHIVES.has(version)) {
-          await publishAdminApiSpec(enSrcDocs, version, 
`${websitePath}/static`);
+          await publishApiReferenceSpecs(enSrcDocs, version, 
`${websitePath}/static`);
         }
 
         await Promise.allSettled([
@@ -447,7 +447,7 @@ function extractDocsNextVersionTasks(project, version) {
         const enTargetDocs = `${websitePath}/docs/${projectName}`;
         const zhTargetDocs = 
`${websitePath}/i18n/zh/docusaurus-plugin-content-docs-docs-${projectName}/current`;
 
-        if (projectName === 'apisix') await publishAdminApiSpec(enSrcDocs, 
'next', `${websitePath}/static`);
+        if (projectName === 'apisix') await 
publishApiReferenceSpecs(enSrcDocs, 'next', `${websitePath}/static`);
 
         await Promise.all([
           copyDocs(enSrcDocs, enTargetDocs)
diff --git a/shared/admin-api-scalar.css b/shared/admin-api-scalar.css
index 182b8c3bf5f..0b8726d3a78 100644
--- a/shared/admin-api-scalar.css
+++ b/shared/admin-api-scalar.css
@@ -20,34 +20,34 @@
   --scalar-border-color: #e8ecf1;
 }
 
-#admin-api-reference .introduction-section {
+#api-reference-container .introduction-section {
   padding-top: 32px;
   padding-bottom: 36px;
 }
 
-#admin-api-reference .introduction-card {
+#api-reference-container .introduction-card {
   border-radius: 10px;
 }
 
-#admin-api-reference .explore-scalar,
-#admin-api-reference a[href*="utm_source=powered-by"] {
+#api-reference-container .explore-scalar,
+#api-reference-container a[href*="utm_source=powered-by"] {
   display: none;
 }
 
-#admin-api-reference h1 {
+#api-reference-container h1 {
   font-size: clamp(24px, 2.5vw, 32px);
   letter-spacing: -0.035em;
   line-height: 1.25;
 }
 
-#admin-api-reference h2 {
+#api-reference-container h2 {
   font-size: 28px;
   letter-spacing: -0.025em;
   line-height: 1.3;
 }
 
 @media (max-width: 640px) {
-  #admin-api-reference .introduction-section {
+  #api-reference-container .introduction-section {
     padding-top: 24px;
     padding-bottom: 24px;
   }
diff --git a/shared/admin-api-spec.test.mjs b/shared/admin-api-spec.test.mjs
index 2ce1b23dabe..acc62c01ec8 100644
--- a/shared/admin-api-spec.test.mjs
+++ b/shared/admin-api-spec.test.mjs
@@ -1,27 +1,29 @@
 import assert from 'node:assert/strict';
 import { test } from 'node:test';
-import { adminApiDisplaySpec, isAdminApiSpec } from './admin-api-spec.ts';
+import { apiReferenceDisplaySpec, isApiReferenceSpec } from 
'./admin-api-spec.ts';
 
 test('accepts patch releases and development versions without crossing release 
series', () => {
   const spec = { openapi: '3.1.0', info: { title: 'Admin API', version: 
'3.18.0' }, paths: {} };
-  assert.equal(isAdminApiSpec(spec, '3.18'), true);
-  assert.equal(isAdminApiSpec(spec, '3.19'), false);
-  assert.equal(isAdminApiSpec(spec, 'next'), true);
-  assert.equal(isAdminApiSpec({ ...spec, info: { title: 'Admin API', version: 
'3.19.0' } }, '3.19.0'), true);
+  assert.equal(isApiReferenceSpec(spec, '3.18', 'admin-api'), true);
+  assert.equal(isApiReferenceSpec(spec, '3.19', 'admin-api'), false);
+  assert.equal(isApiReferenceSpec(spec, 'next', 'admin-api'), true);
+  assert.equal(isApiReferenceSpec({ ...spec, info: { title: 'Admin API', 
version: '3.19.0' } }, '3.19.0', 'admin-api'), true);
+  assert.equal(isApiReferenceSpec({ ...spec, info: { ...spec.info, title: 
'Control API' } }, '3.18', 'control-api'), true);
+  assert.equal(isApiReferenceSpec(spec, '3.18', 'control-api'), false);
 });
 
 test('labels only the rendered Next copy without mutating the canonical spec', 
() => {
   const spec = { openapi: '3.1.0', info: { title: 'Admin API', version: 
'3.19.0' }, paths: {} };
-  const display = adminApiDisplaySpec(spec, 'next');
+  const display = apiReferenceDisplaySpec(spec, 'next');
   assert.equal(display.info.version, 'Next');
   assert.equal(spec.info.version, '3.19.0');
-  assert.equal(adminApiDisplaySpec(spec, '3.19'), spec);
+  assert.equal(apiReferenceDisplaySpec(spec, '3.19'), spec);
 });
 
 test('rejects malformed documents without throwing', () => {
   [null, [], {}, { openapi: 3 },
     { openapi: '3.1.0', info: { title: 'Admin API', version: '3.18.0' }, 
paths: null },
     { openapi: '3.1.0', info: { title: 'Admin API', version: '3.18.0' }, 
paths: [] }].forEach((spec) => {
-    assert.equal(isAdminApiSpec(spec, '3.18'), false);
+    assert.equal(isApiReferenceSpec(spec, '3.18'), false);
   });
 });
diff --git a/shared/admin-api-spec.ts b/shared/admin-api-spec.ts
index 92b7e926a6a..3c734f044f0 100644
--- a/shared/admin-api-spec.ts
+++ b/shared/admin-api-spec.ts
@@ -4,17 +4,21 @@ const isObject = (value: unknown): value is { [key: string]: 
unknown } => (
 
 /** Accept release patch versions; Next is deliberately not tied to a release. 
*/
 // eslint-disable-next-line import/prefer-default-export
-export function isAdminApiSpec(spec: unknown, version?: string): boolean {
+export function isApiReferenceSpec(spec: unknown, version?: string, apiId?: 
string): boolean {
   if (!isObject(spec) || !isObject(spec.info)) return false;
+  let expectedTitle: RegExp | undefined;
+  if (apiId === 'admin-api') expectedTitle = /\bAdmin API\b/i;
+  if (apiId === 'control-api') expectedTitle = /\bControl API\b/i;
   return typeof spec.openapi === 'string' && spec.openapi.startsWith('3.')
     && typeof spec.info.title === 'string'
+    && (!expectedTitle || expectedTitle.test(spec.info.title))
     && typeof spec.info.version === 'string'
     && (!version || version === 'next' || spec.info.version === version || 
spec.info.version.startsWith(`${version}.`))
     && isObject(spec.paths);
 }
 
 /** Keep the canonical Next contract intact while labeling its rendered 
preview. */
-export function adminApiDisplaySpec(spec: unknown, version?: string): unknown {
+export function apiReferenceDisplaySpec(spec: unknown, version?: string): 
unknown {
   if (version !== 'next' || !isObject(spec) || !isObject(spec.info)) return 
spec;
   return { ...spec, info: { ...spec.info, version: 'Next' } };
 }
diff --git a/shared/admin-api-toolbar.css b/shared/admin-api-toolbar.css
index 5ea025aebd2..6ebf6cace53 100644
--- a/shared/admin-api-toolbar.css
+++ b/shared/admin-api-toolbar.css
@@ -1,6 +1,6 @@
-#admin-api-openapi[hidden],
-#admin-api-reference[hidden],
-#admin-api-markdown[hidden] {
+#api-reference-openapi[hidden],
+#api-reference-container[hidden],
+#api-reference-markdown[hidden] {
   display: none;
 }
 
@@ -147,7 +147,7 @@
   outline-offset: 3px;
 }
 
-#admin-api-reference {
+#api-reference-container {
   min-height: 60vh;
 }
 
diff --git a/shared/admin-api.ts b/shared/admin-api.ts
index e523fdbe7c8..bd3e6ca5223 100644
--- a/shared/admin-api.ts
+++ b/shared/admin-api.ts
@@ -1,4 +1,4 @@
-import { adminApiDisplaySpec, isAdminApiSpec } from './admin-api-spec';
+import { apiReferenceDisplaySpec, isApiReferenceSpec } from './admin-api-spec';
 
 declare const Scalar: {
   createApiReference: (
@@ -9,7 +9,7 @@ declare const Scalar: {
 
 /** Shared by Astro and the Docusaurus archives. Dispose on SPA navigation. */
 // eslint-disable-next-line import/prefer-default-export
-export function mountAdminApi(
+export function mountApiReference(
   root: ParentNode,
   scalarTheme: string,
   version?: string,
@@ -25,9 +25,9 @@ export function mountAdminApi(
     target.addEventListener(type, handler as EventListener, capture);
     cleanups.push(() => target.removeEventListener(type, handler as 
EventListener, capture));
   };
-  const reference = root.querySelector<HTMLElement>('#admin-api-openapi');
+  const reference = root.querySelector<HTMLElement>('#api-reference-openapi');
   const markdown = root.querySelector<HTMLElement>('[data-docs-content]');
-  const container = root.querySelector<HTMLElement>('#admin-api-reference');
+  const container = 
root.querySelector<HTMLElement>('#api-reference-container');
   if (reference && markdown && container) {
     const toolbar = root.querySelector<HTMLElement>('.api-toolbar');
     if (toolbar) {
@@ -65,11 +65,9 @@ export function mountAdminApi(
           || !(event.ctrlKey || event.metaKey)
           || event.key.toLowerCase() !== 'k'
         ) return;
-        const searches = [
-          ...container.querySelectorAll<HTMLButtonElement>(
-            'button:has(.sidebar-search-placeholder)',
-          ),
-        ];
+        const searches = 
Array.from(container.querySelectorAll<HTMLButtonElement>(
+          'button:has(.sidebar-search-placeholder)',
+        ));
         const search = searches.find((button) => 
button.getClientRects().length > 0)
           ?? searches[0];
         if (!search) return;
@@ -105,7 +103,14 @@ export function mountAdminApi(
         if (!response.ok) throw new Error('Unable to load local OpenAPI');
         const spec = await response.json();
         const specVersion = version ?? container.dataset.specVersion;
-        if (!isAdminApiSpec(spec, specVersion)) throw new Error('Invalid 
OpenAPI document');
+        if (!isApiReferenceSpec(spec, specVersion, container.dataset.apiId)) {
+          throw new Error('Invalid OpenAPI document');
+        }
+        // Astro hides Markdown at build time and the archives hide it when
+        // their client mounts. Keep this assignment here so any future host
+        // enters the same loading state only after validation.
+        reference.hidden = false;
+        markdown.hidden = true;
         await new Promise<void>((resolve, reject) => {
           const script = document.createElement('script');
           script.src = 
'https://cdn.jsdelivr.net/npm/@scalar/[email protected]/dist/browser/standalone.js';
@@ -115,7 +120,7 @@ export function mountAdminApi(
         });
         if (disposed || reference.hidden) return;
         const instance = Scalar.createApiReference(container, {
-          content: adminApiDisplaySpec(spec, specVersion),
+          content: apiReferenceDisplaySpec(spec, specVersion),
           onLoaded: () => {
             if (disposed || reference.hidden) return;
             window.clearTimeout(timeout);
@@ -134,7 +139,7 @@ export function mountAdminApi(
           withDefaultFonts: false,
           persistAuth: false,
           operationTitleSource: 'path',
-          // Admin credentials and requests stay in the browser; no hosted 
proxy.
+          // API credentials and requests stay in the browser; no hosted proxy.
           proxyUrl: '',
           telemetry: false,
           agent: { disabled: true },

Reply via email to