diff --git a/packages/genui/a2ui-catalog-extractor/AGENTS.md b/packages/genui/a2ui-catalog-extractor/AGENTS.md new file mode 100644 index 0000000000..ed0096b183 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/AGENTS.md @@ -0,0 +1,14 @@ +# a2ui-catalog-extractor + +When editing this package's user documentation, update both `README.md` +and `readme.zh_cn.md`. The English README is the primary document and the +Chinese README should remain an equivalent translation, not a shorter +summary. + +Keep README examples tied to tests. If a documented component contract, +generated schema, CLI flow, or API example changes, update or add a test +fixture or test case in this package so the documented behavior is checked. + +The user-facing READMEs are for external package users only. Do not include +monorepo setup, workspace commands, current private-package status, or +repository-maintainer workflow in either README. diff --git a/packages/genui/a2ui-catalog-extractor/README.md b/packages/genui/a2ui-catalog-extractor/README.md index 2b5122f0a1..50bf7f04e0 100644 --- a/packages/genui/a2ui-catalog-extractor/README.md +++ b/packages/genui/a2ui-catalog-extractor/README.md @@ -1,131 +1,593 @@ # A2UI Catalog Extractor -`@lynx-js/a2ui-catalog-extractor` generates A2UI component catalog JSON from TypeDoc project reflections. -Developers author catalog-facing TypeScript interfaces and comments; this package consumes TypeDoc reflection data and writes `catalog.json`. +English | [简体中文](./readme.zh_cn.md) -The extractor does not parse TS/TSX source text, does not import the TypeScript compiler API, and does not ask developers to write JSON Schema. +`@lynx-js/a2ui-catalog-extractor` turns TypeScript component +interfaces into A2UI component catalog JSON. You write the public +component contract once as a TypeScript `interface`, describe it with +normal TypeDoc comments, and let this package generate the JSON Schema +that an A2UI agent can read. -## A2UI Catalog Shape +## What It Does -A2UI v0.9 catalogs describe the capabilities a renderer exposes to an agent: +A2UI catalogs describe what components a renderer supports. For each +component, the catalog tells an agent which props are valid, which props +are required, which enum values are allowed, and what each field means. -- `catalogId`: stable catalog identifier used during catalog negotiation. -- `components`: component name to JSON Schema for runtime component props. -- `functions`: named functions with JSON Schema `parameters` and a scalar `returnType`. -- `theme`: theme property name to JSON Schema. +This extractor generates the `components` part of an A2UI v0.9 catalog: -This package generates the `components` map and can wrap it with `catalogId`, `functions`, and `theme` through `createA2UICatalog`. +```json +{ + "QuickStartCard": { + "properties": { + "title": { "type": "string" } + }, + "required": ["title"] + } +} +``` + +It can also wrap those generated components with a `catalogId`, +`functions`, and `theme` through `createA2UICatalog`. + +## What It Does Not Do + +- It does not render A2UI UI. +- It does not parse TypeScript source text by hand. +- It does not use the TypeScript compiler API directly. +- It does not ask you to write JSON Schema in comments. +- It does not expand arbitrary imported type aliases or external + interfaces. + +The package consumes TypeDoc reflection data. This keeps the implementation +small, but it also means catalog-facing shapes should be written inline in +the marked interface. + +## Requirements + +- Node.js 22 or newer. +- TypeScript or TSX source files that TypeDoc can read. +- One TypeScript `interface` per catalog-facing component contract. -## Authoring Rules +## Installation + +### Package manager + +Install it as a development dependency: + +```bash +pnpm add -D @lynx-js/a2ui-catalog-extractor +``` + +Then add a script to your package: + +```json +{ + "scripts": { + "build:catalog": "a2ui-catalog-extractor --catalog-dir src/catalog --out-dir dist/catalog" + } +} +``` -Only TypeScript `interface` reflections are converted. -Mark the catalog-facing interface with the single custom tag: +Run it with: + +```bash +pnpm build:catalog +``` + +## Quick Start + +This example walks through a complete component contract from TypeScript +interface to generated catalog JSON. + +### 1. Create a catalog-facing interface + +Create `src/catalog/QuickStartCard.tsx`: ```tsx /** - * @a2uiCatalog Text + * Quick start card. + * + * @remarks Use this contract as a compact card example. + * @a2uiCatalog QuickStartCard */ -export interface TextProps { - /** Literal text or path binding. */ - text: string | { path: string }; - variant?: 'h1' | 'h2' | 'body'; +export interface QuickStartCardProps { + /** Card title text or data binding. */ + title: string | { path: string }; + /** Visual tone used by the renderer. */ + tone?: 'neutral' | 'accent'; + /** + * Tags shown below the title. + * + * @defaultValue `[]` + */ + tags?: string[]; + /** Author metadata rendered in the card footer. */ + author: { + /** Display name. */ + name: string; + /** Optional profile URL. */ + url?: string; + }; + /** + * Extra analytics context sent with user actions. + * + * @defaultValue `{}` + */ + context?: Record; } ``` -The generated schema is: +The important part is `@a2uiCatalog QuickStartCard`. It tells the +extractor that this interface should become a catalog component named +`QuickStartCard`. + +### 2. Generate catalog files + +Run: + +```bash +a2ui-catalog-extractor --catalog-dir src/catalog --out-dir dist/catalog +``` + +The extractor scans the catalog directory, finds interfaces marked with +`@a2uiCatalog`, and writes one file per component: + +```text +dist/catalog/ + QuickStartCard/ + catalog.json +``` + +### 3. Read the generated schema + +`dist/catalog/QuickStartCard/catalog.json` will look like this: ```json { - "Text": { + "QuickStartCard": { "properties": { - "text": { + "title": { "oneOf": [ - { "type": "string" }, + { + "type": "string" + }, { "type": "object", - "properties": { "path": { "type": "string" } }, - "required": ["path"], + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], "additionalProperties": false } ], - "description": "Literal text or path binding." + "description": "Card title text or data binding." }, - "variant": { + "tone": { "type": "string", - "enum": ["h1", "h2", "body"] + "enum": [ + "neutral", + "accent" + ], + "description": "Visual tone used by the renderer." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags shown below the title.", + "default": [] + }, + "author": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Display name." + }, + "url": { + "type": "string", + "description": "Optional profile URL." + } + }, + "required": [ + "name" + ], + "additionalProperties": false, + "description": "Author metadata rendered in the card footer." + }, + "context": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + }, + "description": "Extra analytics context sent with user actions.", + "default": {} } }, - "required": ["text"] + "required": [ + "title", + "author" + ], + "description": "Quick start card.\n\nUse this contract as a compact card example." } } ``` -## Comment Mapping +Notice the main conversions: + +- `title` is required because it does not use `?`. +- `tone` becomes a string enum. +- `tags?: string[]` becomes an optional array of strings. +- `author` becomes a strict inline object with + `additionalProperties: false`. +- `context?: Record` becomes an object + map with `additionalProperties`. +- TypeDoc comments become JSON Schema descriptions. -Only `@a2uiCatalog` is custom. -All other metadata uses standard TypeDoc-supported tags: +## Authoring Guide -- summary text maps to JSON Schema `description`. -- `@remarks` is appended to `description`. -- `@defaultValue` maps to JSON Schema `default`; JSON values are parsed when possible. Wrap object and array defaults in a code span, for example ``@defaultValue `{}```. -- `@deprecated` maps to JSON Schema `deprecated: true`. -- Optional properties are omitted from `required`. +### Mark only the catalog contract + +Only TypeScript `interface` reflections are converted. Put +`@a2uiCatalog` on the interface that describes the props an agent is +allowed to send: + +```tsx +/** + * @a2uiCatalog Text + */ +export interface TextProps { + text: string; +} +``` + +Do not put the tag on the component function: + +```tsx +export function Text(_props: TextProps) { + return null; +} +``` + +### Component names + +You can write the component name explicitly: + +```tsx +/** + * @a2uiCatalog Text + */ +export interface TextProps {} +``` + +If the tag is empty, the extractor infers the name from the interface by +removing a trailing `Props` or `ComponentProps`: + +```tsx +/** + * @a2uiCatalog + */ +export interface DemoTextProps {} +``` + +This becomes `DemoText`. + +### Comments become schema metadata + +Use normal TypeDoc comments: + +```tsx +/** + * User-facing card. + * + * @remarks Use this for compact summaries. + * @a2uiCatalog SummaryCard + */ +export interface SummaryCardProps { + /** + * Optional display density. + * + * @defaultValue `"comfortable"` + */ + density?: 'compact' | 'comfortable'; +} +``` + +The extractor maps comments like this: + +| TypeDoc comment | JSON Schema output | +| ----------------------------- | -------------------------------- | +| Summary text | `description` | +| `@remarks` | Appended to `description` | +| `@defaultValue` or `@default` | `default` | +| `@deprecated` | `deprecated: true` | +| Optional property `?` | Property omitted from `required` | + +For object and array defaults, put JSON inside a code span: + +```tsx +/** + * @defaultValue `{}` + */ +context?: Record; +``` -## Type Mapping +Without the code span, TypeDoc may pass formatted text instead of the raw +JSON value. -The extractor generates schema from the TypeDoc type model: +### Supported TypeScript shapes -- `string`, `number`, `boolean` -- string literal unions as `enum` -- other unions as `oneOf` -- `T[]`, `Array`, `ReadonlyArray` -- inline object type literals -- `Record` +| TypeScript shape | JSON Schema shape | +| ---------------------------- | ------------------------------------------------ | +| `string` | `{ "type": "string" }` | +| `number` | `{ "type": "number" }` | +| `boolean` | `{ "type": "boolean" }` | +| `'a' \| 'b'` | `{ "type": "string", "enum": ["a", "b"] }` | +| `string \| { path: string }` | `{ "oneOf": [...] }` | +| `T[]` | `{ "type": "array", "items": ... }` | +| `Array` | `{ "type": "array", "items": ... }` | +| `ReadonlyArray` | `{ "type": "array", "items": ... }` | +| `{ name: string }` | Strict object with `additionalProperties: false` | +| `Record` | Object map with `additionalProperties: ...` | -Unsupported references and ambiguous catalog-facing types such as `any`, `unknown`, `never`, `void`, and nullable unions fail with an actionable error. -Inline the catalog-facing shape in the marked interface instead of relying on imported type aliases. +### Unsupported or ambiguous types -## CLI +These types intentionally fail: -Run TypeDoc conversion and write one file per component: +- `any` +- `unknown` +- `null` +- `undefined` +- `never` +- `void` +- nullable unions such as `string | null` +- most imported aliases and referenced external interfaces +- `Record` or other non-string record keys + +Prefer explicit catalog contracts: + +```tsx +// Avoid this in catalog-facing interfaces. +type ExternalCardData = { + title: string; +}; + +export interface CardProps { + data: ExternalCardData; +} +``` + +Write the shape inline instead: + +```tsx +export interface CardProps { + data: { + title: string; + }; +} +``` + +## CLI Reference ```bash -a2ui-catalog-extractor --catalog-dir src/catalog --out-dir dist/catalog +a2ui-catalog-extractor [options] ``` -Use an existing TypeDoc JSON project: +| Option | Description | Default | +| ----------------------- | ---------------------------------------------------------------------------- | -------------- | +| `--catalog-dir ` | Directory to scan for source files. Repeatable. | `src/catalog` | +| `--source ` | Source file or directory to scan. Repeatable. | None | +| `--typedoc-json ` | Read an existing TypeDoc JSON project instead of running TypeDoc conversion. | None | +| `--out-dir ` | Directory where component catalog files are written. | `dist/catalog` | +| `--version`, `-v` | Print the package version. | None | +| `--help`, `-h` | Print usage. | None | + +`--source` and `--catalog-dir` can be used together. The extractor merges +all inputs, removes duplicates, sorts them, and then runs TypeDoc. + +The scanner accepts `.ts`, `.tsx`, `.js`, `.jsx`, `.mts`, and `.cts` +files. It ignores `.d.ts`, `node_modules`, `dist`, and `.turbo`. + +## Programmatic API + +### Generate components from source files + +```ts +import { + extractCatalogComponents, + writeComponentCatalogs, +} from '@lynx-js/a2ui-catalog-extractor'; + +const components = await extractCatalogComponents({ + sourceFiles: ['src/catalog/QuickStartCard.tsx'], +}); + +await writeComponentCatalogs({ + sourceFiles: ['src/catalog/QuickStartCard.tsx'], + outDir: 'dist/catalog', +}); +``` + +Use `cwd` when paths should be resolved relative to a specific project +directory: + +```ts +await writeComponentCatalogs({ + cwd: process.cwd(), + sourceFiles: ['src/catalog/QuickStartCard.tsx'], + outDir: 'dist/catalog', +}); +``` + +Use `tsconfig` when the project needs a specific TypeScript config: + +```ts +const components = await extractCatalogComponents({ + cwd: process.cwd(), + sourceFiles: ['src/catalog/QuickStartCard.tsx'], + tsconfig: 'tsconfig.json', +}); +``` + +### Generate components from TypeDoc JSON + +If your build already produces a TypeDoc JSON project, reuse it: + +```ts +import * as fs from 'node:fs'; + +import { + extractCatalogComponentsFromTypeDocJson, + writeCatalogComponents, +} from '@lynx-js/a2ui-catalog-extractor'; + +const projectJson = JSON.parse( + await fs.promises.readFile('typedoc.json', 'utf8'), +); +const components = extractCatalogComponentsFromTypeDocJson(projectJson); + +writeCatalogComponents(components, { + outDir: 'dist/catalog', +}); +``` + +The equivalent CLI command is: ```bash a2ui-catalog-extractor --typedoc-json typedoc.json --out-dir dist/catalog ``` -## API +### Create a full A2UI catalog object + +`createA2UICatalog` is a small helper that wraps generated components with +the other top-level A2UI catalog fields: ```ts import { createA2UICatalog, extractCatalogComponents, - extractCatalogComponentsFromTypeDocJson, - writeComponentCatalogs, } from '@lynx-js/a2ui-catalog-extractor'; const components = await extractCatalogComponents({ - sourceFiles: ['src/catalog/Text/index.tsx'], + sourceFiles: ['src/catalog/QuickStartCard.tsx'], }); const catalog = createA2UICatalog({ catalogId: 'https://example.com/catalogs/basic/v1/catalog.json', components, + functions: [ + { + name: 'formatDisplayValue', + description: 'Format a raw value for display.', + parameters: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + required: ['value'], + additionalProperties: false, + }, + returnType: 'string', + }, + ], + theme: { + accentColor: { type: 'string' }, + }, }); +``` -await writeComponentCatalogs({ - sourceFiles: ['src/catalog/Text/index.tsx'], - outDir: 'dist/catalog', -}); +`functions` and `theme` are not extracted from TypeScript. Pass them +explicitly if your catalog needs them. + +## Troubleshooting + +### `Unsupported ambiguous intrinsic TypeDoc type "unknown"` + +The catalog needs a concrete schema. Replace `unknown` or `any` with a +specific type: + +```tsx +// Fails. +payload: unknown; + +// Works. +payload: { + id: string; + count: number; +} +``` + +### `Unsupported nullable union` + +Nullable unions are not accepted: + +```tsx +// Fails. +label: string | null; +``` + +Make the property optional if it can be omitted: + +```tsx +label?: string; +``` + +Or model the state explicitly: -const componentsFromJson = extractCatalogComponentsFromTypeDocJson(projectJson); +```tsx +label: string | { path: string }; ``` +### `Unsupported TypeDoc reference` + +The extractor only understands a small set of references: +`Array`, `ReadonlyArray`, and `Record`. Inline object +shapes in the catalog-facing interface instead of importing aliases. + +### My output directory is empty + +Check these points: + +- The scanned files contain an `interface`, not only a `type`. +- The interface has `@a2uiCatalog`. +- The path passed to `--catalog-dir` or `--source` exists. +- The files are not `.d.ts`. +- TypeDoc can parse the files with your `tsconfig`. + +### The generated schema does not include inherited props + +Inherited members are skipped. This is intentional because runtime-only +props such as renderer context should not be part of the agent-facing +catalog. Put every catalog-facing prop directly on the marked interface. + +### Should I hand-write JSON Schema instead? + +No. Keep the contract in TypeScript and comments. Hand-written schema tends +to drift away from component props, while this package makes the catalog a +repeatable build artifact. + +### Does this replace TypeScript type checking? + +No. TypeDoc conversion is used to read reflection data, not to validate +your full application. Continue running your normal TypeScript, lint, and +test commands. + ## References - [A2UI Catalogs](https://a2ui.org/concepts/catalogs/) diff --git a/packages/genui/a2ui-catalog-extractor/readme.zh_cn.md b/packages/genui/a2ui-catalog-extractor/readme.zh_cn.md new file mode 100644 index 0000000000..314874d32e --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/readme.zh_cn.md @@ -0,0 +1,584 @@ +# A2UI Catalog Extractor + +[English](./README.md) | 简体中文 + +`@lynx-js/a2ui-catalog-extractor` 会把 TypeScript 组件接口转换成 A2UI +组件 catalog JSON。你只需要用 TypeScript `interface` 写一次组件的公开 +契约,用普通 TypeDoc 注释描述字段,然后让这个包生成 A2UI agent 可以读取的 +JSON Schema。 + +## 它解决什么问题 + +A2UI catalog 用来描述 renderer 支持哪些组件。对每个组件,catalog 会告诉 +agent 哪些 props 合法、哪些 props 必填、哪些 enum 值可用,以及每个字段的 +含义。 + +这个 extractor 生成 A2UI v0.9 catalog 中的 `components` 部分: + +```json +{ + "QuickStartCard": { + "properties": { + "title": { "type": "string" } + }, + "required": ["title"] + } +} +``` + +它也可以通过 `createA2UICatalog` 把生成的 components 包装进带 +`catalogId`、`functions` 和 `theme` 的完整 catalog 对象。 + +## 它不做什么 + +- 它不渲染 A2UI UI。 +- 它不手写解析 TypeScript 源码文本。 +- 它不直接使用 TypeScript compiler API。 +- 它不要求你在注释里写 JSON Schema。 +- 它不会展开任意导入的 type alias 或外部 interface。 + +这个包消费 TypeDoc reflection 数据。这样实现更小,但也意味着面向 catalog +的类型形状应该直接内联写在被标记的 interface 中。 + +## 环境要求 + +- Node.js 22 或更新版本。 +- TypeDoc 可以读取的 TypeScript 或 TSX 源文件。 +- 每个 catalog 组件契约使用一个 TypeScript `interface`。 + +## 安装 + +### 包管理器 + +把它安装为开发依赖: + +```bash +pnpm add -D @lynx-js/a2ui-catalog-extractor +``` + +然后在你的 package 中加入脚本: + +```json +{ + "scripts": { + "build:catalog": "a2ui-catalog-extractor --catalog-dir src/catalog --out-dir dist/catalog" + } +} +``` + +运行: + +```bash +pnpm build:catalog +``` + +## 快速开始 + +这个示例会完整演示如何从 TypeScript interface 生成 catalog JSON。 + +### 1. 创建面向 catalog 的 interface + +创建 `src/catalog/QuickStartCard.tsx`: + +```tsx +/** + * Quick start card. + * + * @remarks Use this contract as a compact card example. + * @a2uiCatalog QuickStartCard + */ +export interface QuickStartCardProps { + /** Card title text or data binding. */ + title: string | { path: string }; + /** Visual tone used by the renderer. */ + tone?: 'neutral' | 'accent'; + /** + * Tags shown below the title. + * + * @defaultValue `[]` + */ + tags?: string[]; + /** Author metadata rendered in the card footer. */ + author: { + /** Display name. */ + name: string; + /** Optional profile URL. */ + url?: string; + }; + /** + * Extra analytics context sent with user actions. + * + * @defaultValue `{}` + */ + context?: Record; +} +``` + +最关键的是 `@a2uiCatalog QuickStartCard`。它告诉 extractor:这个 interface +应该生成名为 `QuickStartCard` 的 catalog 组件。 + +### 2. 生成 catalog 文件 + +运行: + +```bash +a2ui-catalog-extractor --catalog-dir src/catalog --out-dir dist/catalog +``` + +extractor 会扫描 catalog 目录,找到带 `@a2uiCatalog` 的 interface,并为每个 +组件写出一个文件: + +```text +dist/catalog/ + QuickStartCard/ + catalog.json +``` + +### 3. 查看生成的 schema + +`dist/catalog/QuickStartCard/catalog.json` 会类似下面这样: + +```json +{ + "QuickStartCard": { + "properties": { + "title": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + ], + "description": "Card title text or data binding." + }, + "tone": { + "type": "string", + "enum": [ + "neutral", + "accent" + ], + "description": "Visual tone used by the renderer." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags shown below the title.", + "default": [] + }, + "author": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Display name." + }, + "url": { + "type": "string", + "description": "Optional profile URL." + } + }, + "required": [ + "name" + ], + "additionalProperties": false, + "description": "Author metadata rendered in the card footer." + }, + "context": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + }, + "description": "Extra analytics context sent with user actions.", + "default": {} + } + }, + "required": [ + "title", + "author" + ], + "description": "Quick start card.\n\nUse this contract as a compact card example." + } +} +``` + +注意这些转换: + +- `title` 没有 `?`,所以是必填。 +- `tone` 变成字符串 enum。 +- `tags?: string[]` 变成可选的字符串数组。 +- `author` 变成严格的内联对象,并带有 `additionalProperties: false`。 +- `context?: Record` 变成 object map,并用 + `additionalProperties` 描述值类型。 +- TypeDoc 注释变成 JSON Schema description。 + +## 编写规则 + +### 只标记 catalog 契约 + +只有 TypeScript `interface` reflection 会被转换。把 `@a2uiCatalog` 放在 +agent 被允许发送的 props interface 上: + +```tsx +/** + * @a2uiCatalog Text + */ +export interface TextProps { + text: string; +} +``` + +不要把 tag 放在组件函数上: + +```tsx +export function Text(_props: TextProps) { + return null; +} +``` + +### 组件名 + +你可以显式写组件名: + +```tsx +/** + * @a2uiCatalog Text + */ +export interface TextProps {} +``` + +如果 tag 内容为空,extractor 会从 interface 名推断组件名,规则是去掉结尾的 +`Props` 或 `ComponentProps`: + +```tsx +/** + * @a2uiCatalog + */ +export interface DemoTextProps {} +``` + +这会生成 `DemoText`。 + +### 注释会变成 schema 元数据 + +使用普通 TypeDoc 注释: + +```tsx +/** + * User-facing card. + * + * @remarks Use this for compact summaries. + * @a2uiCatalog SummaryCard + */ +export interface SummaryCardProps { + /** + * Optional display density. + * + * @defaultValue `"comfortable"` + */ + density?: 'compact' | 'comfortable'; +} +``` + +extractor 的映射规则如下: + +| TypeDoc 注释 | JSON Schema 输出 | +| ----------------------------- | -------------------- | +| summary 文本 | `description` | +| `@remarks` | 追加到 `description` | +| `@defaultValue` 或 `@default` | `default` | +| `@deprecated` | `deprecated: true` | +| 可选属性 `?` | 不放入 `required` | + +对象和数组默认值建议把 JSON 放在 code span 里: + +```tsx +/** + * @defaultValue `{}` + */ +context?: Record; +``` + +如果不用 code span,TypeDoc 可能传入格式化后的文本,而不是原始 JSON 值。 + +### 支持的 TypeScript 形状 + +| TypeScript 形状 | JSON Schema 形状 | +| ---------------------------- | -------------------------------------------- | +| `string` | `{ "type": "string" }` | +| `number` | `{ "type": "number" }` | +| `boolean` | `{ "type": "boolean" }` | +| `'a' \| 'b'` | `{ "type": "string", "enum": ["a", "b"] }` | +| `string \| { path: string }` | `{ "oneOf": [...] }` | +| `T[]` | `{ "type": "array", "items": ... }` | +| `Array` | `{ "type": "array", "items": ... }` | +| `ReadonlyArray` | `{ "type": "array", "items": ... }` | +| `{ name: string }` | 带 `additionalProperties: false` 的严格对象 | +| `Record` | 带 `additionalProperties: ...` 的 object map | + +### 不支持或含义不明确的类型 + +这些类型会故意报错: + +- `any` +- `unknown` +- `null` +- `undefined` +- `never` +- `void` +- `string | null` 这样的 nullable union +- 大多数导入的 alias 和被引用的外部 interface +- `Record` 或其他非 string record key + +建议写明确的 catalog 契约: + +```tsx +// 不建议在 catalog-facing interface 中这样写。 +type ExternalCardData = { + title: string; +}; + +export interface CardProps { + data: ExternalCardData; +} +``` + +改成内联形状: + +```tsx +export interface CardProps { + data: { + title: string; + }; +} +``` + +## CLI 参考 + +```bash +a2ui-catalog-extractor [options] +``` + +| 选项 | 说明 | 默认值 | +| ----------------------- | -------------------------------------------------------------- | -------------- | +| `--catalog-dir ` | 要扫描的源码目录。可重复。 | `src/catalog` | +| `--source ` | 要扫描的源码文件或目录。可重复。 | 无 | +| `--typedoc-json ` | 读取已有 TypeDoc JSON project,不重新运行 TypeDoc conversion。 | 无 | +| `--out-dir ` | 写出组件 catalog 文件的目录。 | `dist/catalog` | +| `--version`, `-v` | 打印包版本。 | 无 | +| `--help`, `-h` | 打印用法。 | 无 | + +`--source` 和 `--catalog-dir` 可以一起使用。extractor 会合并全部输入、去重、 +排序,然后运行 TypeDoc。 + +扫描器接受 `.ts`、`.tsx`、`.js`、`.jsx`、`.mts` 和 `.cts` 文件。它会忽略 +`.d.ts`、`node_modules`、`dist` 和 `.turbo`。 + +## 编程 API + +### 从源码文件生成 components + +```ts +import { + extractCatalogComponents, + writeComponentCatalogs, +} from '@lynx-js/a2ui-catalog-extractor'; + +const components = await extractCatalogComponents({ + sourceFiles: ['src/catalog/QuickStartCard.tsx'], +}); + +await writeComponentCatalogs({ + sourceFiles: ['src/catalog/QuickStartCard.tsx'], + outDir: 'dist/catalog', +}); +``` + +如果路径需要相对某个项目目录解析,使用 `cwd`: + +```ts +await writeComponentCatalogs({ + cwd: process.cwd(), + sourceFiles: ['src/catalog/QuickStartCard.tsx'], + outDir: 'dist/catalog', +}); +``` + +如果项目需要指定 TypeScript 配置,使用 `tsconfig`: + +```ts +const components = await extractCatalogComponents({ + cwd: process.cwd(), + sourceFiles: ['src/catalog/QuickStartCard.tsx'], + tsconfig: 'tsconfig.json', +}); +``` + +### 从 TypeDoc JSON 生成 components + +如果你的构建流程已经生成 TypeDoc JSON project,可以直接复用: + +```ts +import * as fs from 'node:fs'; + +import { + extractCatalogComponentsFromTypeDocJson, + writeCatalogComponents, +} from '@lynx-js/a2ui-catalog-extractor'; + +const projectJson = JSON.parse( + await fs.promises.readFile('typedoc.json', 'utf8'), +); +const components = extractCatalogComponentsFromTypeDocJson(projectJson); + +writeCatalogComponents(components, { + outDir: 'dist/catalog', +}); +``` + +等价的 CLI 命令是: + +```bash +a2ui-catalog-extractor --typedoc-json typedoc.json --out-dir dist/catalog +``` + +### 创建完整 A2UI catalog 对象 + +`createA2UICatalog` 是一个小 helper,用来把生成的 components 包装进其他 +A2UI catalog 顶层字段: + +```ts +import { + createA2UICatalog, + extractCatalogComponents, +} from '@lynx-js/a2ui-catalog-extractor'; + +const components = await extractCatalogComponents({ + sourceFiles: ['src/catalog/QuickStartCard.tsx'], +}); + +const catalog = createA2UICatalog({ + catalogId: 'https://example.com/catalogs/basic/v1/catalog.json', + components, + functions: [ + { + name: 'formatDisplayValue', + description: 'Format a raw value for display.', + parameters: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + required: ['value'], + additionalProperties: false, + }, + returnType: 'string', + }, + ], + theme: { + accentColor: { type: 'string' }, + }, +}); +``` + +`functions` 和 `theme` 不会从 TypeScript 自动提取。如果 catalog 需要这些 +字段,请显式传入。 + +## 故障排查和 FAQ + +### `Unsupported ambiguous intrinsic TypeDoc type "unknown"` + +catalog 需要明确 schema。把 `unknown` 或 `any` 改成具体类型: + +```tsx +// 会失败。 +payload: unknown; + +// 可以工作。 +payload: { + id: string; + count: number; +} +``` + +### `Unsupported nullable union` + +nullable union 不被接受: + +```tsx +// 会失败。 +label: string | null; +``` + +如果字段可以省略,把它设为可选: + +```tsx +label?: string; +``` + +或者显式建模状态: + +```tsx +label: string | { path: string }; +``` + +### `Unsupported TypeDoc reference` + +extractor 只理解少量 reference:`Array`、`ReadonlyArray` 和 +`Record`。请在 catalog-facing interface 中内联对象形状,不要导入 +alias。 + +### 输出目录为空 + +检查这些点: + +- 被扫描文件里有 `interface`,而不只是 `type`。 +- interface 带有 `@a2uiCatalog`。 +- 传给 `--catalog-dir` 或 `--source` 的路径存在。 +- 文件不是 `.d.ts`。 +- TypeDoc 可以用你的 `tsconfig` 解析这些文件。 + +### 生成的 schema 为什么没有继承来的 props + +继承成员会被跳过。这是有意设计,因为 renderer context 这类运行时字段不应该 +成为 agent-facing catalog 的一部分。请把所有面向 catalog 的 props 直接写在被 +标记的 interface 上。 + +### 我应该手写 JSON Schema 吗 + +不应该。请把契约保留在 TypeScript 和注释里。手写 schema 很容易和组件 props +漂移,而这个包会让 catalog 成为可重复生成的构建产物。 + +### 这能替代 TypeScript 类型检查吗 + +不能。TypeDoc conversion 只是用来读取 reflection 数据,不是用来验证完整应用。 +请继续运行正常的 TypeScript、lint 和测试命令。 + +## 参考资料 + +- [A2UI Catalogs](https://a2ui.org/concepts/catalogs/) +- [A2UI v0.9 protocol](https://a2ui.org/specification/v0.9-a2ui/) +- [TypeDoc custom tags](https://typedoc.org/documents/Tags.html) +- [TypeDoc JSON output](https://typedoc.org/documents/Options.Output.html) diff --git a/packages/genui/a2ui-catalog-extractor/test/extractor.test.ts b/packages/genui/a2ui-catalog-extractor/test/extractor.test.ts index 98bcf684a3..33564af941 100644 --- a/packages/genui/a2ui-catalog-extractor/test/extractor.test.ts +++ b/packages/genui/a2ui-catalog-extractor/test/extractor.test.ts @@ -8,12 +8,14 @@ import { fileURLToPath } from 'node:url'; import { afterAll, describe, expect, test } from '@rstest/core'; +import { runCli } from '../src/cli.js'; import { createA2UICatalog, extractCatalogComponents, findCatalogSourceFiles, writeComponentCatalogs, } from '../src/index.js'; +import type { TypeDocProject } from '../src/index.js'; const __filename = fileURLToPath(import.meta.url); const packageDir = path.resolve(path.dirname(__filename), '..'); @@ -36,6 +38,7 @@ describe('extractCatalogComponents', () => { expect(sourceFiles.map(file => path.basename(file))).toEqual([ 'DemoCard.tsx', 'DemoText.tsx', + 'QuickStartCard.tsx', ]); const components = await extractCatalogComponents({ @@ -51,6 +54,7 @@ describe('extractCatalogComponents', () => { expect(Object.keys(componentsByName).sort()).toEqual([ 'DemoCard', 'DemoText', + 'QuickStartCard', ]); expect(componentsByName['DemoCard']).toMatchObject({ filePath: path.join(catalogFixtureDir, 'DemoCard.tsx'), @@ -64,6 +68,12 @@ describe('extractCatalogComponents', () => { name: 'DemoText', schema: expectedCatalogs['DemoText']!['DemoText'], }); + expect(componentsByName['QuickStartCard']).toMatchObject({ + filePath: path.join(catalogFixtureDir, 'QuickStartCard.tsx'), + interfaceName: 'QuickStartCardProps', + name: 'QuickStartCard', + schema: expectedCatalogs['QuickStartCard']!['QuickStartCard'], + }); }); test('writes catalog.json files from TSX catalog fixtures', async () => { @@ -80,6 +90,7 @@ describe('extractCatalogComponents', () => { expect(components.map(component => component.name).sort()).toEqual([ 'DemoCard', 'DemoText', + 'QuickStartCard', ]); for (const componentName of Object.keys(expectedCatalogs)) { @@ -100,11 +111,77 @@ describe('extractCatalogComponents', () => { expect(createA2UICatalog({ catalogId: 'https://example.com/catalog.json', components, + functions: [ + { + description: 'Format a raw value for display.', + name: 'formatDisplayValue', + parameters: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + required: ['value'], + additionalProperties: false, + }, + returnType: 'string', + }, + ], + theme: { + accentColor: { type: 'string' }, + }, })).toEqual({ catalogId: 'https://example.com/catalog.json', components: { DemoCard: expectedCatalogs['DemoCard']!['DemoCard'], DemoText: expectedCatalogs['DemoText']!['DemoText'], + QuickStartCard: expectedCatalogs['QuickStartCard']!['QuickStartCard'], + }, + functions: [ + { + description: 'Format a raw value for display.', + name: 'formatDisplayValue', + parameters: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + required: ['value'], + additionalProperties: false, + }, + returnType: 'string', + }, + ], + theme: { + accentColor: { type: 'string' }, + }, + }); + }); + + test('writes catalog files from an existing TypeDoc JSON project through the CLI', async () => { + const cwd = createTempDir(); + const typedocJsonPath = path.join(cwd, 'typedoc.json'); + fs.writeFileSync( + typedocJsonPath, + `${JSON.stringify(createCliTypeDocProjectFixture(), null, 2)}\n`, + ); + + await expect(runCli([ + '--typedoc-json', + 'typedoc.json', + '--out-dir', + 'catalog-out', + ], cwd)).resolves.toBe(0); + + expect(readCatalogJson(path.join(cwd, 'catalog-out'), 'CliBadge')).toEqual({ + CliBadge: { + properties: { + label: { + type: 'string', + description: 'Badge label.', + }, + }, + required: ['label'], + description: 'CLI badge fixture.', }, }); }); @@ -155,3 +232,36 @@ function readCatalogJson( ), ) as Record; } + +function createCliTypeDocProjectFixture(): TypeDocProject { + return { + children: [ + { + kindString: 'Interface', + name: 'CliBadgeProps', + comment: { + summary: [{ text: 'CLI badge fixture.' }], + blockTags: [ + { + tag: '@a2uiCatalog', + content: [{ text: 'CliBadge' }], + }, + ], + }, + children: [ + { + kindString: 'Property', + name: 'label', + comment: { + summary: [{ text: 'Badge label.' }], + }, + type: { + type: 'intrinsic', + name: 'string', + }, + }, + ], + }, + ], + }; +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/catalog/QuickStartCard.tsx b/packages/genui/a2ui-catalog-extractor/test/fixtures/catalog/QuickStartCard.tsx new file mode 100644 index 0000000000..2142943d63 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/catalog/QuickStartCard.tsx @@ -0,0 +1,39 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** + * Quick start card. + * + * @remarks Use this contract as a compact card example. + * @a2uiCatalog QuickStartCard + */ +export interface QuickStartCardProps { + /** Card title text or data binding. */ + title: string | { path: string }; + /** Visual tone used by the renderer. */ + tone?: 'neutral' | 'accent'; + /** + * Tags shown below the title. + * + * @defaultValue `[]` + */ + tags?: string[]; + /** Author metadata rendered in the card footer. */ + author: { + /** Display name. */ + name: string; + /** Optional profile URL. */ + url?: string; + }; + /** + * Extra analytics context sent with user actions. + * + * @defaultValue `{}` + */ + context?: Record; +} + +export function QuickStartCard(_props: QuickStartCardProps): null { + return null; +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/expected-catalog/QuickStartCard/catalog.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/expected-catalog/QuickStartCard/catalog.json new file mode 100644 index 0000000000..4099478a8a --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/expected-catalog/QuickStartCard/catalog.json @@ -0,0 +1,83 @@ +{ + "QuickStartCard": { + "properties": { + "title": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + ], + "description": "Card title text or data binding." + }, + "tone": { + "type": "string", + "enum": [ + "neutral", + "accent" + ], + "description": "Visual tone used by the renderer." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags shown below the title.", + "default": [] + }, + "author": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Display name." + }, + "url": { + "type": "string", + "description": "Optional profile URL." + } + }, + "required": [ + "name" + ], + "additionalProperties": false, + "description": "Author metadata rendered in the card footer." + }, + "context": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + }, + "description": "Extra analytics context sent with user actions.", + "default": {} + } + }, + "required": [ + "title", + "author" + ], + "description": "Quick start card.\n\nUse this contract as a compact card example." + } +} diff --git a/website/.gitignore b/website/.gitignore index 2c21ea9f5a..58112a5487 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -3,6 +3,8 @@ docs/en/api docs/zh/api docs/en/changelog docs/zh/changelog +docs/en/guide/genui +docs/zh/guide/genui # api-extractor temp diff --git a/website/rspress.config.ts b/website/rspress.config.ts index fbf64b84f1..f463fa73a2 100644 --- a/website/rspress.config.ts +++ b/website/rspress.config.ts @@ -15,7 +15,11 @@ import { } from '@shikijs/transformers'; import { camelCase } from 'change-case'; -import { createAPI, createChangelogs } from './sidebars/index.js'; +import { + createAPI, + createChangelogs, + createGenUIGuideReadmeDocs, +} from './sidebars/index.js'; const isDev = process.env['NODE_ENV'] === 'development'; @@ -399,6 +403,11 @@ const CHANGELOG_ZH = { ), }; +const GENUI = createGenUIGuideReadmeDocs({ + repositoryRoot: join(__dirname, '..'), + websiteRoot: __dirname, +}); + const config: UserConfig = defineConfig({ root: 'docs', llms: true, @@ -651,6 +660,7 @@ const config: UserConfig = defineConfig({ }, ], }, + GENUI.en, ], '/zh/guide/': [ { @@ -729,6 +739,7 @@ const config: UserConfig = defineConfig({ }, ], }, + GENUI.zh, ], }, nav: [ diff --git a/website/sidebars/genui.ts b/website/sidebars/genui.ts new file mode 100644 index 0000000000..2ecde8c44d --- /dev/null +++ b/website/sidebars/genui.ts @@ -0,0 +1,85 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import fs from 'node:fs'; +import path from 'node:path'; + +import type { SidebarGroup } from '@rspress/core'; + +export function createGenUIGuideReadmeDocs(options: { + repositoryRoot: string; + websiteRoot: string; +}): { + en: SidebarGroup; + zh: SidebarGroup; +} { + const packageRoot = path.join( + options.repositoryRoot, + 'packages/genui/a2ui-catalog-extractor', + ); + + syncReadme({ + languageSwitch: + 'English | 简体中文', + outFile: path.join( + options.websiteRoot, + 'docs/en/guide/genui/a2ui-catalog-extractor.md', + ), + sourceFile: path.join(packageRoot, 'README.md'), + switchPattern: /^English \| \[简体中文\]\(\.\/readme\.zh_cn\.md\)$/m, + }); + + syncReadme({ + languageSwitch: + 'English | 简体中文', + outFile: path.join( + options.websiteRoot, + 'docs/zh/guide/genui/a2ui-catalog-extractor.md', + ), + sourceFile: path.join(packageRoot, 'readme.zh_cn.md'), + switchPattern: /^\[English\]\(\.\/README\.md\) \| 简体中文$/m, + }); + + return { + en: { + text: 'GenUI', + items: [ + { + text: 'A2UI Catalog Extractor', + link: '/guide/genui/a2ui-catalog-extractor', + }, + ], + }, + zh: { + text: 'GenUI', + items: [ + { + text: 'A2UI Catalog Extractor', + link: '/zh/guide/genui/a2ui-catalog-extractor', + }, + ], + }, + }; +} + +function syncReadme(options: { + languageSwitch: string; + outFile: string; + sourceFile: string; + switchPattern: RegExp; +}): void { + const content = fs.readFileSync(options.sourceFile, 'utf8'); + const nextContent = content.replace( + options.switchPattern, + options.languageSwitch, + ); + + if (nextContent === content) { + throw new Error( + `Failed to rewrite language switch in ${options.sourceFile}.`, + ); + } + + fs.mkdirSync(path.dirname(options.outFile), { recursive: true }); + fs.writeFileSync(options.outFile, nextContent); +} diff --git a/website/sidebars/index.ts b/website/sidebars/index.ts index d541d68820..1ec8549a59 100644 --- a/website/sidebars/index.ts +++ b/website/sidebars/index.ts @@ -4,3 +4,4 @@ export * from './api.js'; export * from './changelog.js'; +export * from './genui.js';