Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 27 additions & 24 deletions website/plugins/generated-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -211,7 +212,7 @@ const messages = {
mcpTransports: 'MCP 传输与令牌字段',
pluginComponents: '插件组件',
pluginComponentsIntro:
'每张表的 `plugin` 部分,按点分能力路径展开。布尔行表示适配器会发出的组件;带状态的行记录宿主证据支持或保留该能力的原因。证据说明保留在 JSON 文件中。',
'每张表的 `plugin` 部分,按点分能力路径展开并按顶层键分组。布尔条目表示适配器会发出的组件;带状态的条目记录宿主证据支持或保留该能力的原因。证据说明保留在 JSON 文件中。',
headers: {
host: '宿主',
version: '观测版本',
Expand All @@ -223,8 +224,6 @@ const messages = {
scopes: '作用域',
source: '来源',
token: '令牌',
capability: '能力',
detail: '说明',
stdio: 'stdio',
streamableHttp: 'Streamable HTTP',
tokenFields: '接受路径令牌的字段',
Expand Down Expand Up @@ -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 {
Expand All @@ -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('<br />'), 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));
Expand Down Expand Up @@ -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`;
Expand Down
51 changes: 51 additions & 0 deletions website/plugins/rehype-table-cell-breaks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { Element, ElementContent, Root } from 'hast';

/**
* Insert `<wbr>` 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
* `<wbr>` 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);
}
3 changes: 3 additions & 0 deletions website/rspress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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,
},
Expand Down
Loading