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 0ef68062435 feat(docs): render Next Admin API with Scalar (#2144)
0ef68062435 is described below

commit 0ef68062435ed41cac534c534d9953fed4932ce5
Author: Traky Deng <[email protected]>
AuthorDate: Fri Oct 30 11:15:58 2026 +0800

    feat(docs): render Next Admin API with Scalar (#2144)
    
    * feat(docs): render Next Admin API with Scalar
    
    * test(docs): refresh Next contract fixture pin
    
    * test(docs): pin merged Next Admin API contract
---
 .eslintrc.js                                     |  13 ++
 .gitignore                                       |   2 +
 doc/src/components/AdminApiReference/index.tsx   |  71 ++++++++++
 doc/src/components/AdminApiReference/styles.css  |  18 +++
 doc/src/theme/DocPage/index.tsx                  | 147 +++++++++----------
 next/package.json                                |   2 +-
 next/playwright.admin-api.config.mjs             |  17 +++
 next/scripts/check-admin-api-fixtures.mjs        |  25 ++++
 next/scripts/check-admin-api-sources.mjs         |  24 ++++
 next/src/layouts/AdminApiPage.astro              | 134 +-----------------
 next/src/lib/content.ts                          |   2 +-
 next/tests/admin-api/README.md                   |  57 ++++++++
 next/tests/admin-api/admin-api.spec.mjs          | 109 +++++++++++++++
 next/tests/admin-api/source-fixtures.json        |   7 +
 scripts/lib/admin-api.js                         |  16 +++
 scripts/lib/admin-api.test.js                    |  25 ++++
 scripts/sync-docs.js                             |   3 +
 {next/src/styles => shared}/admin-api-scalar.css |   5 +-
 shared/admin-api-spec.test.mjs                   |  19 +++
 shared/admin-api-spec.ts                         |  14 ++
 shared/admin-api-toolbar.css                     | 171 +++++++++++++++++++++++
 shared/admin-api.ts                              | 155 ++++++++++++++++++++
 22 files changed, 834 insertions(+), 202 deletions(-)

diff --git a/.eslintrc.js b/.eslintrc.js
index 3212fe5ca0b..e1e2e2e486d 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -72,6 +72,19 @@ module.exports = {
     'consistent-return': OFF,
   },
   overrides: [
+    {
+      files: ['next/playwright.admin-api.config.mjs', 
'next/tests/admin-api/*.mjs'],
+      rules: {
+        'import/no-extraneous-dependencies': [ERROR, { devDependencies: true 
}],
+      },
+    },
+    {
+      // Node's native TypeScript entry points require explicit extensions.
+      files: ['next/scripts/check-admin-api-*.mjs', 
'shared/admin-api-spec.test.mjs'],
+      rules: {
+        'import/extensions': [ERROR, 'ignorePackages', { ts: 'always', js: 
'always' }],
+      },
+    },
     {
       files: [
         'scripts/**/*.js',
diff --git a/.gitignore b/.gitignore
index 96b1dc01547..2977310be76 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,3 +54,5 @@ scripts/temp/**
 /blog/src/theme/Footer/
 /doc/src/theme/Footer/event-poster-card.json
 website/src/theme/Footer/event-poster-card.json
+
+/doc/static/assets/openapi/
diff --git a/doc/src/components/AdminApiReference/index.tsx 
b/doc/src/components/AdminApiReference/index.tsx
new file mode 100644
index 00000000000..5f8ece94d61
--- /dev/null
+++ b/doc/src/components/AdminApiReference/index.tsx
@@ -0,0 +1,71 @@
+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 '../../../../shared/admin-api-toolbar.css';
+import './styles.css';
+
+interface Props {
+  path: string;
+  children: React.ReactNode;
+}
+
+// The route owns the version, including during client-side version switching.
+const AdminApiReference = ({ path, children }: Props): JSX.Element => {
+  const root = useRef<HTMLDivElement>(null);
+  const match = path.match(/^(\/zh)?\/docs\/apisix\/([^/]+)\/admin-api\/?$/);
+  const prefix = match?.[1] ?? '';
+  // Roll out the unreleased master contract first. Release archives remain on
+  // Markdown until their branch-specific specifications are merged and wired.
+  const supported = ['next'];
+  const version = supported.includes(match?.[2]) ? match?.[2] : undefined;
+  const zh = prefix === '/zh';
+  const specUrl = `/assets/openapi/apisix/${version}/admin-api.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.
+    const markdown = 
root.current.querySelector<HTMLElement>('[data-docs-content]');
+    if (markdown) markdown.hidden = true;
+    return mountAdminApi(root.current, scalarTheme, version);
+  }, [path]);
+  if (!version) return children as JSX.Element;
+  const versions = [...releaseVersions].reverse().concat('next');
+  const latest = releaseVersions[releaseVersions.length - 1];
+  const versionLabel = (v: string) => {
+    if (v !== 'next') return v;
+    return zh ? '开发版 (next)' : 'Next (unreleased)';
+  };
+  return (
+    <div ref={root} className="archived-admin-api">
+      <section id="admin-api-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>
+            <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>
+                ))}
+              </ul>
+            </details>
+          </div>
+          <a className="api-action api-download" href={specUrl} 
download="apisix-admin-api.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 />
+      </section>
+      <noscript><style>{'#admin-api-openapi { display: none; 
}'}</style></noscript>
+      <div id="admin-api-markdown" data-docs-content>{children}</div>
+    </div>
+  );
+};
+
+export default AdminApiReference;
diff --git a/doc/src/components/AdminApiReference/styles.css 
b/doc/src/components/AdminApiReference/styles.css
new file mode 100644
index 00000000000..91876b2172b
--- /dev/null
+++ b/doc/src/components/AdminApiReference/styles.css
@@ -0,0 +1,18 @@
+.archived-admin-api {
+  --header-height: var(--ifm-navbar-height, 60px);
+  --font-sans: var(--ifm-font-family-base);
+  --font-mono: var(--ifm-font-family-monospace);
+  --scalar-font: var(--ifm-font-family-base);
+  --scalar-font-code: var(--ifm-font-family-monospace);
+  --scalar-custom-header-height:
+    calc(
+      var(--ifm-navbar-height, 60px) + var(--api-toolbar-height, 56px)
+    );
+
+  width: 100%;
+  min-width: 0;
+}
+
+.archived-admin-api #admin-api-markdown:not([hidden]) {
+  display: block;
+}
diff --git a/doc/src/theme/DocPage/index.tsx b/doc/src/theme/DocPage/index.tsx
index 0e79109c2fa..d4f0b46b5e1 100644
--- a/doc/src/theme/DocPage/index.tsx
+++ b/doc/src/theme/DocPage/index.tsx
@@ -34,6 +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 styles from './styles.module.css';
 import 'react-lazy-load-image-component/src/effects/blur.css';
@@ -408,80 +409,82 @@ const DocPageContent = ({
         tag: docVersionSearchTag(pluginId, version),
       }}
     >
-      <div className={styles.docPage}>
-        <BackToTopButton />
-
-        {sidebar && (
-          <aside
-            className={clsx(styles.docSidebarContainer, {
-              [styles.docSidebarContainerHidden]: hiddenSidebarContainer,
-            })}
-            onTransitionEnd={(e) => {
-              if 
(!e.currentTarget.classList.contains(styles.docSidebarContainer)) {
-                return;
-              }
-
-              if (hiddenSidebarContainer) {
-                setHiddenSidebar(true);
-              }
-            }}
-          >
-            <DocSidebar
-              key={
-                // Reset sidebar state on sidebar changes
-                // See https://github.com/facebook/docusaurus/issues/3414
-                sidebarName
-              }
-              sidebar={sidebar}
-              path={currentDocRoute.path}
-              onCollapse={toggleSidebar}
-              isHidden={hiddenSidebar}
-              // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-              // @ts-ignore
-              docsPluginId={pluginId}
-            />
-
-            {hiddenSidebar && (
-              <div
-                className={styles.collapsedDocSidebar}
-                title={translate({
-                  id: 'theme.docs.sidebar.expandButtonTitle',
-                  message: 'Expand sidebar',
-                  description:
-                    'The ARIA label and title attribute for expand button of 
doc sidebar',
-                })}
-                aria-label={translate({
-                  id: 'theme.docs.sidebar.expandButtonAriaLabel',
-                  message: 'Expand sidebar',
-                  description:
-                    'The ARIA label and title attribute for expand button of 
doc sidebar',
-                })}
-                tabIndex={0}
-                role="button"
-                onKeyDown={toggleSidebar}
-                onClick={toggleSidebar}
-              >
-                <IconArrow className={styles.expandSidebarButtonIcon} />
-              </div>
-            )}
-          </aside>
-        )}
-        <main
-          className={clsx(styles.docMainContainer, {
-            [styles.docMainContainerEnhanced]: hiddenSidebarContainer || 
!sidebar,
-          })}
-        >
-          <div
-            className={clsx('container padding-top--md padding-bottom--lg', 
styles.docItemWrapper, {
-              [styles.docItemWrapperEnhanced]: hiddenSidebarContainer,
+      <AdminApiReference key={currentDocRoute.path} 
path={currentDocRoute.path}>
+        <div className={styles.docPage}>
+          <BackToTopButton />
+
+          {sidebar && (
+            <aside
+              className={clsx(styles.docSidebarContainer, {
+                [styles.docSidebarContainerHidden]: hiddenSidebarContainer,
+              })}
+              onTransitionEnd={(e) => {
+                if 
(!e.currentTarget.classList.contains(styles.docSidebarContainer)) {
+                  return;
+                }
+
+                if (hiddenSidebarContainer) {
+                  setHiddenSidebar(true);
+                }
+              }}
+            >
+              <DocSidebar
+                key={
+                  // Reset sidebar state on sidebar changes
+                  // See https://github.com/facebook/docusaurus/issues/3414
+                  sidebarName
+                }
+                sidebar={sidebar}
+                path={currentDocRoute.path}
+                onCollapse={toggleSidebar}
+                isHidden={hiddenSidebar}
+                // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+                // @ts-ignore
+                docsPluginId={pluginId}
+              />
+
+              {hiddenSidebar && (
+                <div
+                  className={styles.collapsedDocSidebar}
+                  title={translate({
+                    id: 'theme.docs.sidebar.expandButtonTitle',
+                    message: 'Expand sidebar',
+                    description:
+                      'The ARIA label and title attribute for expand button of 
doc sidebar',
+                  })}
+                  aria-label={translate({
+                    id: 'theme.docs.sidebar.expandButtonAriaLabel',
+                    message: 'Expand sidebar',
+                    description:
+                      'The ARIA label and title attribute for expand button of 
doc sidebar',
+                  })}
+                  tabIndex={0}
+                  role="button"
+                  onKeyDown={toggleSidebar}
+                  onClick={toggleSidebar}
+                >
+                  <IconArrow className={styles.expandSidebarButtonIcon} />
+                </div>
+              )}
+            </aside>
+          )}
+          <main
+            className={clsx(styles.docMainContainer, {
+              [styles.docMainContainerEnhanced]: hiddenSidebarContainer || 
!sidebar,
             })}
           >
-            <MDXProvider components={() => components(currentPage)}>
-              {children}
-            </MDXProvider>
-          </div>
-        </main>
-      </div>
+            <div
+              className={clsx('container padding-top--md padding-bottom--lg', 
styles.docItemWrapper, {
+                [styles.docItemWrapperEnhanced]: hiddenSidebarContainer,
+              })}
+            >
+              <MDXProvider components={() => components(currentPage)}>
+                {children}
+              </MDXProvider>
+            </div>
+          </main>
+        </div>
+      </AdminApiReference>
     </Layout>
   );
 };
diff --git a/next/package.json b/next/package.json
index 4446af44ec3..55710216316 100644
--- a/next/package.json
+++ b/next/package.json
@@ -1 +1 @@
-{"name":"apisix-website-astro","type":"module","private":true,"dependencies":{"astro":"^6.0.5","entities":"^6.0.1","remark-directive":"^4.0.0","unist-util-visit":"^5.1.0"},"devDependencies":{"@playwright/test":"1.61.1","sax":"^1.6.0","semver":"^7.8.5"},"scripts":{"sync":"node
 scripts/sync-content.mjs && node scripts/lint-content.mjs","build":"astro 
build && node scripts/generate-sitemaps.mjs && node 
scripts/check-non-indexed-content-sitemaps.mjs","dev":"astro 
dev","preview":"astro previe [...]
+{"name":"apisix-website-astro","type":"module","private":true,"dependencies":{"astro":"^6.0.5","entities":"^6.0.1","remark-directive":"^4.0.0","unist-util-visit":"^5.1.0"},"devDependencies":{"@playwright/test":"1.61.1","sax":"^1.6.0","semver":"^7.8.5"},"scripts":{"sync":"node
 scripts/sync-content.mjs && node scripts/lint-content.mjs","build":"astro 
build && node scripts/generate-sitemaps.mjs && node 
scripts/check-non-indexed-content-sitemaps.mjs","dev":"astro 
dev","preview":"astro previe [...]
diff --git a/next/playwright.admin-api.config.mjs 
b/next/playwright.admin-api.config.mjs
new file mode 100644
index 00000000000..be93bd4302c
--- /dev/null
+++ b/next/playwright.admin-api.config.mjs
@@ -0,0 +1,17 @@
+import { defineConfig } from '@playwright/test';
+import config from './playwright.config.mjs';
+
+// Serve the assembled production site first: Astro alone has no archives.
+export default defineConfig({
+  ...config,
+  testDir: './tests/admin-api',
+  // The successful-path cases intentionally exercise Scalar's pinned CDN
+  // bundle. Serialize the desktop and mobile projects so this focused suite
+  // does not turn an external dependency into a self-inflicted rate-limit.
+  workers: 1,
+  webServer: undefined,
+  use: {
+    ...config.use,
+    baseURL: process.env.ADMIN_API_BASE_URL ?? 'http://127.0.0.1:4322',
+  },
+});
diff --git a/next/scripts/check-admin-api-fixtures.mjs 
b/next/scripts/check-admin-api-fixtures.mjs
new file mode 100644
index 00000000000..c1bb2281fa1
--- /dev/null
+++ b/next/scripts/check-admin-api-fixtures.mjs
@@ -0,0 +1,25 @@
+/* eslint-disable no-console -- This CLI prints validation evidence. */
+import assert from 'node:assert/strict';
+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';
+
+// Read-only, explicit validation inputs; never called by sync or deployment.
+const [fixtureRepository, generatedAssetRoot] = process.argv.slice(2);
+assert.ok(
+  fixtureRepository && generatedAssetRoot,
+  '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)));
+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'));
+  const spec = JSON.parse(source);
+  assert.ok(isAdminApiSpec(spec, version), `Invalid ${version} fixture`);
+  assert.deepEqual(generated, source, `${version} 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}`);
+});
diff --git a/next/scripts/check-admin-api-sources.mjs 
b/next/scripts/check-admin-api-sources.mjs
new file mode 100644
index 00000000000..94fe52e9291
--- /dev/null
+++ b/next/scripts/check-admin-api-sources.mjs
@@ -0,0 +1,24 @@
+/* eslint-disable no-console -- This CLI prints validation evidence. */
+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';
+
+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 = ['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}`);
+  }
+});
+if (failed) process.exitCode = 1;
diff --git a/next/src/layouts/AdminApiPage.astro 
b/next/src/layouts/AdminApiPage.astro
index 60aa6399479..bb4c293840c 100644
--- a/next/src/layouts/AdminApiPage.astro
+++ b/next/src/layouts/AdminApiPage.astro
@@ -1,5 +1,7 @@
 ---
 import DocPage from './DocPage.astro';
+import '../../../shared/admin-api-toolbar.css';
+import { isAdminApiSpec } 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';
 
@@ -34,7 +36,7 @@ const assets = 
import.meta.glob('/content/docs-apisix-en/admin-api.json', { eage
 let specUrl: string | undefined;
 try {
   const spec = JSON.parse(Object.values(sources)[0]);
-  if (spec.openapi?.startsWith('3.') && spec.info?.version === 
APISIX_DOCS_VERSION && spec.paths && typeof spec.paths === 'object') {
+  if (isAdminApiSpec(spec, APISIX_DOCS_VERSION)) {
     specUrl = Object.values(assets)[0];
   }
 } catch { /* Missing or invalid JSON keeps the existing Markdown renderer. */ }
@@ -64,7 +66,7 @@ try {
         </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} hidden></div>
+      <div id="admin-api-reference" data-spec-url={specUrl} 
data-spec-version={APISIX_DOCS_VERSION} hidden></div>
     </section>}
     {specUrl && <noscript>
       <style is:inline>
@@ -73,132 +75,10 @@ try {
       </style>
     </noscript>}
     <script>
-      import scalarTheme from '../styles/admin-api-scalar.css?inline';
+      import scalarTheme from '../../../shared/admin-api-scalar.css?inline';
 
-      declare const Scalar: {
-        createApiReference: (element: HTMLElement, options: Record<string, 
unknown>) => void;
-      };
-
-      const reference = 
document.querySelector<HTMLElement>('#admin-api-openapi');
-      const markdown = 
document.querySelector<HTMLElement>('[data-docs-content]');
-      const container = 
document.querySelector<HTMLElement>('#admin-api-reference');
-      if (reference && markdown && container) {
-        const toolbar = document.querySelector<HTMLElement>('.api-toolbar');
-        if (toolbar) {
-          const updateHeight = () => 
document.documentElement.style.setProperty('--api-toolbar-height', 
`${toolbar.getBoundingClientRect().height}px`);
-          new ResizeObserver(updateHeight).observe(toolbar);
-          updateHeight();
-        }
-        const versionPicker = 
document.querySelector<HTMLDetailsElement>('.api-version');
-        document.addEventListener('click', (event) => {
-          if (versionPicker?.open && event.target instanceof Node && 
!versionPicker.contains(event.target)) versionPicker.open = false;
-        });
-        document.addEventListener('keydown', (event) => {
-          if (event.key === 'Escape' && versionPicker?.open) {
-            versionPicker.open = false;
-            versionPicker.querySelector('summary')?.focus();
-          }
-        });
-        // Route the shared shortcut to one search, avoiding nested scroll 
locks.
-        window.addEventListener('keydown', (event) => {
-          if (reference.hidden || container.hidden || !(event.ctrlKey || 
event.metaKey) || event.key.toLowerCase() !== 'k') return;
-          const searches = 
[...container.querySelectorAll<HTMLButtonElement>('button:has(.sidebar-search-placeholder)')];
-          const search = searches.find((button) => 
button.getClientRects().length > 0) ?? searches[0];
-          if (!search) return;
-          event.preventDefault();
-          event.stopImmediatePropagation();
-          if (document.body.style.overflow === 'hidden' || 
document.querySelector('.scalar-modal-layout, .DocSearch-Container')) return;
-          search.click();
-        }, true);
-
-        const showReference = async () => {
-          const showFallback = () => {
-            reference.hidden = true;
-            markdown.hidden = false;
-          };
-          const timeout = window.setTimeout(showFallback, 15000);
-          try {
-            const response = await fetch(container.dataset.specUrl!, { signal: 
AbortSignal.timeout(10000) });
-            if (!response.ok) throw new Error('Unable to load local OpenAPI');
-            const spec = await response.json();
-            if (!spec.openapi?.startsWith('3.') || !spec.paths) throw new 
Error('Invalid OpenAPI document');
-            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';
-              script.onload = () => resolve();
-              script.onerror = () => reject(new Error('Unable to load 
Scalar'));
-              document.head.append(script);
-            });
-            if (reference.hidden) return;
-            Scalar.createApiReference(container, {
-              content: spec,
-              onLoaded: () => {
-                if (reference.hidden) return;
-                window.clearTimeout(timeout);
-                document.querySelector('#api-reference-loading')?.remove();
-                container.hidden = false;
-              },
-              theme: 'default',
-              darkMode: false,
-              hideDarkModeToggle: true,
-              hideClientButton: true,
-              hideModels: true,
-              localization: { locale: document.documentElement.lang === 
'zh-CN' ? 'zh-CN' : 'en' },
-              documentDownloadType: 'none',
-              withDefaultFonts: false,
-              persistAuth: false,
-              operationTitleSource: 'path',
-              // Admin credentials and requests stay in the browser; no hosted 
proxy.
-              proxyUrl: '',
-              telemetry: false,
-              agent: { disabled: true },
-              showDeveloperTools: 'never',
-              customCss: scalarTheme,
-            });
-          } catch {
-            window.clearTimeout(timeout);
-            showFallback();
-          }
-        };
-        showReference();
-      }
+      import { mountAdminApi } from '../../../shared/admin-api';
+      mountAdminApi(document, scalarTheme);
     </script>
   </Fragment>
 </DocPage>
-
-<style>
-  #admin-api-openapi[hidden], #admin-api-reference[hidden], 
:global(#admin-api-markdown[hidden]) { display: none; }
-  #api-reference-loading { min-height: 60vh; padding-top: 2rem; color: 
#64748b; }
-  .api-toolbar {
-    position: sticky; top: var(--header-height); z-index: 90;
-    display: flex; justify-content: space-between; align-items: center; gap: 
1rem;
-    min-height: 56px; padding: .6rem 1.5rem;
-    background: #fff; border-bottom: 1px solid #e8ecf1;
-    font-size: .82rem; line-height: 1.4;
-  }
-  .api-location, .api-back, .api-action, .api-version summary { display: flex; 
align-items: center; }
-  .api-location { gap: .8rem; min-width: 0; }
-  .api-back { gap: .25rem; color: #64748b; white-space: nowrap; }
-  .api-separator { color: #cbd5e1; }
-  .api-page-name { color: #1e293b; font-weight: 650; white-space: nowrap; }
-  .api-version { position: relative; }
-  .api-version summary { gap: .4rem; padding: .3rem .6rem; background: 
#f6f8fa; border: 1px solid #e8ecf1; border-radius: 6px; color: #475569; 
font-size: .75rem; font-variant-numeric: tabular-nums; cursor: pointer; 
list-style: none; }
-  .api-version summary::-webkit-details-marker { display: none; }
-  .api-version[open] summary { border-color: #e8433e; }
-  .api-version ul { position: absolute; top: calc(100% + .6rem); left: 0; 
width: 210px; max-height: 55vh; overflow-y: auto; padding: .4rem; margin: 0; 
list-style: none; border: 1px solid #e8ecf1; border-radius: 10px; background: 
#fff; box-shadow: 0 12px 32px rgb(15 23 42 / 10%); }
-  .api-version a { display: block; padding: .5rem .7rem; border-radius: 5px; 
color: #475569; }
-  .api-version a:hover { background: #f6f8fa; text-decoration: none; }
-  .api-version a[aria-current] { color: #bc3532; background: #fff1f0; 
font-weight: 600; }
-  .api-action { gap: .45rem; padding: .45rem .65rem; border-radius: 6px; 
color: #475569; white-space: nowrap; }
-  .api-action:hover, .api-back:hover { color: #c53632; text-decoration: none; }
-  .api-action:hover { background: #f8fafc; }
-  .api-download { border: 1px solid #e2e8f0; background: #fff; }
-  .api-toolbar a:focus-visible, .api-version summary:focus-visible { outline: 
2px solid #e8433e; outline-offset: 3px; }
-  #admin-api-reference { min-height: 60vh; }
-  @media (max-width: 640px) {
-    .api-toolbar { gap: .5rem; padding: .65rem 1rem; }
-    .api-location { gap: .5rem; }
-    .api-action { padding: .5rem; }
-    .api-action-label { display: none; }
-  }
-</style>
diff --git a/next/src/lib/content.ts b/next/src/lib/content.ts
index a076f5b588d..b87dbe7fca4 100644
--- a/next/src/lib/content.ts
+++ b/next/src/lib/content.ts
@@ -484,7 +484,7 @@ export const APISIX_DOCS_VERSION: string = 
apisixCfg.version ?? 'current';
  */
 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'];
+  const known = ['3.18', '3.17', '3.16', '3.15', '3.14', '3.13', '3.12', 
'3.11', '3.10'];
   return known.filter((v) => v !== cur);
 })();
 
diff --git a/next/tests/admin-api/README.md b/next/tests/admin-api/README.md
new file mode 100644
index 00000000000..6cfd4fd0120
--- /dev/null
+++ b/next/tests/admin-api/README.md
@@ -0,0 +1,57 @@
+# Admin API production checks
+
+This suite needs the assembled production site: the current page is Astro,
+while the Next page is Docusaurus. Serve that output, then run:
+
+```sh
+cd next
+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 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.
+
+Before calling a PR ready, run `node next/scripts/check-admin-api-sources.mjs`
+from the repository root. This readiness gate must pass against real synced
+sources; it is separate from production builds, which retain Markdown when a
+source is absent or invalid. Sync and build from these apache/apisix refs:
+
+- current: `release/3.19`
+- Next: `master`
+
+The Next sync copies `docs/en/latest/admin-api.json` byte-for-byte to
+`doc/static/assets/openapi/apisix/next/admin-api.json`. Both locales use that
+same asset. Missing sources remove stale assets and retain Markdown. Release
+archives continue using Markdown until their own source and rendering PRs land.
+The assembled deployment already ships `doc/build/assets`, so no external
+specification request or additional deployment copy step is needed.
+
+Run the small source/validation checks with Node 22.18+:
+
+```sh
+node --test shared/admin-api-spec.test.mjs scripts/lib/admin-api.test.js
+```
+
+## Immutable canonical validation fixtures
+
+`source-fixtures.json` pins the merged master specification in `apache/apisix`
+for reproducible tests. Production synchronization continues to use
+`apache/apisix:master`.
+
+After preparing generated static assets from those exact commits, check them
+without fetching or changing any content:
+
+```sh
+node next/scripts/check-admin-api-fixtures.mjs 
/path/to/apisix-fixture-checkout doc/static/assets/openapi/apisix
+```
+
+| Canonical Apache ref | Immutable source |
+| --- | --- |
+| `master` | 
https://github.com/apache/apisix/commit/88a41b7f30da10072294ae48b1d1d7369452b1ac
 |
+
+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
new file mode 100644
index 00000000000..29938dd1320
--- /dev/null
+++ b/next/tests/admin-api/admin-api.spec.mjs
@@ -0,0 +1,109 @@
+/* eslint-disable no-await-in-loop -- Each search dialog must close before the 
next shortcut. */
+import { test, expect } from '@playwright/test';
+
+// Run against the combined production output, including Docusaurus archives.
+// Do not substitute another release's JSON: success is a readiness check for
+// the canonical sources, while interception below tests failure recovery.
+const scalarUrl = 
'https://cdn.jsdelivr.net/npm/@scalar/[email protected]/dist/browser/standalone.js';
+let scalarBundle;
+test.beforeAll(async ({ request }) => {
+  const response = await request.get(scalarUrl);
+  expect(response.ok()).toBeTruthy();
+  scalarBundle = await response.body();
+});
+test.beforeEach(async ({ page }, testInfo) => {
+  if (testInfo.title.includes('scalar-script')) return;
+  await page.route(scalarUrl, (route) => route.fulfill({
+    body: scalarBundle,
+    contentType: 'application/javascript',
+  }));
+});
+
+const versions = ['', 'next/'];
+['', '/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 && version !== 'next/') 
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;
+        await page.goto(route, { waitUntil: 'domcontentloaded' });
+        if (mode === 'timeout') {
+          await requested;
+          await expect(page.locator('#api-reference-loading')).toBeVisible();
+          await page.clock.fastForward(16000);
+        }
+        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();
+      });
+    });
+  });
+});
diff --git a/next/tests/admin-api/source-fixtures.json 
b/next/tests/admin-api/source-fixtures.json
new file mode 100644
index 00000000000..05da3ce47d5
--- /dev/null
+++ b/next/tests/admin-api/source-fixtures.json
@@ -0,0 +1,7 @@
+[
+  {
+    "version": "next",
+    "canonicalRef": "master",
+    "commit": "88a41b7f30da10072294ae48b1d1d7369452b1ac"
+  }
+]
diff --git a/scripts/lib/admin-api.js b/scripts/lib/admin-api.js
new file mode 100644
index 00000000000..44b30e26538
--- /dev/null
+++ b/scripts/lib/admin-api.js
@@ -0,0 +1,16 @@
+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 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;
+  }
+  await fs.mkdir(target, { recursive: true });
+  await fs.copyFile(`${source}/admin-api.json`, `${target}/admin-api.json`);
+}
+
+module.exports = { publishAdminApiSpec };
diff --git a/scripts/lib/admin-api.test.js b/scripts/lib/admin-api.test.js
new file mode 100644
index 00000000000..a2f066ca4cd
--- /dev/null
+++ b/scripts/lib/admin-api.test.js
@@ -0,0 +1,25 @@
+const assert = require('node:assert/strict');
+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');
+
+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-'));
+  try {
+    const source = path.join(root, 'en');
+    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);
+    await fs.rm(path.join(source, 'admin-api.json'));
+    await publishAdminApiSpec(source, 'next', output);
+    await assert.rejects(fs.access(asset), { code: 'ENOENT' });
+  } finally {
+    await fs.rm(root, { recursive: true, force: true });
+  }
+});
diff --git a/scripts/sync-docs.js b/scripts/sync-docs.js
index d765b8a7b41..06b7ec17666 100644
--- a/scripts/sync-docs.js
+++ b/scripts/sync-docs.js
@@ -10,6 +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 { versions, versionMap } = require('../config/apisix-versions.js');
 
 const { projects, languages, projectPaths } = common;
@@ -441,6 +442,8 @@ 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`);
+
         await Promise.all([
           copyDocs(enSrcDocs, enTargetDocs)
             .then(() => replaceMDElements(projectName, [enTargetDocs], 
branchName, 'en'))
diff --git a/next/src/styles/admin-api-scalar.css b/shared/admin-api-scalar.css
similarity index 90%
rename from next/src/styles/admin-api-scalar.css
rename to shared/admin-api-scalar.css
index 4ad389b722c..182b8c3bf5f 100644
--- a/next/src/styles/admin-api-scalar.css
+++ b/shared/admin-api-scalar.css
@@ -2,7 +2,10 @@
 :root {
   --scalar-font: var(--font-sans);
   --scalar-font-code: var(--font-mono);
-  --scalar-custom-header-height: calc(var(--header-height) + 
var(--api-toolbar-height, 56px));
+  --scalar-custom-header-height:
+    calc(
+      var(--header-height) + var(--api-toolbar-height, 56px)
+    );
 }
 
 .light-mode {
diff --git a/shared/admin-api-spec.test.mjs b/shared/admin-api-spec.test.mjs
new file mode 100644
index 00000000000..81e48c7e41e
--- /dev/null
+++ b/shared/admin-api-spec.test.mjs
@@ -0,0 +1,19 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { isAdminApiSpec } 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);
+});
+
+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);
+  });
+});
diff --git a/shared/admin-api-spec.ts b/shared/admin-api-spec.ts
new file mode 100644
index 00000000000..ab9c176faca
--- /dev/null
+++ b/shared/admin-api-spec.ts
@@ -0,0 +1,14 @@
+const isObject = (value: unknown): value is { [key: string]: unknown } => (
+  value !== null && typeof value === 'object' && !Array.isArray(value)
+);
+
+/** 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 {
+  if (!isObject(spec) || !isObject(spec.info)) return false;
+  return typeof spec.openapi === 'string' && spec.openapi.startsWith('3.')
+    && typeof spec.info.title === 'string'
+    && typeof spec.info.version === 'string'
+    && (!version || version === 'next' || spec.info.version === version || 
spec.info.version.startsWith(`${version}.`))
+    && isObject(spec.paths);
+}
diff --git a/shared/admin-api-toolbar.css b/shared/admin-api-toolbar.css
new file mode 100644
index 00000000000..5ea025aebd2
--- /dev/null
+++ b/shared/admin-api-toolbar.css
@@ -0,0 +1,171 @@
+#admin-api-openapi[hidden],
+#admin-api-reference[hidden],
+#admin-api-markdown[hidden] {
+  display: none;
+}
+
+#api-reference-loading {
+  min-height: 60vh;
+  padding-top: 2rem;
+  color: #64748b;
+}
+
+.api-toolbar {
+  position: sticky;
+  top: var(--header-height);
+
+  /* Keep this as a custom-property value so the production CSS optimizer does
+   * not rebase it below Scalar's runtime-injected header. */
+  z-index: var(--api-toolbar-z-index, 90);
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 1rem;
+  min-height: 56px;
+  padding: 0.6rem 1.5rem;
+  background: #fff;
+  border-bottom: 1px solid #e8ecf1;
+  font-size: 0.82rem;
+  line-height: 1.4;
+}
+
+.api-location,
+.api-back,
+.api-action,
+.api-version summary {
+  display: flex;
+  align-items: center;
+}
+
+.api-location {
+  gap: 0.8rem;
+  min-width: 0;
+}
+
+.api-back {
+  gap: 0.25rem;
+  color: #64748b;
+  white-space: nowrap;
+}
+
+.api-separator {
+  color: #cbd5e1;
+}
+
+.api-page-name {
+  color: #1e293b;
+  font-weight: 650;
+  white-space: nowrap;
+}
+
+.api-version {
+  position: relative;
+}
+
+.api-version summary {
+  gap: 0.4rem;
+  padding: 0.3rem 0.6rem;
+  background: #f6f8fa;
+  border: 1px solid #e8ecf1;
+  border-radius: 6px;
+  color: #475569;
+  font-size: 0.75rem;
+  font-variant-numeric: tabular-nums;
+  cursor: pointer;
+  list-style: none;
+}
+
+.api-version summary::-webkit-details-marker {
+  display: none;
+}
+
+.api-version[open] summary {
+  border-color: #e8433e;
+}
+
+.api-version ul {
+  position: absolute;
+  z-index: 1;
+  top: calc(100% + 0.6rem);
+  left: 0;
+  width: 210px;
+  max-height: 55vh;
+  overflow-y: auto;
+  padding: 0.4rem;
+  margin: 0;
+  list-style: none;
+  border: 1px solid #e8ecf1;
+  border-radius: 10px;
+  background: #fff;
+  box-shadow: 0 12px 32px rgb(15 23 42 / 10%);
+}
+
+.api-version a {
+  display: block;
+  padding: 0.5rem 0.7rem;
+  border-radius: 5px;
+  color: #475569;
+}
+
+.api-version a:hover {
+  background: #f6f8fa;
+  text-decoration: none;
+}
+
+.api-version a[aria-current] {
+  color: #bc3532;
+  background: #fff1f0;
+  font-weight: 600;
+}
+
+.api-action {
+  gap: 0.45rem;
+  padding: 0.45rem 0.65rem;
+  border-radius: 6px;
+  color: #475569;
+  white-space: nowrap;
+}
+
+.api-action:hover,
+.api-back:hover {
+  color: #c53632;
+  text-decoration: none;
+}
+
+.api-action:hover {
+  background: #f8fafc;
+}
+
+.api-download {
+  border: 1px solid #e2e8f0;
+  background: #fff;
+}
+
+.api-toolbar a:focus-visible,
+.api-version summary:focus-visible {
+  outline: 2px solid #e8433e;
+  outline-offset: 3px;
+}
+
+#admin-api-reference {
+  min-height: 60vh;
+}
+
+@media (max-width: 640px) {
+  .api-toolbar {
+    gap: 0.5rem;
+    padding: 0.65rem 1rem;
+  }
+
+  .api-location {
+    gap: 0.5rem;
+  }
+
+  .api-action {
+    padding: 0.5rem;
+  }
+
+  .api-action-label {
+    display: none;
+  }
+}
diff --git a/shared/admin-api.ts b/shared/admin-api.ts
new file mode 100644
index 00000000000..0bbc3523fe3
--- /dev/null
+++ b/shared/admin-api.ts
@@ -0,0 +1,155 @@
+import { isAdminApiSpec } from './admin-api-spec';
+
+declare const Scalar: {
+  createApiReference: (
+    element: HTMLElement,
+    options: { [key: string]: unknown },
+  ) => { destroy?: () => void } | undefined;
+};
+
+/** Shared by Astro and the Docusaurus archives. Dispose on SPA navigation. */
+// eslint-disable-next-line import/prefer-default-export
+export function mountAdminApi(
+  root: ParentNode,
+  scalarTheme: string,
+  version?: string,
+): () => void {
+  let disposed = false;
+  const cleanups: Array<() => void> = [];
+  const listen = <K extends keyof WindowEventMap>(
+    target: EventTarget,
+    type: K,
+    handler: (event: WindowEventMap[K]) => void,
+    capture = false,
+  ) => {
+    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 markdown = root.querySelector<HTMLElement>('[data-docs-content]');
+  const container = root.querySelector<HTMLElement>('#admin-api-reference');
+  if (reference && markdown && container) {
+    const toolbar = root.querySelector<HTMLElement>('.api-toolbar');
+    if (toolbar) {
+      const updateHeight = () => document.documentElement.style.setProperty(
+        '--api-toolbar-height',
+        `${toolbar.getBoundingClientRect().height}px`,
+      );
+      const observer = new ResizeObserver(updateHeight);
+      observer.observe(toolbar);
+      cleanups.push(() => observer.disconnect());
+      updateHeight();
+    }
+    const versionPicker = 
root.querySelector<HTMLDetailsElement>('.api-version');
+    listen(document, 'click', (event) => {
+      if (
+        versionPicker?.open
+        && event.target instanceof Node
+        && !versionPicker.contains(event.target)
+      ) versionPicker.open = false;
+    });
+    listen(document, 'keydown', (event: KeyboardEvent) => {
+      if (event.key === 'Escape' && versionPicker?.open) {
+        versionPicker.open = false;
+        versionPicker.querySelector('summary')?.focus();
+      }
+    });
+    // Route the shared shortcut to one search, avoiding nested scroll locks.
+    listen(
+      window,
+      'keydown',
+      (event: KeyboardEvent) => {
+        if (
+          reference.hidden
+          || container.hidden
+          || !(event.ctrlKey || event.metaKey)
+          || event.key.toLowerCase() !== 'k'
+        ) return;
+        const searches = [
+          ...container.querySelectorAll<HTMLButtonElement>(
+            'button:has(.sidebar-search-placeholder)',
+          ),
+        ];
+        const search = searches.find((button) => 
button.getClientRects().length > 0)
+          ?? searches[0];
+        if (!search) return;
+        event.preventDefault();
+        event.stopImmediatePropagation();
+        if (
+          document.body.style.overflow === 'hidden'
+          || document.querySelector('.scalar-modal-layout, 
.DocSearch-Container')
+        ) return;
+        search.click();
+      },
+      true,
+    );
+
+    const showReference = async () => {
+      const showFallback = () => {
+        reference.hidden = true;
+        markdown.hidden = false;
+      };
+      const controller = new AbortController();
+      const timeout = window.setTimeout(() => {
+        controller.abort();
+        showFallback();
+      }, 15000);
+      cleanups.push(() => {
+        controller.abort();
+        window.clearTimeout(timeout);
+      });
+      try {
+        const response = await fetch(container.dataset.specUrl!, {
+          signal: controller.signal,
+        });
+        if (!response.ok) throw new Error('Unable to load local OpenAPI');
+        const spec = await response.json();
+        if (!isAdminApiSpec(spec, version ?? container.dataset.specVersion)) 
throw new Error('Invalid OpenAPI document');
+        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';
+          script.onload = () => resolve();
+          script.onerror = () => reject(new Error('Unable to load Scalar'));
+          document.head.append(script);
+        });
+        if (disposed || reference.hidden) return;
+        const instance = Scalar.createApiReference(container, {
+          content: spec,
+          onLoaded: () => {
+            if (disposed || reference.hidden) return;
+            window.clearTimeout(timeout);
+            root.querySelector('#api-reference-loading')?.remove();
+            container.hidden = false;
+          },
+          theme: 'default',
+          darkMode: false,
+          hideDarkModeToggle: true,
+          hideClientButton: true,
+          hideModels: true,
+          localization: {
+            locale: document.documentElement.lang === 'zh-CN' ? 'zh-CN' : 'en',
+          },
+          documentDownloadType: 'none',
+          withDefaultFonts: false,
+          persistAuth: false,
+          operationTitleSource: 'path',
+          // Admin credentials and requests stay in the browser; no hosted 
proxy.
+          proxyUrl: '',
+          telemetry: false,
+          agent: { disabled: true },
+          showDeveloperTools: 'never',
+          customCss: scalarTheme,
+        });
+        cleanups.push(() => instance?.destroy?.());
+      } catch {
+        window.clearTimeout(timeout);
+        showFallback();
+      }
+    };
+    showReference();
+  }
+  return () => {
+    disposed = true;
+    cleanups.reverse().forEach((cleanup) => cleanup());
+  };
+}

Reply via email to