This is an automated email from the ASF dual-hosted git repository.
Yilialinn 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 b90b115cb18 fix(docs): improve content width and table readability
(#2112)
b90b115cb18 is described below
commit b90b115cb1856f690bd983edafcccb5b354c10e6
Author: Ming Wen <[email protected]>
AuthorDate: Mon Aug 31 10:12:48 2026 +0800
fix(docs): improve content width and table readability (#2112)
---
.github/workflows/deploy.yml | 46 +-
doc/src/css/customTheme.scss | 196 ++++++-
doc/src/theme/DocPage/index.tsx | 280 ++++++++-
doc/src/theme/DocPage/styles.module.css | 3 +-
next/astro.config.mjs | 2 +
next/scripts/rehype-doc-tables.mjs | 173 ++++++
next/src/layouts/DocPage.astro | 32 ++
next/src/styles/global.css | 224 +++++++-
next/tests/e2e/docs-mobile-layout.spec.mjs | 877 ++++++++++++++++++++++++++++-
9 files changed, 1815 insertions(+), 18 deletions(-)
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 3f950945b0a..f841419e29c 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -602,12 +602,56 @@ jobs:
exit 1
fi
+ # Production HTML points at the CDN, but a new content-hashed bundle is
+ # unavailable there until after this job succeeds and publishes it. Test
+ # an isolated copy with local asset URLs so hydration exercises the exact
+ # JS/CSS produced above without changing the artifact that gets
published.
+ - name: Prepare the final site for local browser testing
+ id: e2e-site
+ run: |
+ set -euo pipefail
+ cdn_origin='https://apisix-website-static.apiseven.com'
+ cdn_origin_pattern='https://apisix-website-static\.apiseven\.com'
+ kapa_script='https://widget.kapa.ai/kapa-widget.bundle.js'
+ e2e_site=$(mktemp -d "$RUNNER_TEMP/apisix-website-e2e.XXXXXX")
+ cp -R website/build/. "$e2e_site/"
+ # Keep third-party Kapa/reCAPTCHA traffic from blocking layout tests;
+ # the checks below ensure only the disposable copy loses the widget.
+ find "$e2e_site" -type f -name '*.html' -exec \
+ sed -i \
+ -e "s|$cdn_origin_pattern/assets/|/assets/|g" \
+ -e "s|$cdn_origin_pattern/zh/assets/|/zh/assets/|g" \
+ -e 's|<script
src="https://widget\.kapa\.ai/kapa-widget\.bundle\.js"[^>]*></script>||g' {} +
+ for asset_prefix in "$cdn_origin/assets/" "$cdn_origin/zh/assets/";
do
+ if grep -RqlF --include='*.html' "$asset_prefix" "$e2e_site"; then
+ echo "CDN asset URLs remain in the local E2E site: $asset_prefix"
+ exit 1
+ fi
+ if ! grep -RqlF --include='*.html' "$asset_prefix" website/build;
then
+ echo "The production build lost its CDN asset URLs:
$asset_prefix"
+ exit 1
+ fi
+ done
+ if grep -RqlF --include='*.html' "$kapa_script" "$e2e_site"; then
+ echo 'The third-party Kapa widget remains in the isolated E2E
site.'
+ exit 1
+ fi
+ if ! grep -RqlF --include='*.html' "$kapa_script" website/build; then
+ echo 'The production build lost the Kapa widget.'
+ exit 1
+ fi
+ test -d "$e2e_site/assets/js"
+ test -d "$e2e_site/assets/css"
+ test -d "$e2e_site/zh/assets/js"
+ test -d "$e2e_site/zh/assets/css"
+ echo "site=$e2e_site" >> "$GITHUB_OUTPUT"
+
- name: Test the final overlaid site on desktop and mobile
working-directory: next
run: npm run test:e2e
env:
PLAYWRIGHT_BASE_URL: http://127.0.0.1:4321
- PLAYWRIGHT_WEB_SERVER_COMMAND: python3 -m http.server 4321 --bind
127.0.0.1 --directory ../website/build
+ PLAYWRIGHT_WEB_SERVER_COMMAND: python3 -m http.server 4321 --bind
127.0.0.1 --directory ${{ steps.e2e-site.outputs.site }}
EXPECT_DOCUSARUS_ROUTES: 'true'
- name: Upload final site for publishing
diff --git a/doc/src/css/customTheme.scss b/doc/src/css/customTheme.scss
index dfa1d0d3df3..9ab4691825b 100644
--- a/doc/src/css/customTheme.scss
+++ b/doc/src/css/customTheme.scss
@@ -237,6 +237,7 @@ a:hover {
.markdown a {
color: var(--color-primary);
+ overflow-wrap: anywhere;
}
.arrow-btn {
@@ -532,13 +533,202 @@ article img {
height: auto;
}
-.markdown table {
- display: block;
+.markdown .lazy-load-image-background {
max-width: 100%;
- overflow-x: auto;
+}
+
+.markdown > p,
+.markdown > ul,
+.markdown > ol,
+.markdown > blockquote,
+.markdown > div[class*="admonition"] {
+ max-width: 85ch;
+}
+
+.table-shell {
+ position: relative;
+ width: 100%;
+ max-width: 100%;
+ margin-bottom: var(--ifm-spacing-vertical);
+}
+
+.table-scroll {
+ max-width: 100%;
+ overflow: auto;
+ overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
}
+.table-shell[data-overflow="false"] .table-scroll {
+ overflow: visible;
+}
+
+.table-scroll:focus-visible {
+ outline: 3px solid var(--ifm-color-primary);
+ outline-offset: 3px;
+ border-radius: 4px;
+}
+
+.table-shell::before,
+.table-shell::after {
+ content: "";
+ position: absolute;
+ z-index: 5;
+ top: 0;
+ bottom: 0;
+ width: 2.5rem;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity var(--ifm-transition-fast) ease;
+}
+
+.table-shell::before {
+ left: 0;
+ background: linear-gradient(90deg, var(--ifm-background-surface-color),
transparent);
+}
+
+.table-shell::after {
+ right: 0;
+ background: linear-gradient(270deg, var(--ifm-background-surface-color),
transparent);
+}
+
+.table-shell[data-overflow="true"][data-at-start="false"]::before,
+.table-shell[data-overflow="true"][data-at-end="false"]::after {
+ opacity: 1;
+}
+
+.table-shell--attributes[data-overflow="true"][data-at-start="false"]::before {
+ opacity: 0;
+}
+
+.markdown .docs-table {
+ display: table;
+ width: 100%;
+ min-width: 100%;
+ table-layout: auto;
+ margin: 0;
+ border-collapse: separate;
+ border-spacing: 0;
+ border-top: 1px solid var(--ifm-table-border-color, var(--color-border));
+ border-left: 1px solid var(--ifm-table-border-color, var(--color-border));
+ overflow: visible;
+}
+
+.markdown .docs-table th,
+.markdown .docs-table td {
+ padding: 0.5rem 0.75rem;
+ font-size: 0.9rem;
+ line-height: 1.5;
+ vertical-align: top;
+ border: 0;
+ border-right: 1px solid var(--ifm-table-border-color, var(--color-border));
+ border-bottom: 1px solid var(--ifm-table-border-color, var(--color-border));
+}
+
+.markdown .docs-table--attributes thead th {
+ white-space: nowrap;
+}
+
+.markdown .docs-table--attributes {
+ width: 100%;
+ min-width: 100%;
+ table-layout: auto;
+}
+
+.docs-table--attributes .docs-table__col--name {
+ width: 13rem;
+ min-width: 13rem;
+ overflow-wrap: anywhere;
+}
+
+.docs-table--attributes .docs-table__col--type {
+ width: 7.5rem;
+ min-width: 7.5rem;
+ white-space: nowrap;
+}
+
+.docs-table--attributes .docs-table__col--required {
+ width: 6rem;
+ min-width: 6rem;
+ white-space: nowrap;
+}
+
+.docs-table--attributes .docs-table__col--encrypted {
+ width: 7.5rem;
+ min-width: 7.5rem;
+ white-space: nowrap;
+}
+
+.docs-table--attributes .docs-table__col--default {
+ width: 9rem;
+ min-width: 9rem;
+ overflow-wrap: anywhere;
+}
+
+.docs-table--attributes .docs-table__col--valid-values {
+ width: 12rem;
+ min-width: 12rem;
+ overflow-wrap: anywhere;
+}
+
+.docs-table--attributes .docs-table__col--description {
+ width: 19rem;
+ min-width: 19rem;
+ overflow-wrap: anywhere;
+}
+
+.table-shell[data-overflow="true"] .docs-table--attributes
.docs-table__col--name,
+.table-shell[data-overflow="unknown"] .docs-table--attributes
.docs-table__col--name {
+ position: sticky;
+ z-index: 1;
+ left: 0;
+ background: var(--ifm-background-surface-color);
+ box-shadow: 1px 0 var(--ifm-table-border-color, var(--color-border));
+}
+
+.table-shell[data-overflow="true"] .docs-table--attributes thead
.docs-table__col--name,
+.table-shell[data-overflow="unknown"] .docs-table--attributes thead
.docs-table__col--name {
+ z-index: 2;
+ background: var(--ifm-color-emphasis-100);
+}
+
+@media screen and (max-width: 996px) {
+ .docs-table--attributes .docs-table__col--name {
+ width: 10rem;
+ min-width: 10rem;
+ }
+
+ .docs-table--attributes .docs-table__col--type {
+ width: 6.5rem;
+ min-width: 6.5rem;
+ }
+
+ .docs-table--attributes .docs-table__col--required {
+ width: 6rem;
+ min-width: 6rem;
+ }
+
+ .docs-table--attributes .docs-table__col--encrypted {
+ width: 6.5rem;
+ min-width: 6.5rem;
+ }
+
+ .docs-table--attributes .docs-table__col--default {
+ width: 8rem;
+ min-width: 8rem;
+ }
+
+ .docs-table--attributes .docs-table__col--valid-values {
+ width: 11rem;
+ min-width: 11rem;
+ }
+
+ .docs-table--attributes .docs-table__col--description {
+ width: 13rem;
+ min-width: 13rem;
+ }
+}
+
.markdown pre {
max-width: 100%;
overflow-x: auto;
diff --git a/doc/src/theme/DocPage/index.tsx b/doc/src/theme/DocPage/index.tsx
index 99a6b9c97cb..0e79109c2fa 100644
--- a/doc/src/theme/DocPage/index.tsx
+++ b/doc/src/theme/DocPage/index.tsx
@@ -7,9 +7,11 @@
* LICENSE file in the root directory of this source tree.
*/
-import type { ReactNode } from 'react';
+import type {
+ ReactElement, ReactNode, TableHTMLAttributes,
+} from 'react';
import React, {
- useState, useCallback, useEffect,
+ useState, useCallback, useEffect, useRef,
} from 'react';
// eslint-disable-next-line import/no-extraneous-dependencies
import { MDXProvider } from '@mdx-js/react';
@@ -56,8 +58,281 @@ const navbarLinkMap = {
const navbarLinkKeys = Object.keys(navbarLinkMap);
+type AttributeColumnKind =
+ | 'name'
+ | 'type'
+ | 'required'
+ | 'encrypted'
+ | 'default'
+ | 'valid-values'
+ | 'description';
+
+const attributeColumnPatterns: ReadonlyArray<{
+ kind: AttributeColumnKind;
+ pattern: RegExp;
+}> = [
+ { kind: 'name', pattern: /^(name|field|名称|字段|参数名|属性名称)$/i },
+ { kind: 'type', pattern: /^(type|类型)$/i },
+ {
+ kind: 'required',
+ pattern: /^(required|requirement|必选项|要求|是否必需|必需|必填)$/i,
+ },
+ { kind: 'encrypted', pattern: /^(encrypted|加密)$/i },
+ { kind: 'default', pattern: /^(default|default value|默认值|默认)$/i },
+ {
+ kind: 'valid-values',
+ pattern: /^(valid|valid values?|有效值|有效)$/i,
+ },
+ { kind: 'description', pattern: /^(description|描述)$/i },
+];
+
+const fieldTypePattern =
/^(?:array|boolean|integer|null|number|object|string)(?:\s*[/|]\s*(?:array|boolean|integer|null|number|object|string))*$/i;
+
+type ElementWithChildren = ReactElement<{
+ children?: ReactNode;
+ className?: string;
+ mdxType?: string;
+ originalType?: string;
+}>;
+
+const elementType = (element: ElementWithChildren): string | undefined => (
+ typeof element.type === 'string'
+ ? element.type
+ : element.props.mdxType ?? element.props.originalType
+);
+
+const elementChildren = (children: ReactNode): ElementWithChildren[] => (
+ React.Children.toArray(children).filter(
+ (child): child is ElementWithChildren => React.isValidElement(child),
+ )
+);
+
+const findElement = (children: ReactNode, type: string): ElementWithChildren |
undefined => {
+ const directMatch = elementChildren(children).find((child) =>
elementType(child) === type);
+ if (directMatch) return directMatch;
+
+ return elementChildren(children)
+ .map((child) => findElement(child.props.children, type))
+ .find(Boolean);
+};
+
+const reactTextContent = (children: ReactNode): string => (
+ React.Children.toArray(children).map((child) => {
+ if (typeof child === 'string' || typeof child === 'number') return
String(child);
+ if (React.isValidElement<{ children?: ReactNode }>(child)) {
+ return reactTextContent(child.props.children);
+ }
+ return '';
+ }).join('')
+);
+
+const attributeColumnKind = (header: string): AttributeColumnKind | null => (
+ attributeColumnPatterns.find(({ pattern }) => pattern.test(header))?.kind ??
null
+);
+
+const hasFieldTypeValues = (children: ReactNode): boolean => {
+ const body = findElement(children, 'tbody');
+ const rows = elementChildren(body?.props.children)
+ .filter((row) => elementType(row) === 'tr');
+ const typeValues = rows.map((row) => {
+ const cells = elementChildren(row.props.children)
+ .filter((cell) => elementType(cell) === 'th' || elementType(cell) ===
'td');
+ return reactTextContent(cells[1]?.props.children)
+ .trim()
+ .replace(/[\s\u00a0]+/g, ' ');
+ });
+
+ return rows.length > 0
+ && typeValues.every((value) => fieldTypePattern.test(value));
+};
+
+// Header semantics are deliberate: request, metadata, and other field-schema
+// tables need the same readable widths even outside an "Attributes" section.
+const attributeColumns = (children: ReactNode): AttributeColumnKind[] | null
=> {
+ const head = findElement(children, 'thead');
+ const row = findElement(head?.props.children, 'tr');
+ const headers = elementChildren(row?.props.children)
+ .filter((cell) => elementType(cell) === 'th')
+ .map((cell) =>
reactTextContent(cell.props.children).trim().replace(/[\s\u00a0]+/g, ' '));
+ const columns = headers.map(attributeColumnKind);
+
+ if (
+ columns.length < 3
+ || columns.length > 7
+ || columns.some((column) => column === null)
+ ) return null;
+
+ const semanticColumns = columns as AttributeColumnKind[];
+ const uniqueColumns = new Set(semanticColumns);
+
+ // Name | Type | Description is also used by metric catalogs. Only promote
+ // the ambiguous three-column form when its body contains field data types.
+ if (
+ semanticColumns[0] !== 'name'
+ || semanticColumns[semanticColumns.length - 1] !== 'description'
+ || uniqueColumns.size !== semanticColumns.length
+ || (!uniqueColumns.has('type') && !uniqueColumns.has('required'))
+ || (
+ semanticColumns.length === 3
+ && semanticColumns[1] === 'type'
+ && !hasFieldTypeValues(children)
+ )
+ ) return null;
+
+ return semanticColumns;
+};
+
+const decorateRowCells = (
+ children: ReactNode,
+ columns: AttributeColumnKind[],
+): ReactNode => {
+ let cellIndex = 0;
+
+ return React.Children.map(children, (child) => {
+ if (!React.isValidElement<{ className?: string }>(child)) return child;
+ if (elementType(child) !== 'th' && elementType(child) !== 'td') return
child;
+
+ const column = columns[cellIndex];
+ cellIndex += 1;
+ if (!column) return child;
+
+ return React.cloneElement(child, {
+ className: clsx(child.props.className, `docs-table__col--${column}`),
+ });
+ });
+};
+
+const decorateTableRows = (
+ children: ReactNode,
+ columns: AttributeColumnKind[],
+): ReactNode => React.Children.map(children, (child) => {
+ if (!React.isValidElement<{ children?: ReactNode }>(child)) return child;
+
+ if (elementType(child) === 'tr') {
+ return React.cloneElement(child, {
+ children: decorateRowCells(child.props.children, columns),
+ });
+ }
+
+ if (child.props.children === undefined) return child;
+
+ return React.cloneElement(child, {
+ children: decorateTableRows(child.props.children, columns),
+ });
+});
+
+const tableLabel = (frame: HTMLElement): string => {
+ const markdown = frame.closest('.markdown');
+ if (!markdown) return 'Documentation table';
+
+ let label = 'Documentation table';
+ markdown.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((heading) => {
+ if (
+ !heading.closest('.admonition')
+ && heading.compareDocumentPosition(frame) ===
Node.DOCUMENT_POSITION_FOLLOWING
+ ) {
+ label = heading.textContent?.trim() || label;
+ }
+ });
+ return label;
+};
+
+const DocsTable = ({
+ className,
+ children,
+ ...props
+}: TableHTMLAttributes<HTMLTableElement>): JSX.Element => {
+ // Classify from React children so the SSR HTML has stable semantic widths
+ // before hydration, including when JavaScript is unavailable.
+ const columns = attributeColumns(children);
+ const attributes = columns !== null;
+ const tableChildren = columns ? decorateTableRows(children, columns) :
children;
+ const frameRef = useRef<HTMLDivElement>(null);
+ const scrollerRef = useRef<HTMLDivElement>(null);
+ const tableRef = useRef<HTMLTableElement>(null);
+ const [state, setState] = useState<{
+ overflow: boolean | null;
+ atStart: boolean;
+ atEnd: boolean;
+ label: string;
+ }>({
+ overflow: null,
+ atStart: true,
+ atEnd: false,
+ label: 'Documentation table',
+ });
+
+ useEffect(() => {
+ const frame = frameRef.current;
+ const scroller = scrollerRef.current;
+ const table = tableRef.current;
+ if (!frame || !scroller || !table) return undefined;
+
+ const update = () => {
+ const next = {
+ overflow: scroller.scrollWidth > scroller.clientWidth + 1,
+ atStart: scroller.scrollLeft <= 1,
+ atEnd: scroller.scrollLeft + scroller.clientWidth >=
scroller.scrollWidth - 1,
+ label: tableLabel(frame),
+ };
+ setState((current) => (
+ current.overflow === next.overflow
+ && current.atStart === next.atStart
+ && current.atEnd === next.atEnd
+ && current.label === next.label
+ ? current
+ : next
+ ));
+ };
+
+ scroller.addEventListener('scroll', update, { passive: true });
+ const observer = new ResizeObserver(update);
+ observer.observe(scroller);
+ observer.observe(table);
+ update();
+
+ return () => {
+ scroller.removeEventListener('scroll', update);
+ observer.disconnect();
+ };
+ }, []);
+
+ return (
+ <div
+ ref={frameRef}
+ className={clsx('table-shell', {
+ 'table-shell--attributes': attributes,
+ })}
+ data-overflow={state.overflow ?? 'unknown'}
+ data-at-start={state.atStart}
+ data-at-end={state.atEnd}
+ >
+ <div
+ ref={scrollerRef}
+ className="table-scroll"
+ role={state.overflow === false ? undefined : 'region'}
+ aria-label={state.overflow === false ? undefined : state.label}
+ // A focusable region is the keyboard fallback for native horizontal
scrolling.
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
+ tabIndex={state.overflow === false ? -1 : 0}
+ >
+ <table
+ {...props}
+ ref={tableRef}
+ className={clsx('docs-table', {
+ 'docs-table--attributes': attributes,
+ }, className)}
+ >
+ {tableChildren}
+ </table>
+ </div>
+ </div>
+ );
+};
+
const components = (currentPage: string) => ({
...MDXComponents,
+ table: DocsTable,
a: (props) => {
const { children, ...others } = props;
const inCurrent = props.href?.includes(currentPage) ||
props.href?.startsWith('#');
@@ -79,6 +354,7 @@ const components = (currentPage: string) => ({
<div
style={{
width: 500,
+ maxWidth: '100%',
height: 300,
borderRadius: '1rem',
backgroundColor: '#d2d2d7',
diff --git a/doc/src/theme/DocPage/styles.module.css
b/doc/src/theme/DocPage/styles.module.css
index dd633d728f6..014d5939076 100644
--- a/doc/src/theme/DocPage/styles.module.css
+++ b/doc/src/theme/DocPage/styles.module.css
@@ -80,7 +80,8 @@
background-color: var(--collapse-button-bg-color-dark);
}
+ .docItemWrapper,
.docItemWrapperEnhanced {
- max-width: calc(var(--ifm-container-width) + var(--doc-sidebar-width))
!important;
+ max-width: 2000px !important;
}
}
diff --git a/next/astro.config.mjs b/next/astro.config.mjs
index 89ee08ba81c..e4c27c12a38 100644
--- a/next/astro.config.mjs
+++ b/next/astro.config.mjs
@@ -3,6 +3,7 @@ import remarkDirective from 'remark-directive';
import { remarkAdmonitions } from './scripts/remark-admonitions.mjs';
import { remarkHeadingIds } from './scripts/remark-heading-ids.mjs';
import { remarkImageLoading } from './scripts/remark-image-loading.mjs';
+import rehypeDocTables from './scripts/rehype-doc-tables.mjs';
// Static-only rebuild of apisix.apache.org.
// URL contract: every public URL is identical to the current Docusaurus site
@@ -16,6 +17,7 @@ export default defineConfig({
build: { format: 'directory', inlineStylesheets: 'never' },
markdown: {
remarkPlugins: [remarkDirective, remarkAdmonitions, remarkHeadingIds,
remarkImageLoading],
+ rehypePlugins: [rehypeDocTables],
shikiConfig: { theme: 'github-dark-default' },
},
});
diff --git a/next/scripts/rehype-doc-tables.mjs
b/next/scripts/rehype-doc-tables.mjs
new file mode 100644
index 00000000000..2de5f3e72ee
--- /dev/null
+++ b/next/scripts/rehype-doc-tables.mjs
@@ -0,0 +1,173 @@
+import { SKIP, visit } from 'unist-util-visit';
+
+const ATTRIBUTE_COLUMN_KINDS = [
+ ['name', /^(name|field|名称|字段|参数名|属性名称)$/i],
+ ['type', /^(type|类型)$/i],
+ ['required', /^(required|requirement|必选项|必填|必需|是否必需|要求)$/i],
+ ['encrypted', /^(encrypted|加密)$/i],
+ ['default', /^(default( value)?|默认值|默认)$/i],
+ ['valid-values', /^(valid( values?)?|有效值|有效)$/i],
+ ['description', /^(description|描述)$/i],
+];
+
+const FIELD_TYPE_PATTERN =
/^(?:array|boolean|integer|null|number|object|string)(?:\s*[/|]\s*(?:array|boolean|integer|null|number|object|string))*$/i;
+
+function textContent(node) {
+ if (typeof node.value === 'string') return node.value;
+ return node.children?.map(textContent).join('') ?? '';
+}
+
+function classNames(value) {
+ if (Array.isArray(value)) return value;
+ return typeof value === 'string' ? [value] : [];
+}
+
+// Header semantics are deliberate: request, metadata, and other field-schema
+// tables need the same readable widths even when the section is not literally
+// titled "Attributes".
+function attributeColumns(node) {
+ const headerRow = node.children
+ ?.find((child) => child.tagName === 'thead')
+ ?.children?.find((child) => child.tagName === 'tr');
+ const headers = headerRow?.children
+ ?.filter((child) => child.tagName === 'th')
+ .map((child) => textContent(child).trim().replace(/\s+/g, ' ')) ?? [];
+ const columns = headers.map((header) => (
+ ATTRIBUTE_COLUMN_KINDS.find(([, pattern]) => pattern.test(header))?.[0]
+ ));
+
+ if (columns.length < 3 || columns.length > 7) return null;
+ if (columns[0] !== 'name' || columns.at(-1) !== 'description') return null;
+ if (columns.some((column) => !column)) return null;
+ if (new Set(columns).size !== columns.length) return null;
+ if (!columns.includes('type') && !columns.includes('required')) return null;
+ // Name | Type | Description is also used by metric catalogs. Only promote
+ // the ambiguous three-column form when its body contains field data types.
+ if (
+ columns.length === 3
+ && columns[1] === 'type'
+ && !hasFieldTypeValues(node)
+ ) return null;
+
+ return columns;
+}
+
+function decorateRow(row, columns) {
+ let columnIndex = 0;
+
+ return {
+ ...row,
+ children: row.children?.map((cell) => {
+ if (cell.tagName !== 'th' && cell.tagName !== 'td') return cell;
+
+ const column = columns[columnIndex];
+ columnIndex += 1;
+ if (!column) return cell;
+
+ return {
+ ...cell,
+ properties: {
+ ...cell.properties,
+ className: [
+ ...classNames(cell.properties?.className),
+ `docs-table__col--${column}`,
+ ],
+ },
+ };
+ }),
+ };
+}
+
+function decorateAttributeColumns(node, columns) {
+ return {
+ ...node,
+ children: node.children?.map((section) => {
+ if (!['thead', 'tbody', 'tfoot'].includes(section.tagName)) return
section;
+
+ return {
+ ...section,
+ children: section.children?.map((row) => (
+ row.tagName === 'tr' ? decorateRow(row, columns) : row
+ )),
+ };
+ }),
+ };
+}
+
+function hasFieldTypeValues(node) {
+ const bodyRows = node.children
+ ?.find((child) => child.tagName === 'tbody')
+ ?.children?.filter((child) => child.tagName === 'tr') ?? [];
+ const typeValues = bodyRows.map((row) => {
+ const cells = row.children?.filter((child) => (
+ child.tagName === 'th' || child.tagName === 'td'
+ )) ?? [];
+ return cells[1]
+ ? textContent(cells[1]).trim().replace(/[\s\u00a0]+/g, ' ')
+ : '';
+ });
+
+ return bodyRows.length > 0
+ && typeValues.every((value) => FIELD_TYPE_PATTERN.test(value));
+}
+
+export default function rehypeDocTables() {
+ return (tree, file) => {
+ if (!String(file.path ?? '').includes('/docs-')) return;
+
+ let sectionLabel = 'Documentation';
+
+ visit(tree, 'element', (node, index, parent) => {
+ if (/^h[1-6]$/.test(node.tagName)) {
+ sectionLabel = textContent(node).trim() || sectionLabel;
+ return;
+ }
+
+ if (node.tagName !== 'table' || !parent || typeof index !== 'number')
return;
+
+ const classes = classNames(node.properties?.className);
+ const columns = attributeColumns(node);
+ const attributesTable = columns !== null;
+ const decoratedNode = attributesTable
+ ? decorateAttributeColumns(node, columns)
+ : node;
+ const table = {
+ ...decoratedNode,
+ properties: {
+ ...decoratedNode.properties,
+ className: [
+ ...classes,
+ 'docs-table',
+ ...(attributesTable ? ['docs-table--attributes'] : []),
+ ],
+ },
+ };
+
+ const shell = {
+ type: 'element',
+ tagName: 'div',
+ properties: {
+ className: ['table-shell', ...(attributesTable ?
['table-shell--attributes'] : [])],
+ 'data-overflow': 'unknown',
+ 'data-at-start': 'true',
+ 'data-at-end': 'false',
+ 'data-table-label': sectionLabel,
+ },
+ children: [{
+ type: 'element',
+ tagName: 'div',
+ properties: {
+ className: ['table-scroll'],
+ role: 'region',
+ tabIndex: 0,
+ ariaLabel: sectionLabel,
+ },
+ children: [table],
+ }],
+ };
+ parent.children.splice(index, 1, shell);
+
+ return SKIP;
+ });
+ };
+}
diff --git a/next/src/layouts/DocPage.astro b/next/src/layouts/DocPage.astro
index 18cc1f365f8..145d4f2dd01 100644
--- a/next/src/layouts/DocPage.astro
+++ b/next/src/layouts/DocPage.astro
@@ -90,4 +90,36 @@ const hasAlternate = entry.hasTranslation &&
!canonicalOverride;
</div>
</article>
</div>
+ <script>
+ document.querySelectorAll<HTMLElement>('.table-shell').forEach((shell) => {
+ const scroller = shell.querySelector<HTMLElement>('.table-scroll');
+ const table = scroller?.querySelector('table');
+ if (!scroller || !table) return;
+
+ const update = () => {
+ const overflow = scroller.scrollWidth > scroller.clientWidth + 1;
+ const atStart = scroller.scrollLeft <= 1;
+ const atEnd = scroller.scrollLeft + scroller.clientWidth >=
scroller.scrollWidth - 1;
+
+ shell.dataset.overflow = String(overflow);
+ shell.dataset.atStart = String(atStart);
+ shell.dataset.atEnd = String(atEnd);
+ scroller.tabIndex = overflow ? 0 : -1;
+
+ if (overflow) {
+ scroller.setAttribute('role', 'region');
+ scroller.setAttribute('aria-label', shell.dataset.tableLabel ??
'Documentation');
+ } else {
+ scroller.removeAttribute('role');
+ scroller.removeAttribute('aria-label');
+ }
+ };
+
+ scroller.addEventListener('scroll', update, { passive: true });
+ const observer = new ResizeObserver(update);
+ observer.observe(scroller);
+ observer.observe(table);
+ update();
+ });
+ </script>
</Base>
diff --git a/next/src/styles/global.css b/next/src/styles/global.css
index 2850048bb6f..f04787654aa 100644
--- a/next/src/styles/global.css
+++ b/next/src/styles/global.css
@@ -10,6 +10,9 @@
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica,
Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
monospace;
--max-width: 1140px;
+ --docs-reading-max: 85ch;
+ --docs-wide-max: 92rem;
+ --docs-shell-max: 114.5rem;
--header-height: 60px;
--radius-card: .75rem;
--color-surface-warm: #faf7f7;
@@ -304,7 +307,11 @@ a.tag:focus-visible { outline: 2px solid
var(--color-primary); outline-offset: 2
.prose .code-title { background: #21262d; color: #e6edf3; font-family:
var(--font-mono); font-size: .78rem; padding: .45rem 1.2rem; border-radius:
10px 10px 0 0; margin-bottom: 0; }
.prose .code-title + pre { border-radius: 0 0 10px 10px; margin-top: 0; }
.prose pre code { background: none; padding: 0; font-size: inherit; }
-.prose table { border-collapse: collapse; display: block; overflow-x: auto;
margin: 1.25rem 0; }
+
+.prose table {
+ border-collapse: collapse;
+ margin: 1.25rem 0;
+}
.prose th, .prose td { border: 1px solid var(--color-border); padding: .5rem
.8rem; font-size: .9rem; }
.prose th { background: var(--color-surface-alt); }
.prose blockquote { margin: 1.25rem 0; padding: .2rem 1.25rem; border-left:
4px solid var(--color-primary); background: var(--color-surface-alt);
border-radius: 0 8px 8px 0; }
@@ -321,7 +328,15 @@ a.tag:focus-visible { outline: 2px solid
var(--color-primary); outline-offset: 2
.admonition-important { border-color: #a25ddc; background: #f4eefb; }
/* ---------- docs layout ---------- */
-.docs-layout { display: grid; grid-template-columns: 280px minmax(0, 1fr);
gap: 2.5rem; align-items: start; padding-block: 2rem; }
+.docs-layout {
+ display: grid;
+ grid-template-columns: 280px minmax(0, 1fr);
+ gap: 2.5rem;
+ align-items: start;
+ max-width: var(--docs-shell-max);
+ padding-block: 2rem;
+}
+
.docs-sidebar {
position: sticky; top: calc(var(--header-height) + 1rem);
max-height: calc(100vh - var(--header-height) - 2rem);
@@ -353,9 +368,210 @@ a.tag:focus-visible { outline: 2px solid
var(--color-primary); outline-offset: 2
}
.docs-sidebar .sidebar-category[open] > summary::before { transform:
rotate(90deg); }
.docs-sidebar .sidebar-category > summary:hover { color: var(--color-primary);
}
-.docs-content { min-width: 0; padding-bottom: 3rem; }
+
+/* stylelint-disable no-descending-specificity */
+.docs-content {
+ width: 100%;
+ max-width: var(--docs-wide-max);
+ min-width: 0;
+ padding-bottom: 3rem;
+}
+
+.docs-content > p,
+.docs-content > ul,
+.docs-content > ol,
+.docs-content > blockquote,
+.docs-content > .admonition { max-width: var(--docs-reading-max); }
+
+.docs-content a { overflow-wrap: anywhere; }
+
+.table-shell {
+ position: relative;
+ width: 100%;
+ max-width: 100%;
+ margin: 1.25rem 0;
+}
+
+.table-scroll {
+ max-width: 100%;
+ overflow: auto;
+ overscroll-behavior-x: contain;
+ -webkit-overflow-scrolling: touch;
+}
+
+.table-scroll:focus-visible {
+ outline: 3px solid var(--color-primary-dark);
+ outline-offset: 3px;
+ border-radius: 4px;
+}
+
+.table-shell[data-overflow="false"] .table-scroll {
+ overflow: visible;
+}
+
+.table-shell::before,
+.table-shell::after {
+ content: "";
+ position: absolute;
+ z-index: 5;
+ top: 0;
+ bottom: 0;
+ width: 2.5rem;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity var(--dur) var(--ease);
+}
+
+.table-shell::before {
+ left: 0;
+ background: linear-gradient(90deg, rgb(255 255 255 / 92%), transparent);
+}
+
+.table-shell::after {
+ right: 0;
+ background: linear-gradient(270deg, rgb(255 255 255 / 92%), transparent);
+}
+
+.table-shell[data-overflow="true"][data-at-start="false"]::before,
+.table-shell[data-overflow="true"][data-at-end="false"]::after {
+ opacity: 1;
+}
+
+/* The sticky Name column is the left-edge cue for schema tables. A gradient
+ above it would wash out the text once the reader starts panning. */
+.table-shell--attributes[data-overflow="true"][data-at-start="false"]::before {
+ opacity: 0;
+}
+
+.table-shell .docs-table {
+ display: table;
+ width: 100%;
+ min-width: 100%;
+ table-layout: auto;
+ margin: 0;
+ border-collapse: separate;
+ border-spacing: 0;
+ border-top: 1px solid var(--color-border);
+ border-left: 1px solid var(--color-border);
+}
+
+.table-shell .docs-table th,
+.table-shell .docs-table td {
+ vertical-align: top;
+ line-height: 1.5;
+ border: 0;
+ border-right: 1px solid var(--color-border);
+ border-bottom: 1px solid var(--color-border);
+}
+
+.table-shell .docs-table--attributes thead th {
+ white-space: nowrap;
+}
+
+.table-shell .docs-table--attributes {
+ width: 100%;
+ min-width: 100%;
+ table-layout: auto;
+}
+
+.docs-table--attributes .docs-table__col--name {
+ width: 13rem;
+ min-width: 13rem;
+ overflow-wrap: anywhere;
+}
+
+.docs-table--attributes .docs-table__col--type {
+ width: 7.5rem;
+ min-width: 7.5rem;
+ white-space: nowrap;
+}
+
+.docs-table--attributes .docs-table__col--required {
+ width: 6rem;
+ min-width: 6rem;
+ white-space: nowrap;
+}
+
+.docs-table--attributes .docs-table__col--encrypted {
+ width: 7.5rem;
+ min-width: 7.5rem;
+ white-space: nowrap;
+}
+
+.docs-table--attributes .docs-table__col--default {
+ width: 9rem;
+ min-width: 9rem;
+ overflow-wrap: anywhere;
+}
+
+.docs-table--attributes .docs-table__col--valid-values {
+ width: 12rem;
+ min-width: 12rem;
+ overflow-wrap: anywhere;
+}
+
+.docs-table--attributes .docs-table__col--description {
+ width: 19rem;
+ min-width: 19rem;
+ overflow-wrap: anywhere;
+}
+
+.table-shell[data-overflow="true"] .docs-table--attributes
.docs-table__col--name,
+.table-shell[data-overflow="unknown"] .docs-table--attributes
.docs-table__col--name {
+ position: sticky;
+ z-index: 1;
+ left: 0;
+ background: var(--color-surface);
+ box-shadow: 1px 0 var(--color-border);
+}
+
+.table-shell[data-overflow="true"] .docs-table--attributes thead
.docs-table__col--name,
+.table-shell[data-overflow="unknown"] .docs-table--attributes thead
.docs-table__col--name {
+ z-index: 2;
+ background: var(--color-surface-alt);
+}
+
+@media (max-width: 996px) {
+ .docs-table--attributes .docs-table__col--name {
+ width: 10rem;
+ min-width: 10rem;
+ }
+
+ .docs-table--attributes .docs-table__col--type {
+ width: 6.5rem;
+ min-width: 6.5rem;
+ }
+
+ .docs-table--attributes .docs-table__col--required {
+ width: 6rem;
+ min-width: 6rem;
+ }
+
+ .docs-table--attributes .docs-table__col--encrypted {
+ width: 6.5rem;
+ min-width: 6.5rem;
+ }
+
+ .docs-table--attributes .docs-table__col--default {
+ width: 8rem;
+ min-width: 8rem;
+ }
+
+ .docs-table--attributes .docs-table__col--valid-values {
+ width: 11rem;
+ min-width: 11rem;
+ }
+
+ .docs-table--attributes .docs-table__col--description {
+ width: 13rem;
+ min-width: 13rem;
+ }
+}
+/* stylelint-enable no-descending-specificity */
+
.docs-meta { border-top: 1px solid var(--color-border); margin-top: 2.5rem;
padding-top: 1rem; font-size: .85rem; color: var(--color-text-soft); display:
flex; gap: 1rem; flex-wrap: wrap; }
-@media (max-width: 960px) {
+
+@media (max-width: 1260px) {
/* The nav stays in document order, above the article. It used to be pushed
below it, which put the link tree — and the version picker inside it —
roughly seven screens down, with only ~4% of the tree visible once you got
diff --git a/next/tests/e2e/docs-mobile-layout.spec.mjs
b/next/tests/e2e/docs-mobile-layout.spec.mjs
index 5f44e204020..2e0ed9b1e7e 100644
--- a/next/tests/e2e/docs-mobile-layout.spec.mjs
+++ b/next/tests/e2e/docs-mobile-layout.spec.mjs
@@ -1,5 +1,10 @@
+// Playwright is a test-only dependency by design.
+// eslint-disable-next-line import/no-extraneous-dependencies
import { expect, test } from '@playwright/test';
+/* Viewport and schema matrices intentionally reuse one page in sequence. */
+/* eslint-disable no-await-in-loop, no-restricted-syntax */
+
/** Computed horizontal padding of the first element matching `selector`. */
async function inlinePadding(page, selector) {
return page.locator(selector).first().evaluate((el) => {
@@ -21,6 +26,37 @@ async function firstBlogPost(page) {
return href;
}
+const ATTRIBUTE_COLUMNS = {
+ 'server-info': ['name', 'type', 'description'],
+ degraphql: ['name', 'type', 'required', 'description'],
+ 'proxy-buffering': ['name', 'type', 'required', 'default', 'description'],
+ 'openid-connect': ['name', 'type', 'required', 'default', 'valid-values',
'description'],
+ 'saml-auth': [
+ 'name', 'type', 'required', 'encrypted', 'default', 'valid-values',
'description',
+ ],
+};
+
+const ATTRIBUTE_COLUMN_MIN_WIDTHS = {
+ compact: {
+ name: 158,
+ type: 102,
+ required: 94,
+ encrypted: 102,
+ default: 126,
+ 'valid-values': 174,
+ description: 206,
+ },
+ desktop: {
+ name: 206,
+ type: 118,
+ required: 94,
+ encrypted: 118,
+ default: 142,
+ 'valid-values': 190,
+ description: 302,
+ },
+};
+
/** Padding restored and nav ahead of the article, for any docs page. */
async function assertDocsLayout(page, url) {
await page.goto(url);
@@ -41,14 +77,16 @@ async function assertDocsLayout(page, url) {
expect(geom.h1Left, `${url}: article text must not touch the viewport
edge`).toBeGreaterThan(0);
- // Both remaining checks are breakpoint-dependent, and 960px is the line
- // where .docs-layout collapses to one column (the max-width: 960px media
+ // Both remaining checks are breakpoint-dependent, and 1260px is the line
+ // where .docs-layout collapses to one column (the max-width: 1260px media
// query in global.css).
- if (geom.viewport <= 960) {
+ if (geom.viewport <= 1260) {
// Stacked: the article shares the container's inline padding with the
// header, so their left edges line up.
- expect(Math.abs(geom.h1Left - geom.brandLeft),
- `${url}: article should line up with the header
brand`).toBeLessThanOrEqual(1);
+ expect(
+ Math.abs(geom.h1Left - geom.brandLeft),
+ `${url}: article should line up with the header brand`,
+ ).toBeLessThanOrEqual(1);
// Stacked: the nav must precede the article. This is the defect — `order:
2`
// used to push it below.
expect(geom.navTop, `${url}: the docs nav must come before the article`)
@@ -64,12 +102,440 @@ async function assertDocsLayout(page, url) {
}
}
+async function assertAttributeTableSurfacePaint(shell, context, {
+ requireThemeSurface = false,
+} = {}) {
+ const metrics = await shell.evaluate((element) => {
+ const alpha = (color) => {
+ if (!color || color === 'transparent') return 0;
+ const channels = color.match(/rgba?\(([^)]+)\)/)?.[1]
+ .split(/[,/\s]+/)
+ .filter(Boolean) ?? [];
+ if (channels.length < 4) return 1;
+ const value = parseFloat(channels[3]);
+ return channels[3].endsWith('%') ? value / 100 : value;
+ };
+ const stickyCell = element.querySelector(
+ '.docs-table--attributes tbody tr > :first-child',
+ );
+ const surfaceProbe = document.createElement('span');
+ surfaceProbe.style.backgroundColor = 'var(--ifm-background-surface-color)';
+ element.append(surfaceProbe);
+ const surfaceColor = getComputedStyle(surfaceProbe).backgroundColor;
+ surfaceProbe.remove();
+ const stickyBackground = getComputedStyle(stickyCell).backgroundColor;
+ const cueBackgrounds = ['::before', '::after']
+ .map((pseudo) => getComputedStyle(element, pseudo).backgroundImage);
+ const cueEdgeColors = cueBackgrounds.map((background) => (
+ background.match(/rgba?\([^)]+\)/)?.[0] ?? null
+ ));
+
+ return {
+ cueEdgeAlphas: cueEdgeColors.map(alpha),
+ cueEdgeColors,
+ stickyBackground,
+ stickyBackgroundAlpha: alpha(stickyBackground),
+ surfaceAlpha: alpha(surfaceColor),
+ surfaceColor,
+ };
+ });
+
+ expect(metrics.stickyBackgroundAlpha, `${context}: sticky Name cells must
mask scrolled text`)
+ .toBeGreaterThan(0);
+ expect(
+ metrics.cueEdgeAlphas.every((value) => value > 0),
+ `${context}: both overflow cues must include a visible edge stop`,
+ ).toBe(true);
+ if (requireThemeSurface) {
+ expect(metrics.surfaceAlpha, `${context}: the Docusaurus surface must be
opaque`).toBe(1);
+ expect(metrics.stickyBackground, `${context}: sticky Name cells must use
the theme surface`)
+ .toBe(metrics.surfaceColor);
+ expect(metrics.cueEdgeColors, `${context}: both cues must start with the
theme surface`)
+ .toEqual([metrics.surfaceColor, metrics.surfaceColor]);
+ }
+}
+
+async function assertPluginTable(page, {
+ path,
+ shellSelector,
+ contentSelector,
+ expectedColumns,
+ shouldOverflow,
+ minimumShellWidth = 0,
+ verifyShortCells = false,
+ accessibleName = /Attributes/i,
+ requireThemeSurface = false,
+}) {
+ if (new URL(page.url()).pathname !== path) {
+ await page.goto(path, { waitUntil: 'domcontentloaded' });
+ }
+ const shell = page.locator(shellSelector);
+ const scroller = shell.locator('.table-scroll');
+ const expectedOverflow = String(shouldOverflow);
+ await expect(shell).toHaveAttribute('data-overflow', expectedOverflow);
+ await expect(shell.locator('.docs-table--attributes')).toHaveCount(1);
+ await scroller.evaluate((element) => {
+ element.scrollTo({ left: 0 });
+ });
+
+ const metrics = await page.evaluate(({ content, expectedColumnCount,
tableShell }) => {
+ const contentElement = document.querySelector(content);
+ const shellElement = document.querySelector(tableShell);
+ const scrollerElement = shellElement.querySelector('.table-scroll');
+ const tableElement = shellElement.querySelector('.docs-table--attributes');
+ const headers = [...tableElement.querySelectorAll('thead th')];
+ const bodyRows = [...tableElement.querySelectorAll('tbody tr')];
+ const completeBodyRow = bodyRows
+ .find((row, index) => (
+ index > 0 && row.querySelectorAll(':scope > td').length ===
expectedColumnCount
+ ))
+ ?? bodyRows.find((row) => (
+ row.querySelectorAll(':scope > td').length === expectedColumnCount
+ ));
+ const bodyCells = completeBodyRow
+ ? [...completeBodyRow.querySelectorAll(':scope > td')]
+ : [];
+ const firstParagraph = [...contentElement.children]
+ .find((element) => element.matches('p') &&
element.getBoundingClientRect().width > 0);
+ const shortCells = [...tableElement.querySelectorAll('tbody td')]
+ .filter((cell) => ['string', 'True',
'False'].includes(cell.textContent.trim()));
+ const readingProbe = document.createElement('span');
+ readingProbe.style.cssText =
'position:absolute;visibility:hidden;width:85ch';
+ contentElement.append(readingProbe);
+ const readingMaxWidth = readingProbe.getBoundingClientRect().width;
+ readingProbe.remove();
+
+ return {
+ contentWidth: contentElement.getBoundingClientRect().width,
+ paragraphWidth: firstParagraph?.getBoundingClientRect().width ?? 0,
+ readingMaxWidth,
+ shellWidth: shellElement.getBoundingClientRect().width,
+ pageClientWidth: document.documentElement.clientWidth,
+ pageScrollWidth: document.documentElement.scrollWidth,
+ scrollerClientWidth: scrollerElement.clientWidth,
+ scrollerScrollWidth: scrollerElement.scrollWidth,
+ scrollerClientHeight: scrollerElement.clientHeight,
+ scrollerScrollHeight: scrollerElement.scrollHeight,
+ tableWidth: tableElement.getBoundingClientRect().width,
+ columnWidths: headers.map((cell) => cell.getBoundingClientRect().width),
+ columnKinds: headers.map((cell) => (
+ [...cell.classList]
+ .find((name) => name.startsWith('docs-table__col--'))
+ ?.replace('docs-table__col--', '') ?? null
+ )),
+ bodyColumnKinds: bodyCells.map((cell) => (
+ [...cell.classList]
+ .find((name) => name.startsWith('docs-table__col--'))
+ ?.replace('docs-table__col--', '') ?? null
+ )),
+ headerWhiteSpace: headers.map((cell) =>
getComputedStyle(cell).whiteSpace),
+ shortCellWhiteSpace: shortCells.slice(0, 3)
+ .map((cell) => getComputedStyle(cell).whiteSpace),
+ };
+ }, {
+ content: contentSelector,
+ expectedColumnCount: expectedColumns.length,
+ tableShell: shellSelector,
+ });
+
+ expect(metrics.pageScrollWidth, `${path}: the page itself must not scroll
sideways`)
+ .toBeLessThanOrEqual(metrics.pageClientWidth);
+ expect(metrics.paragraphWidth, `${path}: prose must retain the configured
85ch line length`)
+ .toBeLessThanOrEqual(metrics.readingMaxWidth + 1);
+ expect(metrics.shellWidth, `${path}: table should use the available content
rail`)
+ .toBeGreaterThanOrEqual(minimumShellWidth);
+ const widthMode = page.viewportSize().width <= 996 ? 'compact' : 'desktop';
+ const minimumWidths = ATTRIBUTE_COLUMN_MIN_WIDTHS[widthMode];
+ const minimumTableWidth = expectedColumns
+ .reduce((total, column) => total + minimumWidths[column], 0);
+ expect(metrics.tableWidth, `${path}: preserve readable semantic column
widths`)
+ .toBeGreaterThanOrEqual(minimumTableWidth);
+ expect(metrics.columnKinds, `${path}: every attribute column needs a
semantic class`)
+ .toEqual(expectedColumns);
+ expect(
+ metrics.bodyColumnKinds,
+ `${path}: a complete body row must preserve the header's semantic column
order`,
+ )
+ .toEqual(expectedColumns);
+ metrics.columnWidths.forEach((width, index) => {
+ const column = expectedColumns[index];
+ expect(width, `${path}: ${column} column must remain readable`)
+ .toBeGreaterThanOrEqual(minimumWidths[column]);
+ if (shouldOverflow && widthMode === 'compact' && column === 'name') {
+ expect(width, `${path}: compact Name must not consume the table
viewport`)
+ .toBeLessThanOrEqual(162);
+ }
+ if (shouldOverflow && widthMode === 'compact' && column === 'description')
{
+ expect(width, `${path}: compact Description must leave room for adjacent
columns`)
+ .toBeLessThanOrEqual(210);
+ }
+ });
+ expect(metrics.headerWhiteSpace, `${path}: short headers must not break
letter by letter`)
+ .toEqual(expectedColumns.map(() => 'nowrap'));
+ if (verifyShortCells) {
+ expect(metrics.shortCellWhiteSpace, `${path}: common short values must
stay on one line`)
+ .toEqual(['nowrap', 'nowrap', 'nowrap']);
+ }
+ expect(metrics.scrollerScrollHeight, `${path}: table must not create nested
vertical scrolling`)
+ .toBeLessThanOrEqual(metrics.scrollerClientHeight + 1);
+
+ if (shouldOverflow) {
+
expect(metrics.scrollerScrollWidth).toBeGreaterThan(metrics.scrollerClientWidth);
+ await expect(scroller).toHaveAttribute('role', 'region');
+ await expect(scroller).toHaveAttribute('aria-label', accessibleName);
+ await expect(scroller).toHaveAttribute('tabindex', '0');
+ await expect(shell).toHaveAttribute('data-at-start', 'true');
+
+ await scroller.focus();
+ await expect(scroller).toBeFocused();
+ await page.keyboard.press('ArrowRight');
+ await expect.poll(() => scroller.evaluate((element) => element.scrollLeft))
+ .toBeGreaterThan(0);
+ await expect(shell).toHaveAttribute('data-at-start', 'false');
+
+ const stickyMetrics = await scroller.evaluate((element) => {
+ element.scrollTo({ left: Math.min(250, element.scrollWidth -
element.clientWidth) });
+ const firstCell = element.querySelector('tbody tr > :first-child');
+ return {
+ position: getComputedStyle(firstCell).position,
+ cellLeft: firstCell.getBoundingClientRect().left,
+ scrollerLeft: element.getBoundingClientRect().left,
+ };
+ });
+ expect(stickyMetrics.position, `${path}: keep the attribute name visible
while panning`)
+ .toBe('sticky');
+ await assertAttributeTableSurfacePaint(shell, path, { requireThemeSurface
});
+ expect(Math.abs(stickyMetrics.cellLeft - stickyMetrics.scrollerLeft))
+ .toBeLessThanOrEqual(2);
+ const leftCueOpacity = await shell.evaluate((element) => (
+ getComputedStyle(element, '::before').opacity
+ ));
+ expect(leftCueOpacity, `${path}: the overflow cue must not wash out sticky
Name text`)
+ .toBe('0');
+ } else {
+
expect(metrics.scrollerScrollWidth).toBeLessThanOrEqual(metrics.scrollerClientWidth
+ 1);
+ await expect(scroller).not.toHaveAttribute('role', 'region');
+ await expect(scroller).toHaveAttribute('tabindex', '-1');
+ await expect(shell).toHaveAttribute('data-at-start', 'true');
+ await expect(shell).toHaveAttribute('data-at-end', 'true');
+ }
+
+ return metrics;
+}
+
+async function assertNoJsPluginTable(browser, {
+ path,
+ shellSelector,
+ expectedColumns,
+ requireThemeSurface = false,
+ theme = 'light',
+}) {
+ const noJsPage = await browser.newPage({
+ baseURL: test.info().project.use.baseURL,
+ javaScriptEnabled: false,
+ viewport: { width: 390, height: 844 },
+ });
+ try {
+ await noJsPage.goto(path);
+ if (theme === 'dark') {
+ await noJsPage.locator('html').evaluate((element) => {
+ element.setAttribute('data-theme', 'dark');
+ });
+ await expect(noJsPage.locator('html')).toHaveAttribute('data-theme',
'dark');
+ }
+ const shell = noJsPage.locator(shellSelector);
+ const scroller = shell.locator('.table-scroll');
+ const table = scroller.locator('.docs-table--attributes');
+
+ await expect(shell).toHaveAttribute('data-overflow', 'unknown');
+ await expect(scroller).toHaveAttribute('role', 'region');
+ await expect(scroller).toHaveAttribute('tabindex', '0');
+ await expect(table).toHaveCount(1);
+ const metrics = await table.evaluate((element) => {
+ const scrollRegion = element.closest('.table-scroll');
+ const semanticColumns = [...element.querySelectorAll('thead
th')].map((cell) => (
+ [...cell.classList]
+ .find((name) => name.startsWith('docs-table__col--'))
+ ?.replace('docs-table__col--', '') ?? null
+ ));
+ return {
+ firstBodyCellPosition: getComputedStyle(
+ element.querySelector('tbody tr > :first-child'),
+ ).position,
+ pageClientWidth: document.documentElement.clientWidth,
+ pageScrollWidth: document.documentElement.scrollWidth,
+ scrollerClientWidth: scrollRegion.clientWidth,
+ scrollerScrollWidth: scrollRegion.scrollWidth,
+ semanticColumns,
+ };
+ });
+ expect(metrics.semanticColumns).toEqual(expectedColumns);
+ expect(metrics.firstBodyCellPosition, 'no-JS schema tables must keep Name
visible')
+ .toBe('sticky');
+ await assertAttributeTableSurfacePaint(
+ shell,
+ `${path} in ${theme} theme without JavaScript`,
+ { requireThemeSurface },
+ );
+
expect(metrics.scrollerScrollWidth).toBeGreaterThan(metrics.scrollerClientWidth);
+ expect(metrics.pageScrollWidth, 'no-JS table overflow must remain inside
its scroller')
+ .toBeLessThanOrEqual(metrics.pageClientWidth);
+ } finally {
+ await noJsPage.close();
+ }
+}
+
+async function assertGenericMetricsTable(page, { path, shellSelector }) {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto(path, { waitUntil: 'domcontentloaded' });
+ const shell = page.locator(shellSelector);
+ const table = shell.locator('table');
+ await expect(shell).toHaveCount(1);
+ await expect(shell).not.toHaveClass(/table-shell--attributes/);
+ await expect(table).not.toHaveClass(/docs-table--attributes/);
+ await expect(table.locator('thead th')).toHaveText(['Name', 'Type',
'Description']);
+
+ const metrics = await shell.evaluate((element) => ({
+ firstBodyCellPosition: getComputedStyle(element.querySelector('tbody
td')).position,
+ headerWhiteSpace: [...element.querySelectorAll('thead th')]
+ .map((header) => getComputedStyle(header).whiteSpace),
+ pageClientWidth: document.documentElement.clientWidth,
+ pageScrollWidth: document.documentElement.scrollWidth,
+ }));
+ expect(metrics.headerWhiteSpace).toEqual(metrics.headerWhiteSpace.map(() =>
'normal'));
+ expect(metrics.firstBodyCellPosition).not.toBe('sticky');
+ expect(metrics.pageScrollWidth, `${path}: generic metric overflow must
remain local`)
+ .toBeLessThanOrEqual(metrics.pageClientWidth);
+}
+
+async function assertLiveResizeTransitions(page, { path, shellSelector }) {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto(path, { waitUntil: 'domcontentloaded' });
+ const shell = page.locator(shellSelector);
+ const scroller = shell.locator('.table-scroll');
+
+ await expect(shell).toHaveAttribute('data-overflow', 'true');
+ await expect(shell).toHaveAttribute('data-at-end', 'false');
+ await expect(scroller).toHaveAttribute('role', 'region');
+ await expect(scroller).toHaveAttribute('tabindex', '0');
+ await expect.poll(() => shell.evaluate((element) => (
+ getComputedStyle(element, '::after').opacity
+ ))).toBe('1');
+
+ await page.setViewportSize({ width: 2962, height: 1668 });
+ await expect(shell).toHaveAttribute('data-overflow', 'false');
+ await expect(shell).toHaveAttribute('data-at-end', 'true');
+ await expect(scroller).not.toHaveAttribute('role', 'region');
+ await expect(scroller).toHaveAttribute('tabindex', '-1');
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ await expect(shell).toHaveAttribute('data-overflow', 'true');
+ await expect(shell).toHaveAttribute('data-at-end', 'false');
+ await expect(scroller).toHaveAttribute('role', 'region');
+ await expect(scroller).toHaveAttribute('tabindex', '0');
+ await expect.poll(() => shell.evaluate((element) => (
+ getComputedStyle(element, '::after').opacity
+ ))).toBe('1');
+
+ await scroller.evaluate((element) => element.scrollTo({ left:
element.scrollWidth }));
+ await expect(shell).toHaveAttribute('data-at-end', 'true');
+ await expect.poll(() => shell.evaluate((element) => (
+ getComputedStyle(element, '::after').opacity
+ ))).toBe('0');
+}
+
// docs/general/** ships from this repo, so it exists in the PR CI build too —
// no gate, and the fix is verified before anything is deployed.
test('general docs keep padding and put the nav above the article', async ({
page }) => {
await assertDocsLayout(page, '/docs/general/contributor-guide/');
});
+test('docs content rail stays wide across the sidebar breakpoint', async ({
page }) => {
+ test.skip(test.info().project.name !== 'desktop-chrome', 'one desktop run
covers both widths');
+
+ let lastStackedContentWidth = 0;
+ for (const width of [960, 961, 1200, 1260]) {
+ await page.setViewportSize({ width, height: 900 });
+ await page.goto('/docs/general/contributor-guide/');
+ const metrics = await page.evaluate(() => ({
+ contentWidth:
document.querySelector('.docs-content').getBoundingClientRect().width,
+ navTop: document.querySelector('.docs-sidebar').offsetTop,
+ articleTop: document.querySelector('.docs-content').offsetTop,
+ }));
+ expect(metrics.contentWidth, `${width}px: the docs rail must not collapse
beside the sidebar`)
+ .toBeGreaterThanOrEqual(900);
+ expect(metrics.navTop, `${width}px: docs navigation should remain above
the article`)
+ .toBeLessThan(metrics.articleTop);
+ lastStackedContentWidth = metrics.contentWidth;
+ }
+
+ await page.setViewportSize({ width: 1261, height: 900 });
+ await page.goto('/docs/general/contributor-guide/');
+ const desktopMetrics = await page.evaluate(() => ({
+ contentWidth:
document.querySelector('.docs-content').getBoundingClientRect().width,
+ navTop: document.querySelector('.docs-sidebar').offsetTop,
+ articleTop: document.querySelector('.docs-content').offsetTop,
+ }));
+ expect(desktopMetrics.contentWidth, '1261px: keep a readable rail beside the
sidebar')
+ .toBeGreaterThanOrEqual(900);
+ expect(
+ lastStackedContentWidth - desktopMetrics.contentWidth,
+ 'the rail should not collapse excessively when the sidebar returns',
+ ).toBeLessThanOrEqual(330);
+ expect(desktopMetrics.navTop, '1261px: nav and article should switch to the
same grid row')
+ .toBe(desktopMetrics.articleTop);
+});
+
+test('ordinary prose tables wrap within the available docs rail', async ({
page }) => {
+ await page.goto('/docs/general/how-to-contribute/');
+ const shell = page.locator('.docs-content .table-shell').first();
+ await expect(shell).toHaveAttribute('data-overflow', 'false');
+
+ const metrics = await shell.evaluate((element) => {
+ const scroller = element.querySelector('.table-scroll');
+ const table = element.querySelector('.docs-table');
+ const description = table.querySelector('tbody td:nth-child(2)');
+ return {
+ shellWidth: element.getBoundingClientRect().width,
+ tableWidth: table.getBoundingClientRect().width,
+ scrollWidth: scroller.scrollWidth,
+ clientWidth: scroller.clientWidth,
+ pageScrollWidth: document.documentElement.scrollWidth,
+ pageClientWidth: document.documentElement.clientWidth,
+ descriptionWhiteSpace: getComputedStyle(description).whiteSpace,
+ };
+ });
+
+ expect(metrics.pageScrollWidth).toBeLessThanOrEqual(metrics.pageClientWidth);
+ expect(metrics.tableWidth).toBeLessThanOrEqual(metrics.shellWidth + 1);
+ expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth + 1);
+ expect(metrics.descriptionWhiteSpace).toBe('normal');
+});
+
+test('ordinary docs table headers can wrap instead of forcing overflow', async
({ page }) => {
+ test.skip(process.env.EXPECT_DOCUSARUS_ROUTES !== 'true', 'APISIX docs need
the final tree');
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto('/docs/apisix/security-threat-model/');
+ const table = page.locator('.docs-content .table-shell').first();
+ await expect(table).toHaveCount(1);
+ await expect(table.locator('thead th').first()).toHaveText('Role');
+ await expect(table).not.toHaveClass(/table-shell--attributes/);
+ await
expect(table.locator('table')).not.toHaveClass(/docs-table--attributes/);
+ const whiteSpace = await table.locator('thead th').evaluateAll((headers) => (
+ headers.map((header) => getComputedStyle(header).whiteSpace)
+ ));
+ expect(whiteSpace).toEqual(whiteSpace.map(() => 'normal'));
+ const metrics = await table.evaluate((element) => ({
+ firstBodyCellPosition: getComputedStyle(element.querySelector('tbody
td')).position,
+ pageClientWidth: document.documentElement.clientWidth,
+ pageScrollWidth: document.documentElement.scrollWidth,
+ }));
+ expect(metrics.firstBodyCellPosition, 'ordinary table cells must not become
sticky').not.toBe('sticky');
+ expect(metrics.pageScrollWidth, 'ordinary table overflow must remain local
to its scroller')
+ .toBeLessThanOrEqual(metrics.pageClientWidth);
+});
+
// Same assertions over the 200-link apisix tree that motivated the report.
// Gated: apisix docs need .sync/ checkouts only the deploy pipeline has.
test('apisix docs keep padding and put the nav above the article', async ({
page }) => {
@@ -80,6 +546,402 @@ test('apisix docs keep padding and put the nav above the
article', async ({ page
await assertDocsLayout(page, '/docs/apisix/getting-started/README/');
});
+test('current APISIX plugin tables use wide screens without sacrificing
readability', async ({ page }) => {
+ test.skip(
+ process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+ || test.info().project.name !== 'desktop-chrome',
+ 'desktop APISIX docs only exist in the final overlaid tree',
+ );
+
+ const currentPath = '/docs/apisix/plugins/openid-connect/';
+ const table = {
+ path: currentPath,
+ shellSelector: '.docs-content h2#attributes + .table-shell',
+ contentSelector: '.docs-content',
+ };
+
+ await page.setViewportSize({ width: 961, height: 900 });
+ await assertPluginTable(page, {
+ ...table,
+ expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+ shouldOverflow: false,
+ minimumShellWidth: 900,
+ verifyShortCells: true,
+ });
+
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await assertPluginTable(page, {
+ ...table,
+ expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+ shouldOverflow: false,
+ minimumShellWidth: 1070,
+ verifyShortCells: true,
+ });
+
+ await page.setViewportSize({ width: 1920, height: 1080 });
+ await assertPluginTable(page, {
+ ...table,
+ expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+ shouldOverflow: false,
+ minimumShellWidth: 1460,
+ verifyShortCells: true,
+ });
+
+ await page.setViewportSize({ width: 2962, height: 1668 });
+ await assertPluginTable(page, {
+ ...table,
+ expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+ shouldOverflow: false,
+ minimumShellWidth: 1460,
+ verifyShortCells: true,
+ });
+});
+
+test('current Attributes table remains usable without JavaScript', async ({
browser }) => {
+ test.skip(
+ process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+ || test.info().project.name !== 'desktop-chrome',
+ 'current APISIX docs only exist in the final overlaid tree',
+ );
+
+ await assertNoJsPluginTable(browser, {
+ path: '/docs/apisix/plugins/openid-connect/',
+ shellSelector: '.docs-content h2#attributes + .table-shell',
+ expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+ });
+});
+
+test('current metrics catalog stays generic', async ({ page }) => {
+ test.skip(
+ process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+ || test.info().project.name !== 'desktop-chrome',
+ 'current APISIX docs only exist in the final overlaid tree',
+ );
+
+ await assertGenericMetricsTable(page, {
+ path: '/docs/apisix/plugins/prometheus/',
+ shellSelector: '.docs-content .table-shell:has(>
.table-scroll[aria-label="Metrics"])',
+ });
+});
+
+test('current Attributes table reacts to live viewport changes', async ({ page
}) => {
+ test.skip(
+ process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+ || test.info().project.name !== 'desktop-chrome',
+ 'current APISIX docs only exist in the final overlaid tree',
+ );
+
+ await assertLiveResizeTransitions(page, {
+ path: '/docs/apisix/plugins/openid-connect/',
+ shellSelector: '.docs-content h2#attributes + .table-shell',
+ });
+});
+
+test('current Attributes tables support four, five, and seven column schemas',
async ({ page }) => {
+ test.skip(
+ process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+ || test.info().project.name !== 'desktop-chrome',
+ 'desktop APISIX docs only exist in the final overlaid tree',
+ );
+
+ const cases = [
+ { slug: 'degraphql', overflowAt961: false, overflowAt1440: false },
+ { slug: 'proxy-buffering', overflowAt961: false, overflowAt1440: false },
+ { slug: 'saml-auth', overflowAt961: true, overflowAt1440: true },
+ ];
+
+ for (const item of cases) {
+ const table = {
+ path: `/docs/apisix/plugins/${item.slug}/`,
+ shellSelector: '.docs-content h2#attributes + .table-shell',
+ contentSelector: '.docs-content',
+ expectedColumns: ATTRIBUTE_COLUMNS[item.slug],
+ };
+ await page.setViewportSize({ width: 961, height: 900 });
+ await assertPluginTable(page, {
+ ...table, shouldOverflow: item.overflowAt961, minimumShellWidth: 900,
+ });
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await assertPluginTable(page, {
+ ...table, shouldOverflow: item.overflowAt1440, minimumShellWidth: 1070,
+ });
+ }
+});
+
+test('archived APISIX plugin tables expand when space is available', async ({
page }) => {
+ test.setTimeout(120_000);
+ test.skip(
+ process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+ || test.info().project.name !== 'desktop-chrome',
+ 'desktop APISIX docs only exist in the final overlaid tree',
+ );
+
+ const table = {
+ path: '/docs/apisix/3.18/plugins/openid-connect/',
+ shellSelector: '.markdown h2:has(#attributes) + .table-shell',
+ contentSelector: '.markdown',
+ requireThemeSurface: true,
+ };
+
+ const serverResponse = await page.request.get(table.path);
+ expect(serverResponse.ok(), `${table.path}: SSR document should
load`).toBe(true);
+ const serverHtml = await serverResponse.text();
+ expect(
+ serverHtml,
+ `${table.path}: Attributes widths must be present before hydration`,
+ ).toMatch(/<table[^>]+class="[^"]*docs-table--attributes/);
+ expect(
+ serverHtml,
+ `${table.path}: overflow cues should wait for client measurement`,
+ ).toMatch(/class="table-shell[^"]*" data-overflow="unknown"/);
+
+ await page.setViewportSize({ width: 961, height: 900 });
+ await assertPluginTable(page, {
+ ...table,
+ expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+ shouldOverflow: false,
+ minimumShellWidth: 880,
+ });
+
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await assertPluginTable(page, {
+ ...table,
+ expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+ shouldOverflow: true,
+ minimumShellWidth: 800,
+ });
+
+ await page.setViewportSize({ width: 1920, height: 1080 });
+ await assertPluginTable(page, {
+ ...table,
+ expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+ shouldOverflow: false,
+ minimumShellWidth: 1150,
+ });
+
+ await page.setViewportSize({ width: 2962, height: 1668 });
+ await assertPluginTable(page, {
+ ...table,
+ expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+ shouldOverflow: false,
+ minimumShellWidth: 1460,
+ });
+});
+
+test('archived Docusaurus tables cover variable schemas and generic wrapping',
async ({ page }) => {
+ test.setTimeout(120_000);
+ test.skip(
+ process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+ || test.info().project.name !== 'desktop-chrome',
+ 'archived Docusaurus docs only exist in the final overlaid tree',
+ );
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ const schemaCases = [
+ {
+ slug: 'server-info',
+ shellSelector: '.markdown .table-shell:has(>
.table-scroll[aria-label^="Description"])',
+ accessibleName: /Description/i,
+ },
+ { slug: 'degraphql' },
+ { slug: 'proxy-buffering' },
+ { slug: 'saml-auth' },
+ ];
+ for (const item of schemaCases) {
+ await assertPluginTable(page, {
+ path: `/docs/apisix/3.18/plugins/${item.slug}/`,
+ shellSelector: item.shellSelector
+ ?? '.markdown h2:has(#attributes) + .table-shell',
+ contentSelector: '.markdown',
+ expectedColumns: ATTRIBUTE_COLUMNS[item.slug],
+ shouldOverflow: true,
+ minimumShellWidth: 340,
+ accessibleName: item.accessibleName ?? /Attributes/i,
+ requireThemeSurface: true,
+ });
+ }
+
+ await assertGenericMetricsTable(page, {
+ path: '/docs/apisix/3.18/plugins/prometheus/',
+ shellSelector: '.markdown .table-shell:has(>
.table-scroll[aria-label^="Metrics"])',
+ });
+});
+
+test('archived Docusaurus Attributes table works without JavaScript', async ({
browser }) => {
+ test.skip(
+ process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+ || test.info().project.name !== 'desktop-chrome',
+ 'archived Docusaurus docs only exist in the final overlaid tree',
+ );
+
+ for (const theme of ['light', 'dark']) {
+ await assertNoJsPluginTable(browser, {
+ path: '/docs/apisix/3.18/plugins/openid-connect/',
+ shellSelector: '.markdown h2:has(#attributes) + .table-shell',
+ expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+ requireThemeSurface: true,
+ theme,
+ });
+ }
+});
+
+test('archived Docusaurus table focus stays visible in dark mode', async ({
browser }) => {
+ test.setTimeout(120_000);
+ test.skip(
+ process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+ || test.info().project.name !== 'desktop-chrome',
+ 'archived Docusaurus docs only exist in the final overlaid tree',
+ );
+
+ const darkPage = await browser.newPage({
+ baseURL: test.info().project.use.baseURL,
+ viewport: { width: 1440, height: 900 },
+ });
+ try {
+ await darkPage.addInitScript(() => localStorage.setItem('theme', 'dark'));
+ await darkPage.goto('/docs/apisix/3.18/plugins/openid-connect/', {
+ waitUntil: 'domcontentloaded',
+ });
+ await expect(darkPage.locator('html')).toHaveAttribute('data-theme',
'dark');
+ const shell = darkPage.locator('.markdown h2:has(#attributes) +
.table-shell');
+ const scroller = shell.locator('.table-scroll');
+ await expect(shell).toHaveAttribute('data-overflow', 'true');
+ await assertAttributeTableSurfacePaint(shell, 'archived dark-mode table', {
+ requireThemeSurface: true,
+ });
+ await darkPage.locator('.markdown h2:has(#attributes) .hash-link').focus();
+ await darkPage.keyboard.press('Tab');
+ await expect(scroller).toBeFocused();
+
+ const focusMetrics = await scroller.evaluate((element) => {
+ const probe = document.createElement('span');
+ probe.style.color = 'var(--ifm-color-primary)';
+ probe.style.backgroundColor = 'var(--ifm-background-color)';
+ document.body.append(probe);
+ const { color: primaryColor, backgroundColor } = getComputedStyle(probe);
+ probe.remove();
+
+ const channels = (color) => (
+ (color.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number)
+ );
+ const luminance = (color) => {
+ const [red, green, blue] = channels(color).map((value) => {
+ const channel = value / 255;
+ return channel <= 0.04045
+ ? channel / 12.92
+ : ((channel + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
+ };
+ const foreground = luminance(primaryColor);
+ const background = luminance(backgroundColor);
+ const contrast = (Math.max(foreground, background) + 0.05)
+ / (Math.min(foreground, background) + 0.05);
+ const style = getComputedStyle(element);
+
+ return {
+ contrast,
+ outlineColor: style.outlineColor,
+ outlineStyle: style.outlineStyle,
+ outlineWidth: parseFloat(style.outlineWidth),
+ primaryColor,
+ };
+ });
+ expect(focusMetrics.outlineStyle).toBe('solid');
+ expect(focusMetrics.outlineWidth).toBeGreaterThanOrEqual(3);
+ expect(focusMetrics.outlineColor).toBe(focusMetrics.primaryColor);
+ expect(focusMetrics.contrast, 'focus outline needs at least 3:1
contrast').toBeGreaterThanOrEqual(3);
+ } finally {
+ await darkPage.close();
+ }
+});
+
+test('archived Docusaurus table reacts to live viewport changes', async ({
page }) => {
+ test.setTimeout(120_000);
+ test.skip(
+ process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+ || test.info().project.name !== 'desktop-chrome',
+ 'archived Docusaurus docs only exist in the final overlaid tree',
+ );
+
+ await assertLiveResizeTransitions(page, {
+ path: '/docs/apisix/3.18/plugins/openid-connect/',
+ shellSelector: '.markdown h2:has(#attributes) + .table-shell',
+ });
+});
+
+test('current plugin table overflow remains local and keyboard accessible on
mobile', async ({ page }) => {
+ test.skip(
+ process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+ || test.info().project.name !== 'mobile-chrome',
+ 'mobile APISIX docs only exist in the final overlaid tree',
+ );
+
+ await assertPluginTable(page, {
+ path: '/docs/apisix/plugins/openid-connect/',
+ shellSelector: '.docs-content h2#attributes + .table-shell',
+ contentSelector: '.docs-content',
+ expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+ shouldOverflow: true,
+ minimumShellWidth: 340,
+ });
+});
+
+test('variable Attributes schemas keep Name visible on mobile', async ({ page
}) => {
+ test.skip(
+ process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+ || test.info().project.name !== 'mobile-chrome',
+ 'mobile APISIX docs only exist in the final overlaid tree',
+ );
+
+ for (const slug of ['degraphql', 'proxy-buffering', 'saml-auth']) {
+ await assertPluginTable(page, {
+ path: `/docs/apisix/plugins/${slug}/`,
+ shellSelector: '.docs-content h2#attributes + .table-shell',
+ contentSelector: '.docs-content',
+ expectedColumns: ATTRIBUTE_COLUMNS[slug],
+ shouldOverflow: true,
+ minimumShellWidth: 340,
+ });
+ }
+});
+
+test('non-Attributes field schemas intentionally use semantic table behavior',
async ({ page }) => {
+ test.skip(
+ process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+ || test.info().project.name !== 'mobile-chrome',
+ 'mobile APISIX docs only exist in the final overlaid tree',
+ );
+
+ await assertPluginTable(page, {
+ path: '/docs/apisix/plugins/ai-proxy/',
+ shellSelector: '.docs-content h2#request-format + .table-shell',
+ contentSelector: '.docs-content',
+ expectedColumns: ATTRIBUTE_COLUMNS.degraphql,
+ shouldOverflow: true,
+ minimumShellWidth: 340,
+ accessibleName: /Request Format/i,
+ });
+});
+
+test('archived plugin table overflow remains local and keyboard accessible on
mobile', async ({ page }) => {
+ test.skip(
+ process.env.EXPECT_DOCUSARUS_ROUTES !== 'true'
+ || test.info().project.name !== 'mobile-chrome',
+ 'mobile APISIX docs only exist in the final overlaid tree',
+ );
+
+ await assertPluginTable(page, {
+ path: '/docs/apisix/3.18/plugins/openid-connect/',
+ shellSelector: '.markdown h2:has(#attributes) + .table-shell',
+ contentSelector: '.markdown',
+ expectedColumns: ATTRIBUTE_COLUMNS['openid-connect'],
+ shouldOverflow: true,
+ minimumShellWidth: 340,
+ requireThemeSurface: true,
+ });
+});
+
test('blog posts keep their horizontal padding', async ({ page }) => {
await page.goto(await firstBlogPost(page));
const pad = await inlinePadding(page, '.article-wrap');
@@ -97,8 +959,9 @@ test('desktop keeps the three-column article rails', async
({ page }) => {
const rails = page.locator('.article-wrap.with-rails');
await expect(rails, 'the discovered post should render the rails
layout').toHaveCount(1);
- const tracks = await rails.evaluate((el) =>
-
getComputedStyle(el).gridTemplateColumns.split(/\s+/).filter(Boolean).map(parseFloat));
+ const tracks = await rails.evaluate((el) => (
+
getComputedStyle(el).gridTemplateColumns.split(/\s+/).filter(Boolean).map(parseFloat)
+ ));
expect(tracks.length, 'the rails grid must stay three columns').toBe(3);