From addf464f8ac51de90e062d9f8336b786c16a0ad7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 00:31:13 +0000 Subject: [PATCH] docs(site): wrap long table tokens and list host capabilities by group The host capability matrix scrolled up to 709px sideways at 1440px: its per-host 'Plugin components' tables had 154 rows whose Detail column held stringified JSON objects as single tokens, and Rspress's auto table layout widens a column to its longest unbreakable token. - Render each host's plugin components as Rspack-style reference sections: one heading per top-level capability group and one list entry per capability, with anchors and outline navigation instead of a three-column grid. - Emit object-valued details as one `key.member: value` line each. - Add a rehype plugin that inserts into long code and link text inside table cells (after ./@,_- and at camelCase boundaries), so the remaining matrices (events, install surface, path tokens) fit the content column. The Markdown source, search index, llms-full.txt, and clipboard keep the plain token. --- pnpm-lock.yaml | 3 ++ website/package.json | 1 + website/plugins/generated-reference.ts | 51 +++++++++++---------- website/plugins/rehype-table-cell-breaks.ts | 51 +++++++++++++++++++++ website/rspress.config.ts | 3 ++ 5 files changed, 85 insertions(+), 24 deletions(-) create mode 100644 website/plugins/rehype-table-cell-breaks.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2d7842dd..f885690e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -471,6 +471,9 @@ importers: '@shikijs/transformers': specifier: 4.4.3 version: 4.4.3 + '@types/hast': + specifier: 3.0.5 + version: 3.0.5 '@types/node': specifier: 26.4.0 version: 26.4.0 diff --git a/website/package.json b/website/package.json index b7a7c8604..8af8f63c0 100644 --- a/website/package.json +++ b/website/package.json @@ -21,6 +21,7 @@ "@rspress/plugin-twoslash": "2.0.21", "@rspress/plugin-typedoc": "2.0.21", "@shikijs/transformers": "4.4.3", + "@types/hast": "3.0.5", "@types/node": "26.4.0", "@types/react": "19.2.18", "@types/react-dom": "19.2.5", diff --git a/website/plugins/generated-reference.ts b/website/plugins/generated-reference.ts index 22a816fd4..165ce6df4 100644 --- a/website/plugins/generated-reference.ts +++ b/website/plugins/generated-reference.ts @@ -112,6 +112,9 @@ const code = (text: string): string => `\`${text.replaceAll('|', '\\|')}\``; const codeList = (values: readonly JsonValue[]): string => values.map(value => code(String(value))).join(', '); +const detailValueCell = (value: JsonValue): string => + Array.isArray(value) ? codeList(value) : code(isObject(value) ? JSON.stringify(value) : String(value)); + const table = (headers: readonly string[], rows: readonly (readonly string[])[]): string => { const line = (cells: readonly string[]) => `| ${cells.join(' | ')} |`; return [ @@ -138,7 +141,7 @@ const messages = { mcpTransports: 'MCP transports and token fields', pluginComponents: 'Plugin components', pluginComponentsIntro: - 'The `plugin` section of each table, flattened to dotted capability paths. Boolean rows record a component the adapter emits; rows with a state carry the reason the host evidence supports or withholds it. Evidence notes stay in the JSON files.', + 'The `plugin` section of each table, flattened to dotted capability paths and grouped by top-level key. Boolean entries record a component the adapter emits; entries with a state carry the reason the host evidence supports or withholds it. Evidence notes stay in the JSON files.', headers: { host: 'Host', version: 'Observed version', @@ -150,8 +153,6 @@ const messages = { scopes: 'Scopes', source: 'Source', token: 'Token', - capability: 'Capability', - detail: 'Detail', stdio: 'stdio', streamableHttp: 'Streamable HTTP', tokenFields: 'Fields accepting path tokens', @@ -211,7 +212,7 @@ const messages = { mcpTransports: 'MCP 传输与令牌字段', pluginComponents: '插件组件', pluginComponentsIntro: - '每张表的 `plugin` 部分,按点分能力路径展开。布尔行表示适配器会发出的组件;带状态的行记录宿主证据支持或保留该能力的原因。证据说明保留在 JSON 文件中。', + '每张表的 `plugin` 部分,按点分能力路径展开并按顶层键分组。布尔条目表示适配器会发出的组件;带状态的条目记录宿主证据支持或保留该能力的原因。证据说明保留在 JSON 文件中。', headers: { host: '宿主', version: '观测版本', @@ -223,8 +224,6 @@ const messages = { scopes: '作用域', source: '来源', token: '令牌', - capability: '能力', - detail: '说明', stdio: 'stdio', streamableHttp: 'Streamable HTTP', tokenFields: '接受路径令牌的字段', @@ -332,8 +331,8 @@ const eventRoutesOf = (data: JsonObject): JsonObject => asObject(asObject(data.h interface FlattenedRow { readonly path: string; - readonly state: string; - readonly detail: string; + readonly state?: string; + readonly detail?: string; } function flattenPlugin(value: JsonObject, prefix: string, m: Messages, rows: FlattenedRow[]): void { @@ -349,35 +348,38 @@ function flattenPlugin(value: JsonObject, prefix: string, m: Messages, rows: Fla if (detailKey === 'state' || detailKey === 'reason' || detailKey === 'evidence') { continue; } - if (Array.isArray(detailValue)) { - details.push(`${code(detailKey)}: ${codeList(detailValue)}`); - } else if (isObject(detailValue)) { - details.push(`${code(detailKey)}: ${code(JSON.stringify(detailValue))}`); - } else { - details.push(`${code(detailKey)}: ${code(String(detailValue))}`); + // One line per object member; a stringified object is one unbreakable token. + const members = isObject(detailValue) + ? Object.entries(detailValue).map(([key, value]) => [`${detailKey}.${key}`, value] as const) + : [[detailKey, detailValue] as const]; + for (const [key, value] of members) { + details.push(`${code(key)}: ${detailValueCell(value)}`); } } if (Array.isArray(entry.evidence)) { details.push(m.evidenceNotes(entry.evidence.length)); } - rows.push({ detail: details.join('
'), path: dotted, state: entry.state }); + rows.push({ detail: details.join(' · '), path: dotted, state: entry.state }); continue; } flattenPlugin(entry, dotted, m, rows); continue; } if (Array.isArray(entry)) { - rows.push({ detail: codeList(entry), path: dotted, state: m.notApplicable }); + rows.push({ detail: codeList(entry), path: dotted }); continue; } if (typeof entry === 'boolean') { - rows.push({ detail: m.notApplicable, path: dotted, state: entry ? 'supported' : 'unavailable' }); + rows.push({ path: dotted, state: entry ? 'supported' : 'unavailable' }); continue; } - rows.push({ detail: code(String(entry)), path: dotted, state: m.notApplicable }); + rows.push({ detail: code(String(entry)), path: dotted }); } } +const capabilityItem = ({ path, state, detail }: FlattenedRow): string => + `- ${code(path)}${state ? ` **${state}**` : ''}${detail ? ` — ${detail}` : ''}`; + function renderHosts(hosts: readonly HostCapabilityTable[], m: Messages): string { const sections: string[] = []; sections.push(frontmatter(m.hostsTitle, m.hostsDescription)); @@ -476,12 +478,13 @@ function renderHosts(hosts: readonly HostCapabilityTable[], m: Messages): string const rows: FlattenedRow[] = []; flattenPlugin(asObject(host.data.plugin), '', m, rows); sections.push(`### ${hostHeader(host)}\n`); - sections.push( - table( - [m.headers.capability, m.headers.state, m.headers.detail], - rows.map(row => [code(row.path), row.state, row.detail]), - ), - ); + // One heading per top-level key, one list item per capability: a + // 150-row three-column table scrolls sideways and has no anchors. + const groups = Map.groupBy(rows, row => row.path.split('.')[0] ?? row.path); + for (const [group, groupRows] of groups) { + sections.push(`#### ${group}\n`); + sections.push(groupRows.map(capabilityItem).join('\n')); + } } return `${sections.join('\n\n')}\n`; diff --git a/website/plugins/rehype-table-cell-breaks.ts b/website/plugins/rehype-table-cell-breaks.ts new file mode 100644 index 000000000..791e61023 --- /dev/null +++ b/website/plugins/rehype-table-cell-breaks.ts @@ -0,0 +1,51 @@ +import type { Element, ElementContent, Root } from 'hast'; + +/** + * Insert `` into long `code` and link text inside table cells. + * + * Rspress tables use `table-layout: auto`, so an unbreakable token (a dotted + * capability key, a path, a URL) widens its column and the table scrolls + * sideways. `overflow-wrap: anywhere` shrinks the columns instead, but breaks + * short identifiers mid-word once five columns share the content width. A + * `` after separators and at camelCase boundaries is taken only when the + * token does not fit, and the Markdown source, search index, `llms-full.txt`, + * and clipboard keep the plain token. + */ + +/** Shorter tokens fit Rspress's 8rem cell minimum. */ +const MIN_LENGTH = 16; + +/** After a separator that ends a segment (`https://`, `a.b`, `x/y`), or between camelCase words. */ +const BREAK_AFTER = /(?<=[\w/][./@,_-])(?=\S)|(?<=[a-z0-9])(?=[A-Z])/g; + +const wbr = (): Element => ({ type: 'element', tagName: 'wbr', properties: {}, children: [] }); + +const isElement = (node: { readonly type: string }): node is Element => node.type === 'element'; + +function addBreaks(node: Element): void { + node.children = node.children.flatMap((child): ElementContent[] => { + if (child.type !== 'text' || child.value.length < MIN_LENGTH) { + return [child]; + } + return child.value + .split(BREAK_AFTER) + .flatMap((value, index) => (index === 0 ? [{ type: 'text', value }] : [wbr(), { type: 'text', value }])); + }); +} + +function walk(node: Element | Root, inCell: boolean): void { + for (const child of node.children) { + if (!isElement(child)) { + continue; + } + const cell = inCell || child.tagName === 'td' || child.tagName === 'th'; + if (cell && (child.tagName === 'code' || child.tagName === 'a')) { + addBreaks(child); + } + walk(child, cell); + } +} + +export function rehypeTableCellBreaks() { + return (tree: Root): void => walk(tree, false); +} diff --git a/website/rspress.config.ts b/website/rspress.config.ts index 4cfd1f4db..46b6912e2 100644 --- a/website/rspress.config.ts +++ b/website/rspress.config.ts @@ -11,6 +11,7 @@ import { } from '@shikijs/transformers'; import { generatedReference } from './plugins/generated-reference.ts'; import { cleanGeneratedApiMarkdown, mirrorApiLocale } from './plugins/mirror-api-locale.ts'; +import { rehypeTableCellBreaks } from './plugins/rehype-table-cell-breaks.ts'; const websiteDir = import.meta.dirname; const docsDir = path.join(websiteDir, 'docs'); @@ -97,6 +98,8 @@ export default defineConfig({ checkDeadLinks: { excludes: isGeneratedLlmsTarget }, checkAnchors: true, }, + // Long keys, paths, and URLs in table cells wrap instead of widening the table. + rehypePlugins: [rehypeTableCellBreaks], image: { checkDeadImages: true, },