This is an automated email from the ASF dual-hosted git repository.
LiteSun pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git
The following commit(s) were added to refs/heads/master by this push:
new 045e31428 feat: link a plugin's documentation from the plugin editor
(#3462)
045e31428 is described below
commit 045e3142867e3b7d5d1b8ec40bf8f66a7ce24a64
Author: Yuhan <[email protected]>
AuthorDate: Tue Aug 4 14:15:20 2026 +0800
feat: link a plugin's documentation from the plugin editor (#3462)
---
e2e/tests/regression/plugin.docs-link.spec.ts | 137 +++++++++++++++++++++
.../form-slice/FormItemPlugins/PluginCard.tsx | 9 +-
.../form-slice/FormItemPlugins/PluginDocsLink.tsx | 77 ++++++++++++
.../FormItemPlugins/PluginEditorDrawer.tsx | 8 +-
src/locales/de/common.json | 1 +
src/locales/en/common.json | 1 +
src/locales/es/common.json | 1 +
src/locales/tr/common.json | 1 +
src/locales/zh/common.json | 1 +
src/utils/pluginDocs.test.ts | 74 +++++++++++
src/utils/pluginDocs.ts | 56 +++++++++
11 files changed, 359 insertions(+), 7 deletions(-)
diff --git a/e2e/tests/regression/plugin.docs-link.spec.ts
b/e2e/tests/regression/plugin.docs-link.spec.ts
new file mode 100644
index 000000000..6c5977dba
--- /dev/null
+++ b/e2e/tests/regression/plugin.docs-link.spec.ts
@@ -0,0 +1,137 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { routesPom } from '@e2e/pom/routes';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+
+// #3461: the plugin editor never said what a plugin does. The gateway
+// carries no description for any plugin (0 of 106 have a
+// `schema.description`), so the docs site is the only source, and its URL
+// is derivable from the plugin name.
+
+const DOCS = 'https://apisix.apache.org';
+
+test.beforeAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+test.afterEach(async ({ page }) => {
+ await page
+ .evaluate(() => localStorage.removeItem('settings:lang'))
+ .catch(() => {});
+});
+
+test.afterAll(async () => {
+ await deleteAllRoutes(e2eReq);
+});
+
+const openPicker = async (page: import('@playwright/test').Page) => {
+ await routesPom.toAdd(page);
+ await routesPom.isAddPage(page);
+ await page.getByRole('button', { name: 'Select Plugins' }).click();
+ const picker = page.getByRole('dialog', { name: 'Select Plugins' });
+ await expect(picker).toBeVisible();
+ return picker;
+};
+
+const openPluginDrawer = async (
+ page: import('@playwright/test').Page,
+ plugin: string
+) => {
+ const picker = await openPicker(page);
+ await picker.getByPlaceholder('Search').fill(plugin);
+ await picker
+ .getByTestId(`plugin-${plugin}`)
+ .getByRole('button', { name: 'Add' })
+ .click();
+ const drawer = page.getByRole('dialog', { name: 'Add Plugin' });
+ await expect(drawer).toBeVisible();
+ return drawer;
+};
+
+test('the plugin editor links that plugin documentation', async ({ page }) => {
+ const drawer = await openPluginDrawer(page, 'key-auth');
+
+ // getByRole('link') is the load-bearing part: it fails if the control
+ // ever becomes a <button href>, which cannot be cmd-clicked or copied.
+ const link = drawer.getByRole('link', { name: 'Docs' });
+ await expect(link).toHaveAttribute(
+ 'href',
+ `${DOCS}/docs/apisix/plugins/key-auth/`
+ );
+ await expect(link).toHaveAttribute('target', '_blank');
+});
+
+test('a plugin with no documentation page gets no link', async ({ page }) => {
+ const drawer = await openPluginDrawer(page, 'example-plugin');
+
+ // example-plugin is the gateway's sample plugin and has no docs page.
+ // A link here would be a broken link, which is worse than none.
+ await expect(drawer.getByRole('link', { name: 'Docs' })).toHaveCount(0);
+});
+
+test('the documentation link follows the UI language', async ({ page }) => {
+ await routesPom.toIndex(page);
+ await routesPom.isIndexPage(page);
+ await page.locator('button[aria-haspopup="menu"]').click();
+ await page.getByRole('menuitem', { name: '中文' }).click();
+ await expect(
+ page.getByRole('link', { name: '路由', exact: true })
+ ).toBeVisible();
+
+ await routesPom.toAdd(page);
+ await page.getByRole('button', { name: '选择插件' }).click();
+ const picker = page.getByRole('dialog', { name: '选择插件' });
+ await picker.getByPlaceholder('搜索').fill('key-auth');
+ await picker
+ .getByTestId('plugin-key-auth')
+ .getByRole('button', { name: '新增' })
+ .click();
+ const drawer = page.getByRole('dialog', { name: '添加插件' });
+ await expect(drawer.getByRole('link', { name: '文档' })).toHaveAttribute(
+ 'href',
+ `${DOCS}/zh/docs/apisix/plugins/key-auth/`
+ );
+});
+
+test('a picker card links that plugin documentation', async ({ page }) => {
+ const picker = await openPicker(page);
+ await picker.getByPlaceholder('Search').fill('key-auth');
+
+ // Icon-only: the accessible name is what tells a screen reader which of
+ // a hundred identical icons this one belongs to.
+ const link = picker
+ .getByTestId('plugin-key-auth')
+ .getByRole('link', { name: 'key-auth documentation' });
+ await expect(link).toHaveAttribute(
+ 'href',
+ `${DOCS}/docs/apisix/plugins/key-auth/`
+ );
+});
+
+test('a picker card for an undocumented plugin has no link', async ({
+ page,
+}) => {
+ const picker = await openPicker(page);
+ await picker.getByPlaceholder('Search').fill('example-plugin');
+ await expect(
+ picker.getByTestId('plugin-example-plugin').getByRole('link')
+ ).toHaveCount(0);
+});
diff --git a/src/components/form-slice/FormItemPlugins/PluginCard.tsx
b/src/components/form-slice/FormItemPlugins/PluginCard.tsx
index bd6ad045f..d0ace6ecd 100644
--- a/src/components/form-slice/FormItemPlugins/PluginCard.tsx
+++ b/src/components/form-slice/FormItemPlugins/PluginCard.tsx
@@ -18,6 +18,8 @@ import { Button, Card,Group, Text } from '@mantine/core';
import { modals } from '@mantine/modals';
import { useTranslation } from 'react-i18next';
+import { PluginDocsLink } from './PluginDocsLink';
+
export type PluginCardProps = {
name: string;
desc?: string;
@@ -34,10 +36,9 @@ export const PluginCard = (props: PluginCardProps) => {
return (
<Card withBorder radius="md" p="md" data-testid={`plugin-${name}`}>
<Card.Section withBorder inheritPadding py="xs">
- <Group justify="space-between">
- <Group>
- <Text fw={500}>{name}</Text>
- </Group>
+ <Group justify="space-between" wrap="nowrap">
+ <Text fw={500}>{name}</Text>
+ <PluginDocsLink name={name} />
</Group>
</Card.Section>
diff --git a/src/components/form-slice/FormItemPlugins/PluginDocsLink.tsx
b/src/components/form-slice/FormItemPlugins/PluginDocsLink.tsx
new file mode 100644
index 000000000..15c7dd8dc
--- /dev/null
+++ b/src/components/form-slice/FormItemPlugins/PluginDocsLink.tsx
@@ -0,0 +1,77 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { Anchor, Group, Tooltip } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+
+import { getPluginDocsUrl } from '@/utils/pluginDocs';
+import IconExternalLink from '~icons/tabler/external-link';
+
+export type PluginDocsLinkProps = {
+ name: string;
+ /** Show the visible label beside the icon. Icon-only otherwise. */
+ withLabel?: boolean;
+};
+
+/**
+ * A real `<a>`, not the repo's `RouteLinkBtn`, which is a Mantine `Button`
+ * carrying `href`: middle-click, cmd-click and "copy link address" are
+ * exactly what someone reaching for documentation does, and none of them
+ * work on a `<button>`.
+ *
+ * Renders nothing when the plugin has no page on the released docs —
+ * a missing affordance beats a broken link.
+ */
+export const PluginDocsLink = (props: PluginDocsLinkProps) => {
+ const { name, withLabel = false } = props;
+ const { t, i18n } = useTranslation();
+
+ const href = getPluginDocsUrl(name, i18n.language);
+ if (!href) return null;
+
+ // i18next escapes interpolated values by default; #3459 shipped an
+ // accessible name reading `R&D` before that was caught. Plugin names
+ // are gateway-controlled and hyphenated, so this is prophylactic.
+ const named = t('form.plugins.docsFor', {
+ name,
+ interpolation: { escapeValue: false },
+ });
+
+ const link = (
+ <Anchor
+ href={href}
+ target="_blank"
+ rel="noopener noreferrer"
+ underline="never"
+ size="sm"
+ // With a visible label the label IS the accessible name; overriding
+ // it with text that does not contain the visible label would break
+ // WCAG 2.5.3. Icon-only has no visible text, so it needs one.
+ {...(!withLabel && { 'aria-label': named })}
+ >
+ {withLabel ? (
+ <Group component="span" gap={4} wrap="nowrap">
+ <IconExternalLink aria-hidden focusable="false" />
+ {t('docs')}
+ </Group>
+ ) : (
+ <IconExternalLink aria-hidden focusable="false" />
+ )}
+ </Anchor>
+ );
+
+ return withLabel ? link : <Tooltip label={named}>{link}</Tooltip>;
+};
diff --git a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
index f72e07685..b883d9bc0 100644
--- a/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
+++ b/src/components/form-slice/FormItemPlugins/PluginEditorDrawer.tsx
@@ -27,6 +27,7 @@ import { FormSubmitBtn } from '@/components/form/Btn';
import { FormItemEditor } from '@/components/form/Editor';
import type { PluginCardListProps } from './PluginCardList';
+import { PluginDocsLink } from './PluginDocsLink';
import { redactByPaths } from './redact';
// PluginConfig is defined in the API layer (apis/plugins) and re-exported
@@ -113,9 +114,10 @@ export const PluginEditorDrawer = (props:
PluginEditorDrawerProps) => {
{...(mode === 'edit' && { title: t('form.plugins.editPlugin') })}
{...(mode === 'view' && { title: t('form.plugins.viewPlugin') })}
>
- <Title order={3} mb={10}>
- {name}
- </Title>
+ <Group gap="xs" mb={10} align="center">
+ <Title order={3}>{name}</Title>
+ <PluginDocsLink name={name} withLabel />
+ </Group>
<FormProvider {...methods}>
{mode === 'view' && hasSecrets && (
<Button
diff --git a/src/locales/de/common.json b/src/locales/de/common.json
index a030962b4..60b822fe1 100644
--- a/src/locales/de/common.json
+++ b/src/locales/de/common.json
@@ -66,6 +66,7 @@
"plugins": {
"addPlugin": "Plugin hinzufügen",
"configId": "Plugin-Konfigurations-ID",
+ "docsFor": "Dokumentation zu {{name}}",
"editPlugin": "Plugin bearbeiten",
"hideSecrets": "Geheimnisse verbergen",
"label": "Plugins",
diff --git a/src/locales/en/common.json b/src/locales/en/common.json
index 67c092583..b896a6ef9 100644
--- a/src/locales/en/common.json
+++ b/src/locales/en/common.json
@@ -66,6 +66,7 @@
"plugins": {
"addPlugin": "Add Plugin",
"configId": "Plugin Config ID",
+ "docsFor": "{{name}} documentation",
"editPlugin": "Edit Plugin",
"hideSecrets": "Hide secrets",
"label": "Plugins",
diff --git a/src/locales/es/common.json b/src/locales/es/common.json
index f34caa921..635c9a48e 100644
--- a/src/locales/es/common.json
+++ b/src/locales/es/common.json
@@ -66,6 +66,7 @@
"plugins": {
"addPlugin": "Añadir Plugin",
"configId": "ID de Configuración de Plugin",
+ "docsFor": "Documentación de {{name}}",
"editPlugin": "Editar Plugin",
"hideSecrets": "Ocultar secretos",
"label": "Plugins",
diff --git a/src/locales/tr/common.json b/src/locales/tr/common.json
index 816430647..ed8e03371 100644
--- a/src/locales/tr/common.json
+++ b/src/locales/tr/common.json
@@ -66,6 +66,7 @@
"plugins": {
"addPlugin": "Plugin Ekle",
"configId": "Plugin Config ID",
+ "docsFor": "{{name}} dokümantasyonu",
"editPlugin": "Plugin Düzenle",
"hideSecrets": "Gizli değerleri gizle",
"label": "Plugin'ler",
diff --git a/src/locales/zh/common.json b/src/locales/zh/common.json
index b803c02b5..84e323c40 100644
--- a/src/locales/zh/common.json
+++ b/src/locales/zh/common.json
@@ -66,6 +66,7 @@
"plugins": {
"addPlugin": "添加插件",
"configId": "插件配置ID",
+ "docsFor": "{{name}} 文档",
"editPlugin": "编辑插件",
"hideSecrets": "隐藏密钥",
"label": "插件",
diff --git a/src/utils/pluginDocs.test.ts b/src/utils/pluginDocs.test.ts
new file mode 100644
index 000000000..2e995f7ce
--- /dev/null
+++ b/src/utils/pluginDocs.test.ts
@@ -0,0 +1,74 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { describe, expect, it } from 'vitest';
+
+import { getPluginDocsUrl } from './pluginDocs';
+
+const BASE = 'https://apisix.apache.org';
+
+describe('getPluginDocsUrl', () => {
+ it('builds the released-docs URL from the plugin name', () => {
+ expect(getPluginDocsUrl('key-auth', 'en')).toBe(
+ `${BASE}/docs/apisix/plugins/key-auth/`
+ );
+ });
+
+ it.each([
+ 'serverless-pre-function',
+ 'serverless-post-function',
+ ])('sends %s to the shared serverless page', (name) => {
+ expect(getPluginDocsUrl(name, 'en')).toBe(
+ `${BASE}/docs/apisix/plugins/serverless/`
+ );
+ });
+
+ // Verified against the live docs site on 2026-08-03: these are the only
+ // names with no page on the released docs. `example-plugin` is the
+ // gateway's sample and `ai` has no page of its own; the other three are
+ // newer than the current docs release.
+ it.each([
+ 'example-plugin',
+ 'ai',
+ 'ai-cache',
+ 'ai-lakera-guard',
+ 'mcp-bridge',
+ ])('returns null for %s, which has no released docs page', (name) => {
+ expect(getPluginDocsUrl(name, 'en')).toBeNull();
+ });
+
+ it('uses the Chinese docs when the UI language is zh', () => {
+ expect(getPluginDocsUrl('key-auth', 'zh')).toBe(
+ `${BASE}/zh/docs/apisix/plugins/key-auth/`
+ );
+ });
+
+ // The docs site publishes en and zh only; /es/, /de/ and /tr/ are 404.
+ it.each(['es', 'de', 'tr'])(
+ 'falls back to English for %s, which the docs site does not publish',
+ (language) => {
+ expect(getPluginDocsUrl('key-auth', language)).toBe(
+ `${BASE}/docs/apisix/plugins/key-auth/`
+ );
+ }
+ );
+
+ it('applies the zh prefix and the slug override together', () => {
+ expect(getPluginDocsUrl('serverless-pre-function', 'zh')).toBe(
+ `${BASE}/zh/docs/apisix/plugins/serverless/`
+ );
+ });
+});
diff --git a/src/utils/pluginDocs.ts b/src/utils/pluginDocs.ts
new file mode 100644
index 000000000..dea2b2767
--- /dev/null
+++ b/src/utils/pluginDocs.ts
@@ -0,0 +1,56 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+const DOCS_ORIGIN = 'https://apisix.apache.org';
+
+/** Plugins whose documentation page is not named after the plugin. */
+const SLUG_OVERRIDES: Record<string, string> = {
+ // both halves are documented on one page
+ 'serverless-pre-function': 'serverless',
+ 'serverless-post-function': 'serverless',
+};
+
+/**
+ * Names with no page on the released docs. Two reasons, one consequence:
+ * `example-plugin` is the gateway's sample plugin and `ai` has no page of
+ * its own; the rest are newer than the current docs release and resolve
+ * only under `/next/`. Drop those from here once the released docs catch
+ * up — linking `/next/` instead would point users at documentation for a
+ * version they are not running.
+ */
+const WITHOUT_DOC_PAGE = new Set([
+ 'example-plugin',
+ 'ai',
+ 'ai-cache',
+ 'ai-lakera-guard',
+ 'mcp-bridge',
+]);
+
+/**
+ * The docs site publishes English and Chinese only. `language` is one of
+ * the bare codes in `src/config/i18n.ts` (`en | zh | es | de | tr`) —
+ * `supportedLngs` is derived from the resource keys, so there are no
+ * regional variants to match loosely.
+ */
+export const getPluginDocsUrl = (
+ name: string,
+ language: string
+): string | null => {
+ if (WITHOUT_DOC_PAGE.has(name)) return null;
+ const slug = SLUG_OVERRIDES[name] ?? name;
+ const prefix = language === 'zh' ? '/zh' : '';
+ return `${DOCS_ORIGIN}${prefix}/docs/apisix/plugins/${slug}/`;
+};