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());
+ };
+}