From 111f4bc6780bf4498c75ba6b1447ac27d66ac3a0 Mon Sep 17 00:00:00 2001 From: Haoyang Wang <12288479+PupilTong@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:34:01 +0800 Subject: [PATCH 01/10] feat: add TypeDoc A2UI catalog extractor --- .github/a2ui-catalog.instructions.md | 7 + .../genui/a2ui-catalog-extractor/README.md | 134 +++ .../genui/a2ui-catalog-extractor/package.json | 38 + .../a2ui-catalog-extractor/rslib.config.ts | 17 + .../a2ui-catalog-extractor/rstest.config.ts | 10 + .../skills/a2ui-catalog-extractor/SKILL.md | 30 + .../genui/a2ui-catalog-extractor/src/cli.ts | 170 ++++ .../genui/a2ui-catalog-extractor/src/index.ts | 764 ++++++++++++++++++ .../test/extractor.test.ts | 278 +++++++ .../tsconfig.build.json | 5 + .../a2ui-catalog-extractor/tsconfig.json | 14 + .../genui/a2ui/src/catalog/Button/index.tsx | 3 + .../genui/a2ui/src/catalog/Card/index.tsx | 3 + .../genui/a2ui/src/catalog/CheckBox/index.tsx | 3 + .../genui/a2ui/src/catalog/Column/index.tsx | 7 +- .../genui/a2ui/src/catalog/Divider/index.tsx | 3 + .../genui/a2ui/src/catalog/Image/index.tsx | 10 +- .../genui/a2ui/src/catalog/List/index.tsx | 5 +- .../a2ui/src/catalog/RadioGroup/index.tsx | 3 + packages/genui/a2ui/src/catalog/Row/index.tsx | 7 +- .../genui/a2ui/src/catalog/Text/index.tsx | 3 + .../genui/a2ui/tools/catalog_generator.ts | 285 +------ packages/genui/tsconfig.json | 1 + pnpm-lock.yaml | 88 ++ rstest.config.ts | 5 +- 25 files changed, 1620 insertions(+), 273 deletions(-) create mode 100644 .github/a2ui-catalog.instructions.md create mode 100644 packages/genui/a2ui-catalog-extractor/README.md create mode 100644 packages/genui/a2ui-catalog-extractor/package.json create mode 100644 packages/genui/a2ui-catalog-extractor/rslib.config.ts create mode 100644 packages/genui/a2ui-catalog-extractor/rstest.config.ts create mode 100644 packages/genui/a2ui-catalog-extractor/skills/a2ui-catalog-extractor/SKILL.md create mode 100644 packages/genui/a2ui-catalog-extractor/src/cli.ts create mode 100644 packages/genui/a2ui-catalog-extractor/src/index.ts create mode 100644 packages/genui/a2ui-catalog-extractor/test/extractor.test.ts create mode 100644 packages/genui/a2ui-catalog-extractor/tsconfig.build.json create mode 100644 packages/genui/a2ui-catalog-extractor/tsconfig.json diff --git a/.github/a2ui-catalog.instructions.md b/.github/a2ui-catalog.instructions.md new file mode 100644 index 0000000000..916e4a5770 --- /dev/null +++ b/.github/a2ui-catalog.instructions.md @@ -0,0 +1,7 @@ +--- +applyTo: "packages/genui/a2ui*/**" +--- + +When maintaining A2UI component catalogs, keep the catalog-facing contract in a TypeScript interface marked with `@a2uiCatalog `. The extractor consumes TypeDoc reflection data and does not parse TS/TSX source itself, so inline the JSON-schema-facing property shape instead of relying on aliases or external interfaces. + +Only `@a2uiCatalog` is a custom tag. Use standard TypeDoc-supported comments and tags for metadata: summaries for descriptions, `@remarks` for additional description, `@defaultValue` for schema defaults, and `@deprecated` for deprecated fields. Do not write JSON Schema in comments. Preserve existing enum order when regenerating catalog JSON, because catalog snapshots and LLM prompts can depend on deterministic option ordering. diff --git a/packages/genui/a2ui-catalog-extractor/README.md b/packages/genui/a2ui-catalog-extractor/README.md new file mode 100644 index 0000000000..86fe064bf0 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/README.md @@ -0,0 +1,134 @@ +# 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`. + +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. + +## A2UI Catalog Shape + +A2UI v0.9 catalogs describe the capabilities a renderer exposes to an agent: + +- `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 package generates the `components` map and can wrap it with `catalogId`, `functions`, and `theme` through `createA2UICatalog`. + +## Authoring Rules + +Only TypeScript `interface` reflections are converted. +Mark the catalog-facing interface with the single custom tag: + +```tsx +/** + * @a2uiCatalog Text + */ +export interface TextProps { + /** Literal text or path binding. */ + text: string | { path: string }; + variant?: 'h1' | 'h2' | 'body'; +} +``` + +The generated schema is: + +```json +{ + "Text": { + "properties": { + "text": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "properties": { "path": { "type": "string" } }, + "required": ["path"], + "additionalProperties": false + } + ], + "description": "Literal text or path binding." + }, + "variant": { + "type": "string", + "enum": ["h1", "h2", "body"] + } + }, + "required": ["text"] + } +} +``` + +## Comment Mapping + +Only `@a2uiCatalog` is custom. +All other metadata uses standard TypeDoc-supported tags: + +- 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`. + +## Type Mapping + +The extractor generates schema from the TypeDoc type model: + +- `string`, `number`, `boolean` +- string literal unions as `enum` +- other unions as `oneOf` +- `T[]`, `Array`, `ReadonlyArray` +- inline object type literals +- `Record` + +Unsupported references fail with an actionable error. +Inline the catalog-facing shape in the marked interface instead of relying on imported type aliases. + +## CLI + +Run TypeDoc conversion and write one file per component: + +```bash +a2ui-catalog-extractor --catalog-dir src/catalog --out-dir dist/catalog +``` + +Use an existing TypeDoc JSON project: + +```bash +a2ui-catalog-extractor --typedoc-json typedoc.json --out-dir dist/catalog +``` + +## API + +```ts +import { + createA2UICatalog, + extractCatalogComponents, + extractCatalogComponentsFromTypeDocJson, + writeComponentCatalogs, +} from '@lynx-js/a2ui-catalog-extractor'; + +const components = await extractCatalogComponents({ + sourceFiles: ['src/catalog/Text/index.tsx'], +}); + +const catalog = createA2UICatalog({ + catalogId: 'https://example.com/catalogs/basic/v1/catalog.json', + components, +}); + +await writeComponentCatalogs({ + sourceFiles: ['src/catalog/Text/index.tsx'], + outDir: 'dist/catalog', +}); + +const componentsFromJson = extractCatalogComponentsFromTypeDocJson(projectJson); +``` + +## References + +- [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/package.json b/packages/genui/a2ui-catalog-extractor/package.json new file mode 100644 index 0000000000..07c47fb088 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/package.json @@ -0,0 +1,38 @@ +{ + "name": "@lynx-js/a2ui-catalog-extractor", + "version": "0.0.0", + "private": true, + "description": "TypeDoc-driven A2UI catalog extractor for TypeScript interfaces.", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./skill": "./skills/a2ui-catalog-extractor/SKILL.md", + "./package.json": "./package.json" + }, + "bin": { + "a2ui-catalog-extractor": "./dist/cli.js" + }, + "files": [ + "dist", + "skills", + "README.md" + ], + "scripts": { + "build": "rslib build", + "test": "rstest" + }, + "dependencies": { + "typedoc": "^0.28.19" + }, + "devDependencies": { + "@rstest/core": "catalog:rstest", + "@types/node": "^24.10.13" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/genui/a2ui-catalog-extractor/rslib.config.ts b/packages/genui/a2ui-catalog-extractor/rslib.config.ts new file mode 100644 index 0000000000..8a1d0b77cb --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/rslib.config.ts @@ -0,0 +1,17 @@ +// 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 { defineConfig } from '@rslib/core'; + +export default defineConfig({ + lib: [ + { format: 'esm', syntax: 'es2022', dts: { bundle: true, tsgo: true } }, + ], + source: { + entry: { + index: './src/index.ts', + cli: './src/cli.ts', + }, + tsconfigPath: './tsconfig.build.json', + }, +}); diff --git a/packages/genui/a2ui-catalog-extractor/rstest.config.ts b/packages/genui/a2ui-catalog-extractor/rstest.config.ts new file mode 100644 index 0000000000..c8d4cce271 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/rstest.config.ts @@ -0,0 +1,10 @@ +// 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 { defineConfig } from '@rstest/core'; + +export default defineConfig({ + name: 'genui/a2ui-catalog-extractor', + globals: true, + include: ['test/**/*.test.ts'], +}); diff --git a/packages/genui/a2ui-catalog-extractor/skills/a2ui-catalog-extractor/SKILL.md b/packages/genui/a2ui-catalog-extractor/skills/a2ui-catalog-extractor/SKILL.md new file mode 100644 index 0000000000..7b1c535d95 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/skills/a2ui-catalog-extractor/SKILL.md @@ -0,0 +1,30 @@ +--- +name: a2ui-catalog-extractor +description: Generate or maintain A2UI catalog JSON from TypeDoc reflections for TypeScript interfaces marked with @a2uiCatalog. +--- + +# A2UI Catalog Extractor + +Use this skill when a task involves authoring, updating, debugging, or generating A2UI catalog JSON from TSX/TypeScript component interfaces. + +## Workflow + +1. Mark only the catalog-facing TypeScript interface with `@a2uiCatalog `. +2. Use only standard TypeDoc-supported tags besides `@a2uiCatalog`: summaries, `@remarks`, `@defaultValue`, and `@deprecated`. +3. Keep catalog-facing shapes inline in the marked interface. The extractor consumes TypeDoc reflection data and does not parse source files itself. +4. Generate component catalog files with: + +```bash +a2ui-catalog-extractor --catalog-dir src/catalog --out-dir dist/catalog +``` + +## Supported Type Shapes + +- `string`, `number`, `boolean` +- string literal unions, emitted as JSON Schema `enum` +- mixed unions, emitted as `oneOf` +- arrays with `T[]`, `Array`, or `ReadonlyArray` +- inline object type literals +- `Record` + +Developers should not write JSON Schema in comments. If extraction fails because a reference is unsupported, inline the component's catalog contract in the marked interface. diff --git a/packages/genui/a2ui-catalog-extractor/src/cli.ts b/packages/genui/a2ui-catalog-extractor/src/cli.ts new file mode 100644 index 0000000000..9cc7e0bda2 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/src/cli.ts @@ -0,0 +1,170 @@ +#!/usr/bin/env node +// 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 * as fs from 'node:fs'; +import { createRequire } from 'node:module'; +import * as path from 'node:path'; + +import { + extractCatalogComponentsFromTypeDocJson, + findCatalogSourceFiles, + writeCatalogComponents, + writeComponentCatalogs, +} from './index.js'; +import type { TypeDocProject } from './index.js'; + +interface CliOptions { + catalogDirs: string[]; + help: boolean; + outDir: string; + sourceInputs: string[]; + typedocJson?: string; + version: boolean; +} + +const usage = `Usage: a2ui-catalog-extractor [options] + +Options: + --catalog-dir Directory to scan for TypeScript catalog interfaces. + --source Source file or directory to scan. Repeatable. + --typedoc-json + Read an existing TypeDoc JSON project instead of + running TypeDoc conversion. + --out-dir Output directory for component catalog.json files. + --version Print the package version. + --help Print this help message. + +Defaults: + --catalog-dir src/catalog + --out-dir dist/catalog +`; + +export function parseCliArgs(args: string[]): CliOptions { + const options: CliOptions = { + catalogDirs: [], + help: false, + outDir: 'dist/catalog', + sourceInputs: [], + version: false, + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]!; + switch (arg) { + case '--catalog-dir': + options.catalogDirs.push(readValue(args, ++index, arg)); + break; + case '--source': + options.sourceInputs.push(readValue(args, ++index, arg)); + break; + case '--typedoc-json': + options.typedocJson = readValue(args, ++index, arg); + break; + case '--out-dir': + options.outDir = readValue(args, ++index, arg); + break; + case '--help': + case '-h': + options.help = true; + break; + case '--version': + case '-v': + options.version = true; + break; + default: + throw new Error(`Unknown option: ${arg}`); + } + } + + return options; +} + +export async function runCli( + args: string[], + cwd = process.cwd(), +): Promise { + const options = parseCliArgs(args); + + if (options.help) { + console.info(usage); + return 0; + } + + if (options.version) { + const require = createRequire(import.meta.url); + const packageJson = require('../package.json') as { version: string }; + console.info(packageJson.version); + return 0; + } + + if (options.typedocJson) { + const typedocJsonPath = path.resolve(cwd, options.typedocJson); + const project = JSON.parse( + fs.readFileSync(typedocJsonPath, 'utf8'), + ) as TypeDocProject; + const components = extractCatalogComponentsFromTypeDocJson(project, { + cwd, + }); + + writeCatalogComponents(components, { + cwd, + outDir: options.outDir, + }); + printGeneratedComponents(components); + return 0; + } + + const inputs = options.sourceInputs.length > 0 + ? options.sourceInputs + : (options.catalogDirs.length > 0 + ? options.catalogDirs + : ['src/catalog']); + + const sourceFiles = inputs.flatMap(input => + findCatalogSourceFiles(path.resolve(cwd, input)) + ); + const uniqueSourceFiles = [...new Set(sourceFiles)].sort((left, right) => + left.localeCompare(right) + ); + + if (uniqueSourceFiles.length === 0) { + throw new Error( + `No TypeScript source files found in ${inputs.join(', ')}.`, + ); + } + + const components = await writeComponentCatalogs({ + cwd, + outDir: options.outDir, + sourceFiles: uniqueSourceFiles, + }); + + printGeneratedComponents(components); + + return 0; +} + +function printGeneratedComponents(components: { name: string }[]): void { + console.info(`Generated ${components.length} A2UI component catalog files.`); + for (const component of components) { + console.info(`Generated strict schema for ${component.name}`); + } +} + +function readValue(args: string[], index: number, option: string): string { + const value = args[index]; + if (!value || value.startsWith('-')) { + throw new Error(`Missing value for ${option}.`); + } + return value; +} + +try { + if (import.meta.url === `file://${process.argv[1]}`) { + process.exitCode = await runCli(process.argv.slice(2)); + } +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} diff --git a/packages/genui/a2ui-catalog-extractor/src/index.ts b/packages/genui/a2ui-catalog-extractor/src/index.ts new file mode 100644 index 0000000000..78fd1e3b5b --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/src/index.ts @@ -0,0 +1,764 @@ +// 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 * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { Application, OptionDefaults, ReflectionKind } from 'typedoc'; +import type { ProjectReflection, TypeDocOptions } from 'typedoc'; + +export interface JsonSchema { + $ref?: string; + additionalProperties?: boolean | JsonSchema; + default?: unknown; + deprecated?: boolean; + description?: string; + enum?: string[]; + items?: JsonSchema; + oneOf?: JsonSchema[]; + properties?: Record; + required?: string[]; + type?: string; +} + +export interface CatalogComponent { + filePath: string; + interfaceName: string; + name: string; + schema: JsonSchema; +} + +export interface ExtractCatalogOptions { + cwd?: string; + sourceFiles: string[]; + tsconfig?: string; +} + +export interface ExtractCatalogFromTypeDocOptions { + cwd?: string; +} + +export interface WriteComponentCatalogOptions extends ExtractCatalogOptions { + outDir: string; +} + +export interface A2UICatalog { + catalogId: string; + components?: Record; + functions?: FunctionDefinition[]; + theme?: Record; +} + +export interface FunctionDefinition { + description?: string; + name: string; + parameters: JsonSchema; + returnType: + | 'string' + | 'number' + | 'boolean' + | 'array' + | 'object' + | 'any' + | 'void'; +} + +interface ParsedDoc { + a2uiCatalogName?: string; + defaultValue?: unknown; + deprecated?: boolean; + description?: string; +} + +export interface TypeDocProject { + children?: TypeDocReflection[]; +} + +export interface TypeDocReflection { + children?: TypeDocReflection[]; + comment?: TypeDocComment; + flags?: { + isOptional?: boolean; + }; + inheritedFrom?: unknown; + kind?: number; + kindString?: string; + name: string; + sources?: TypeDocSource[]; + type?: TypeDocType; +} + +export interface TypeDocSource { + character?: number; + fileName?: string; + fullFileName?: string; + line?: number; +} + +export interface TypeDocComment { + blockTags?: TypeDocCommentTag[]; + modifierTags?: Iterable | Record; + summary?: TypeDocCommentDisplayPart[]; +} + +export interface TypeDocCommentTag { + content?: TypeDocCommentDisplayPart[]; + tag: string; +} + +export interface TypeDocCommentDisplayPart { + code?: string; + content?: TypeDocCommentDisplayPart[]; + kind?: string; + tag?: string; + target?: unknown; + text?: string; +} + +export interface TypeDocType { + declaration?: TypeDocReflection; + elementType?: TypeDocType; + name?: string; + qualifiedName?: string; + target?: TypeDocType; + type: string; + typeArguments?: TypeDocType[]; + types?: TypeDocType[]; + value?: bigint | boolean | number | string | null; +} + +const supportedSourceExtensions = new Set([ + '.cts', + '.js', + '.jsx', + '.mts', + '.ts', + '.tsx', +]); + +export function findCatalogSourceFiles(inputPath: string): string[] { + const absoluteInputPath = path.resolve(inputPath); + if (!fs.existsSync(absoluteInputPath)) { + return []; + } + + const stat = fs.statSync(absoluteInputPath); + if (stat.isFile()) { + return isSupportedSourceFile(absoluteInputPath) ? [absoluteInputPath] : []; + } + + const files: string[] = []; + collectSourceFiles(absoluteInputPath, files); + return files.sort((left, right) => left.localeCompare(right)); +} + +export async function extractCatalogComponents( + options: ExtractCatalogOptions, +): Promise { + const project = await createTypeDocProject(options); + return extractCatalogComponentsFromTypeDocProject( + project, + options.cwd ? { cwd: options.cwd } : {}, + ); +} + +export function extractCatalogComponentsFromTypeDocProject( + project: ProjectReflection | TypeDocProject, + options: ExtractCatalogFromTypeDocOptions = {}, +): CatalogComponent[] { + const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd(); + const components: CatalogComponent[] = []; + + for (const reflection of walkReflections(project as TypeDocProject)) { + if (!isInterfaceReflection(reflection)) { + continue; + } + + const parsedDoc = parseComment(reflection.comment); + if (parsedDoc.a2uiCatalogName === undefined) { + continue; + } + + components.push({ + filePath: getReflectionFilePath(reflection, cwd), + interfaceName: reflection.name, + name: parsedDoc.a2uiCatalogName || inferCatalogName(reflection.name), + schema: createComponentSchema(reflection, parsedDoc), + }); + } + + return components; +} + +export function extractCatalogComponentsFromTypeDocJson( + project: TypeDocProject, + options: ExtractCatalogFromTypeDocOptions = {}, +): CatalogComponent[] { + return extractCatalogComponentsFromTypeDocProject(project, options); +} + +export async function writeComponentCatalogs( + options: WriteComponentCatalogOptions, +): Promise { + const components = await extractCatalogComponents(options); + writeCatalogComponents(components, options); + return components; +} + +export function writeCatalogComponents( + components: CatalogComponent[], + options: { cwd?: string; outDir: string }, +): void { + const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd(); + const outDir = path.resolve(cwd, options.outDir); + + for (const component of components) { + const componentOutDir = path.join(outDir, component.name); + fs.mkdirSync(componentOutDir, { recursive: true }); + fs.writeFileSync( + path.join(componentOutDir, 'catalog.json'), + `${JSON.stringify({ [component.name]: component.schema }, null, 2)}\n`, + ); + } +} + +export function createA2UICatalog(options: { + catalogId: string; + components: CatalogComponent[] | Record; + functions?: FunctionDefinition[]; + theme?: Record; +}): A2UICatalog { + const catalogComponents = Array.isArray(options.components) + ? Object.fromEntries( + options.components.map(component => [component.name, component.schema]), + ) + : options.components; + + return { + catalogId: options.catalogId, + components: catalogComponents, + ...(options.functions ? { functions: options.functions } : {}), + ...(options.theme ? { theme: options.theme } : {}), + }; +} + +async function createTypeDocProject( + options: ExtractCatalogOptions, +): Promise { + const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd(); + const sourceFiles = options.sourceFiles.map(sourceFile => + path.resolve(cwd, sourceFile) + ); + + if (sourceFiles.length === 0) { + throw new Error('No TypeDoc entry points were provided.'); + } + + const tsconfigPath = getTsconfigPath(cwd, options.tsconfig); + const bootstrapOptions: TypeDocOptions = { + blockTags: [...OptionDefaults.blockTags, '@a2uiCatalog'], + entryPoints: sourceFiles, + excludePrivate: false, + excludeProtected: false, + readme: 'none', + skipErrorChecking: true, + sort: ['source-order'], + sortEntryPoints: false, + }; + + if (tsconfigPath) { + bootstrapOptions.tsconfig = tsconfigPath; + } + + const app = await Application.bootstrap(bootstrapOptions); + const project = await app.convert(); + + if (!project) { + throw new Error('TypeDoc did not produce a project reflection.'); + } + + return project; +} + +function collectSourceFiles(dir: string, files: string[]): void { + for (const dirent of fs.readdirSync(dir, { withFileTypes: true })) { + const nextPath = path.join(dir, dirent.name); + if (dirent.isDirectory()) { + if ( + dirent.name === 'node_modules' || dirent.name === 'dist' + || dirent.name === '.turbo' + ) { + continue; + } + collectSourceFiles(nextPath, files); + } else if (dirent.isFile() && isSupportedSourceFile(nextPath)) { + files.push(nextPath); + } + } +} + +function isSupportedSourceFile(filePath: string): boolean { + if (filePath.endsWith('.d.ts')) { + return false; + } + return supportedSourceExtensions.has(path.extname(filePath)); +} + +function* walkReflections( + reflection: TypeDocReflection | TypeDocProject, +): Generator { + for (const child of reflection.children ?? []) { + yield child; + yield* walkReflections(child); + } +} + +function isInterfaceReflection(reflection: TypeDocReflection): boolean { + return reflection.kind === ReflectionKind.Interface + || reflection.kindString === 'Interface'; +} + +function isPropertyReflection(reflection: TypeDocReflection): boolean { + return reflection.kind === ReflectionKind.Property + || reflection.kindString === 'Property'; +} + +function createComponentSchema( + reflection: TypeDocReflection, + parsedDoc: ParsedDoc, +): JsonSchema { + const schema: JsonSchema = { properties: {}, required: [] }; + applyDocToSchema(schema, parsedDoc); + + for (const child of reflection.children ?? []) { + if (child.inheritedFrom) { + continue; + } + if (!isPropertyReflection(child)) { + continue; + } + if (!child.type) { + throw createReflectionError(child, `Missing type for "${child.name}".`); + } + + const propertySchema = parseTypeDocType(child.type, child); + applyDocToSchema(propertySchema, parseComment(child.comment)); + schema.properties![child.name] = propertySchema; + + if (!isOptionalProperty(child)) { + schema.required!.push(child.name); + } + } + + return schema; +} + +function parseTypeDocType( + type: TypeDocType, + owner: TypeDocReflection, +): JsonSchema { + switch (type.type) { + case 'intrinsic': + return parseIntrinsicType(type.name ?? '', owner); + case 'literal': + return parseLiteralType(type.value); + case 'union': + return parseUnionType(type.types ?? [], owner); + case 'array': + if (!type.elementType) { + throw createReflectionError( + owner, + `Missing array element type for "${owner.name}".`, + ); + } + return { + type: 'array', + items: parseTypeDocType(type.elementType, owner), + }; + case 'reflection': + if (!type.declaration) { + throw createReflectionError( + owner, + `Missing reflection declaration for "${owner.name}".`, + ); + } + return parseObjectReflection(type.declaration, owner); + case 'reference': + return parseReferenceType(type, owner); + case 'typeOperator': + if (!type.target) { + throw createReflectionError( + owner, + `Missing type operator target for "${owner.name}".`, + ); + } + return parseTypeDocType(type.target, owner); + default: + if (type.elementType) { + return parseTypeDocType(type.elementType, owner); + } + throw createReflectionError( + owner, + `Unsupported TypeDoc type "${type.type}" for "${owner.name}".`, + ); + } +} + +function parseIntrinsicType( + name: string, + owner: TypeDocReflection, +): JsonSchema { + switch (name) { + case 'string': + case 'number': + case 'boolean': + return { type: name }; + case 'any': + case 'unknown': + case 'undefined': + case 'null': + case 'never': + case 'void': + return {}; + default: + throw createReflectionError( + owner, + `Unsupported intrinsic TypeDoc type "${name}" for "${owner.name}".`, + ); + } +} + +function parseLiteralType( + value: bigint | boolean | number | string | null | undefined, +): JsonSchema { + switch (typeof value) { + case 'string': + return { type: 'string', enum: [value] }; + case 'number': + case 'bigint': + return { type: 'number' }; + case 'boolean': + return { type: 'boolean' }; + default: + return {}; + } +} + +function parseUnionType( + types: TypeDocType[], + owner: TypeDocReflection, +): JsonSchema { + const actualTypes = types.filter(type => !isNullishType(type)); + + if (actualTypes.length === 0) { + return {}; + } + if (actualTypes.length === 1) { + return parseTypeDocType(actualTypes[0]!, owner); + } + + const stringLiteralValues = actualTypes.map(type => + getStringLiteralValue(type) + ); + if (stringLiteralValues.every(value => value !== undefined)) { + return { type: 'string', enum: stringLiteralValues }; + } + + if ( + actualTypes.length === 2 + && actualTypes.every(type => + type.type === 'literal' && typeof type.value === 'boolean' + ) + ) { + return { type: 'boolean' }; + } + + const oneOf: JsonSchema[] = []; + for (const childType of actualTypes) { + const schema = parseTypeDocType(childType, owner); + if (!oneOf.some(existing => schemasEqual(existing, schema))) { + oneOf.push(schema); + } + } + + return oneOf.length === 1 ? oneOf[0]! : { oneOf }; +} + +function parseObjectReflection( + declaration: TypeDocReflection, + owner: TypeDocReflection, +): JsonSchema { + const schema: JsonSchema = { + type: 'object', + properties: {}, + required: [], + additionalProperties: false, + }; + + for (const child of declaration.children ?? []) { + if (!isPropertyReflection(child)) { + continue; + } + if (!child.type) { + throw createReflectionError(child, `Missing type for "${child.name}".`); + } + + const propertySchema = parseTypeDocType(child.type, child); + applyDocToSchema(propertySchema, parseComment(child.comment)); + schema.properties![child.name] = propertySchema; + + if (!isOptionalProperty(child)) { + schema.required!.push(child.name); + } + } + + if (declaration.children === undefined) { + throw createReflectionError( + owner, + `Missing object declaration for "${owner.name}".`, + ); + } + + return schema; +} + +function parseReferenceType( + type: TypeDocType, + owner: TypeDocReflection, +): JsonSchema { + const typeName = String(type.name ?? type.qualifiedName ?? ''); + const typeArguments = type.typeArguments ?? []; + + if ( + (typeName === 'Array' || typeName === 'ReadonlyArray') + && typeArguments.length === 1 + ) { + return { + type: 'array', + items: parseTypeDocType(typeArguments[0]!, owner), + }; + } + + if (typeName === 'Record' && typeArguments.length === 2) { + const keyType = typeArguments[0]!; + if (!isStringKeyType(keyType)) { + throw createReflectionError( + owner, + 'A2UI catalog Record keys must be string-compatible.', + ); + } + return { + type: 'object', + additionalProperties: parseTypeDocType(typeArguments[1]!, owner), + }; + } + + throw createReflectionError( + owner, + `Unsupported TypeDoc reference "${typeName}" for "${owner.name}". Use an inline type literal, array, union, or Record.`, + ); +} + +function parseComment(comment: TypeDocComment | undefined): ParsedDoc { + if (!comment) { + return {}; + } + + const parsedDoc: ParsedDoc = {}; + const summary = normalizeDescription(renderCommentParts(comment.summary)); + if (summary) { + parsedDoc.description = summary; + } + + if (hasModifierTag(comment, '@deprecated')) { + parsedDoc.deprecated = true; + } + + for (const block of comment.blockTags ?? []) { + const content = normalizeDescription(renderCommentParts(block.content)); + switch (block.tag) { + case '@a2uiCatalog': + parsedDoc.a2uiCatalogName = content; + break; + case '@remarks': + if (content) { + parsedDoc.description = parsedDoc.description + ? `${parsedDoc.description}\n\n${content}` + : content; + } + break; + case '@default': + case '@defaultValue': + if (content) { + parsedDoc.defaultValue = parseDefaultValue(content); + } + break; + case '@deprecated': + parsedDoc.deprecated = true; + break; + default: + break; + } + } + + return parsedDoc; +} + +function applyDocToSchema(schema: JsonSchema, parsedDoc: ParsedDoc): void { + if (parsedDoc.description) { + schema.description = parsedDoc.description; + } + if (parsedDoc.defaultValue !== undefined) { + schema.default = parsedDoc.defaultValue; + } + if (parsedDoc.deprecated) { + schema.deprecated = true; + } +} + +function renderCommentParts( + parts: TypeDocCommentDisplayPart[] | undefined, +): string { + return (parts ?? []).map(part => renderCommentPart(part)).join(''); +} + +function renderCommentPart(part: TypeDocCommentDisplayPart): string { + if (part.text !== undefined) { + return part.text; + } + if (part.code !== undefined) { + return `\`${part.code}\``; + } + if (part.content) { + return renderCommentParts(part.content); + } + if (part.kind === 'softBreak') { + return ' '; + } + return ''; +} + +function normalizeDescription(text: string): string { + return text.replace(/[ \t\r\n]+/g, ' ').trim(); +} + +function parseDefaultValue(text: string): unknown { + const trimmed = unwrapCodeSpan(text.trim()); + if (trimmed === 'undefined') { + return undefined; + } + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } +} + +function unwrapCodeSpan(text: string): string { + if (text.startsWith('`') && text.endsWith('`') && text.length >= 2) { + return text.slice(1, -1); + } + return text; +} + +function hasModifierTag(comment: TypeDocComment, tag: string): boolean { + const modifierTags = comment.modifierTags; + if (!modifierTags) { + return false; + } + if (typeof (modifierTags as Set).has === 'function') { + return (modifierTags as Set).has(tag); + } + return Boolean((modifierTags as Record)[tag]); +} + +function isOptionalProperty(reflection: TypeDocReflection): boolean { + return reflection.flags?.isOptional === true + || (reflection.type ? typeIncludesUndefined(reflection.type) : false); +} + +function typeIncludesUndefined(type: TypeDocType): boolean { + if (type.type !== 'union') { + return false; + } + return (type.types ?? []).some(childType => + childType.type === 'intrinsic' && childType.name === 'undefined' + ); +} + +function isNullishType(type: TypeDocType): boolean { + return (type.type === 'intrinsic' + && (type.name === 'undefined' || type.name === 'null')) + || (type.type === 'literal' && type.value === null); +} + +function getStringLiteralValue(type: TypeDocType): string | undefined { + if (type.type === 'literal' && typeof type.value === 'string') { + return type.value; + } + return undefined; +} + +function isStringKeyType(type: TypeDocType): boolean { + if (type.type === 'intrinsic' && type.name === 'string') { + return true; + } + if (type.type === 'union') { + return (type.types ?? []).every(childType => + childType.type === 'literal' && typeof childType.value === 'string' + ); + } + return false; +} + +function getReflectionFilePath( + reflection: TypeDocReflection, + cwd: string, +): string { + const source = reflection.sources?.[0]; + if (!source) { + return ''; + } + if (source.fullFileName) { + return path.resolve(source.fullFileName); + } + if (source.fileName) { + return path.resolve(cwd, source.fileName); + } + return ''; +} + +function createReflectionError( + reflection: TypeDocReflection, + message: string, +): Error { + const source = reflection.sources?.[0]; + if (!source) { + return new Error(message); + } + + const fileName = source.fullFileName ?? source.fileName ?? ''; + const line = source.line ? `:${source.line}` : ''; + const character = source.character ? `:${source.character}` : ''; + return new Error(`${fileName}${line}${character} ${message}`.trim()); +} + +function inferCatalogName(interfaceName: string): string { + return interfaceName.replace(/(?:Component)?Props$/, ''); +} + +function schemasEqual(left: JsonSchema, right: JsonSchema): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function getTsconfigPath( + cwd: string, + tsconfig: string | undefined, +): string | undefined { + if (tsconfig) { + return path.resolve(cwd, tsconfig); + } + + const defaultTsconfig = path.join(cwd, 'tsconfig.json'); + return fs.existsSync(defaultTsconfig) ? defaultTsconfig : undefined; +} diff --git a/packages/genui/a2ui-catalog-extractor/test/extractor.test.ts b/packages/genui/a2ui-catalog-extractor/test/extractor.test.ts new file mode 100644 index 0000000000..4bb9a74d1e --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/extractor.test.ts @@ -0,0 +1,278 @@ +// 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 * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, test } from '@rstest/core'; + +import { + createA2UICatalog, + extractCatalogComponents, + extractCatalogComponentsFromTypeDocJson, + writeComponentCatalogs, +} from '../src/index.js'; + +const __filename = fileURLToPath(import.meta.url); +const packageDir = path.resolve(path.dirname(__filename), '..'); +const a2uiDir = path.resolve(packageDir, '../a2ui'); +const a2uiDistCatalogDir = path.join(a2uiDir, 'dist/catalog'); +const a2uiSourceCatalogDir = path.join(a2uiDir, 'src/catalog'); + +describe('extractCatalogComponents', () => { + test('extracts a component schema from a TypeDoc-marked interface', async () => { + const fixtureDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'a2ui-catalog-fixture-'), + ); + const fixture = path.join(fixtureDir, 'DemoCard.tsx'); + const fixtureTsconfig = path.join(fixtureDir, 'tsconfig.json'); + fs.writeFileSync( + fixture, + ` +/** + * @a2uiCatalog DemoCard + */ +export interface DemoCardProps { + /** Main title. */ + title: string | { path: string }; + /** Visual tone. */ + tone?: 'neutral' | 'accent'; + /** Extra payload. + * @defaultValue \`{}\` + */ + context?: Record; + action: { + event: { + name: string; + }; + }; +} +`, + ); + fs.writeFileSync( + fixtureTsconfig, + JSON.stringify({ + compilerOptions: { + jsx: 'preserve', + module: 'ESNext', + moduleResolution: 'Bundler', + target: 'ESNext', + }, + include: ['DemoCard.tsx'], + }), + ); + + const components = await extractCatalogComponents({ + cwd: fixtureDir, + sourceFiles: ['DemoCard.tsx'], + }); + + expect(components).toEqual([ + { + filePath: fixture, + interfaceName: 'DemoCardProps', + name: 'DemoCard', + schema: { + properties: { + title: { + oneOf: [ + { type: 'string' }, + { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + additionalProperties: false, + }, + ], + description: 'Main title.', + }, + tone: { + type: 'string', + enum: ['neutral', 'accent'], + description: 'Visual tone.', + }, + context: { + type: 'object', + additionalProperties: { + oneOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + ], + }, + description: 'Extra payload.', + default: {}, + }, + action: { + type: 'object', + properties: { + event: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + required: ['event'], + additionalProperties: false, + }, + }, + required: ['title', 'action'], + }, + }, + ]); + }); + + test('extracts a component schema from TypeDoc JSON', () => { + const components = extractCatalogComponentsFromTypeDocJson({ + children: [ + { + name: 'DemoTextProps', + kindString: 'Interface', + comment: { + blockTags: [ + { + tag: '@a2uiCatalog', + content: [{ text: 'DemoText' }], + }, + ], + }, + children: [ + { + name: 'text', + kindString: 'Property', + comment: { + summary: [{ text: 'Literal text or path binding.' }], + }, + type: { + type: 'union', + types: [ + { type: 'intrinsic', name: 'string' }, + { + type: 'reflection', + declaration: { + name: '__type', + children: [ + { + name: 'path', + kindString: 'Property', + type: { type: 'intrinsic', name: 'string' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + ], + }); + + expect(components).toEqual([ + { + filePath: '', + interfaceName: 'DemoTextProps', + name: 'DemoText', + schema: { + properties: { + text: { + description: 'Literal text or path binding.', + oneOf: [ + { type: 'string' }, + { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + additionalProperties: false, + }, + ], + }, + }, + required: ['text'], + }, + }, + ]); + }); + + test('generates JSON deep-equal to packages/genui/a2ui/dist/catalog', async () => { + const expectedCatalogs = readDistCatalogs(); + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'a2ui-catalog-out-')); + + await writeComponentCatalogs({ + cwd: a2uiDir, + outDir, + sourceFiles: expectedCatalogs.map(({ componentName }) => + getSourceFileForComponent(componentName) + ), + }); + + for (const expectedCatalog of expectedCatalogs) { + const actualJsonPath = path.join( + outDir, + expectedCatalog.componentName, + 'catalog.json', + ); + expect(JSON.parse(fs.readFileSync(actualJsonPath, 'utf8'))).toEqual( + expectedCatalog.json, + ); + } + }); + + test('can create a full catalog from extracted components', async () => { + const textCatalog = readDistCatalogs().find(({ componentName }) => + componentName === 'Text' + ); + expect(textCatalog).toBeDefined(); + + const components = await extractCatalogComponents({ + cwd: a2uiDir, + sourceFiles: [getSourceFileForComponent('Text')], + }); + + expect(createA2UICatalog({ + catalogId: 'https://example.com/catalog.json', + components, + })).toEqual({ + catalogId: 'https://example.com/catalog.json', + components: { + Text: textCatalog!.json.Text, + }, + }); + }); +}); + +function readDistCatalogs(): { + componentName: string; + json: Record; +}[] { + expect(fs.existsSync(a2uiDistCatalogDir)).toBe(true); + + const catalogJsonPaths = fs.readdirSync(a2uiDistCatalogDir) + .map(componentName => path.join(a2uiDistCatalogDir, componentName)) + .filter(componentPath => fs.statSync(componentPath).isDirectory()) + .map(componentPath => path.join(componentPath, 'catalog.json')) + .filter(catalogJsonPath => fs.existsSync(catalogJsonPath)) + .sort((left, right) => left.localeCompare(right)); + + expect(catalogJsonPaths.length).toBeGreaterThan(0); + + return catalogJsonPaths.map(catalogJsonPath => { + const componentName = path.basename(path.dirname(catalogJsonPath)); + return { + componentName, + json: JSON.parse(fs.readFileSync(catalogJsonPath, 'utf8')) as Record< + string, + unknown + >, + }; + }); +} + +function getSourceFileForComponent(componentName: string): string { + return path.join(a2uiSourceCatalogDir, componentName, 'index.tsx'); +} diff --git a/packages/genui/a2ui-catalog-extractor/tsconfig.build.json b/packages/genui/a2ui-catalog-extractor/tsconfig.build.json new file mode 100644 index 0000000000..6770be9b55 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"], + "exclude": ["test"], +} diff --git a/packages/genui/a2ui-catalog-extractor/tsconfig.json b/packages/genui/a2ui-catalog-extractor/tsconfig.json new file mode 100644 index 0000000000..f5a8356776 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["node", "@rstest/core/globals"], + }, + "include": ["src", "test", "rstest.config.ts", "rslib.config.ts"], + "references": [], +} diff --git a/packages/genui/a2ui/src/catalog/Button/index.tsx b/packages/genui/a2ui/src/catalog/Button/index.tsx index 9c0b742f95..9c58f23724 100755 --- a/packages/genui/a2ui/src/catalog/Button/index.tsx +++ b/packages/genui/a2ui/src/catalog/Button/index.tsx @@ -6,6 +6,9 @@ import type { GenericComponentProps } from '../../core/types.js'; import './style.css'; +/** + * @a2uiCatalog Button + */ export interface ButtonProps extends GenericComponentProps { child: string; variant?: 'primary' | 'borderless'; diff --git a/packages/genui/a2ui/src/catalog/Card/index.tsx b/packages/genui/a2ui/src/catalog/Card/index.tsx index b08d9905f0..59bfad4757 100755 --- a/packages/genui/a2ui/src/catalog/Card/index.tsx +++ b/packages/genui/a2ui/src/catalog/Card/index.tsx @@ -6,6 +6,9 @@ import type { GenericComponentProps } from '../../core/types.js'; import './style.css'; +/** + * @a2uiCatalog Card + */ export interface CardProps extends GenericComponentProps { child: string; } diff --git a/packages/genui/a2ui/src/catalog/CheckBox/index.tsx b/packages/genui/a2ui/src/catalog/CheckBox/index.tsx index 70d1369883..ed9bc37ed2 100755 --- a/packages/genui/a2ui/src/catalog/CheckBox/index.tsx +++ b/packages/genui/a2ui/src/catalog/CheckBox/index.tsx @@ -5,6 +5,9 @@ import type { GenericComponentProps } from '../../core/types.js'; import './style.css'; +/** + * @a2uiCatalog CheckBox + */ export interface CheckBoxProps extends GenericComponentProps { label: string | { path: string }; value: boolean | { path: string }; diff --git a/packages/genui/a2ui/src/catalog/Column/index.tsx b/packages/genui/a2ui/src/catalog/Column/index.tsx index 0771635a46..59e410a322 100644 --- a/packages/genui/a2ui/src/catalog/Column/index.tsx +++ b/packages/genui/a2ui/src/catalog/Column/index.tsx @@ -6,6 +6,9 @@ import type { GenericComponentProps } from '../../core/types.js'; import './style.css'; +/** + * @a2uiCatalog Column + */ export interface ColumnProps extends GenericComponentProps { /** Static child IDs array or template object. */ children: string[] | { componentId: string; path: string }; @@ -14,10 +17,10 @@ export interface ColumnProps extends GenericComponentProps { | 'start' | 'center' | 'end' + | 'stretch' | 'spaceBetween' | 'spaceAround' - | 'spaceEvenly' - | 'stretch'; + | 'spaceEvenly'; } export function Column( diff --git a/packages/genui/a2ui/src/catalog/Divider/index.tsx b/packages/genui/a2ui/src/catalog/Divider/index.tsx index 5073dac675..e83682adcc 100755 --- a/packages/genui/a2ui/src/catalog/Divider/index.tsx +++ b/packages/genui/a2ui/src/catalog/Divider/index.tsx @@ -5,6 +5,9 @@ import type { GenericComponentProps } from '../../core/types.js'; import './style.css'; +/** + * @a2uiCatalog Divider + */ export interface DividerProps extends GenericComponentProps { axis?: 'horizontal' | 'vertical'; } diff --git a/packages/genui/a2ui/src/catalog/Image/index.tsx b/packages/genui/a2ui/src/catalog/Image/index.tsx index fc8d0af038..ecdf69a3d5 100755 --- a/packages/genui/a2ui/src/catalog/Image/index.tsx +++ b/packages/genui/a2ui/src/catalog/Image/index.tsx @@ -7,6 +7,14 @@ import type { GenericComponentProps } from '../../core/types.js'; import './style.css'; +const useLynxEffect = useEffect as ( + effect: () => undefined | (() => void), + deps?: readonly unknown[], +) => void; + +/** + * @a2uiCatalog Image + */ export interface ImageProps extends GenericComponentProps { /** Image URL or path binding. */ url: string | { path: string }; @@ -27,7 +35,7 @@ export function Image( const [hasError, setHasError] = useState(false); - useEffect(() => { + useLynxEffect(() => { setHasError(false); }, [url]); diff --git a/packages/genui/a2ui/src/catalog/List/index.tsx b/packages/genui/a2ui/src/catalog/List/index.tsx index c18e278147..f2f68a28d1 100755 --- a/packages/genui/a2ui/src/catalog/List/index.tsx +++ b/packages/genui/a2ui/src/catalog/List/index.tsx @@ -9,10 +9,13 @@ import { useDataBinding } from '../../core/useDataBinding.js'; import './style.css'; +/** + * @a2uiCatalog List + */ export interface ListProps extends GenericComponentProps { /** Static child IDs array or template object. */ children: string[] | { componentId: string; path: string }; - direction?: 'vertical' | 'horizontal'; + direction?: 'horizontal' | 'vertical'; align?: 'start' | 'center' | 'end' | 'stretch'; } diff --git a/packages/genui/a2ui/src/catalog/RadioGroup/index.tsx b/packages/genui/a2ui/src/catalog/RadioGroup/index.tsx index 7a931d6bbd..df73de0441 100644 --- a/packages/genui/a2ui/src/catalog/RadioGroup/index.tsx +++ b/packages/genui/a2ui/src/catalog/RadioGroup/index.tsx @@ -16,6 +16,9 @@ const HitSlop = { }, }; +/** + * @a2uiCatalog RadioGroup + */ export interface RadioGroupComponentProps extends GenericComponentProps { /** The list of string options to display. */ items: string[] | { path: string }; diff --git a/packages/genui/a2ui/src/catalog/Row/index.tsx b/packages/genui/a2ui/src/catalog/Row/index.tsx index fef0d61783..e94404b799 100755 --- a/packages/genui/a2ui/src/catalog/Row/index.tsx +++ b/packages/genui/a2ui/src/catalog/Row/index.tsx @@ -6,6 +6,9 @@ import type { GenericComponentProps } from '../../core/types.js'; import './style.css'; +/** + * @a2uiCatalog Row + */ export interface RowProps extends GenericComponentProps { /** Static child IDs array or template object. */ children: string[] | { componentId: string; path: string }; @@ -13,10 +16,10 @@ export interface RowProps extends GenericComponentProps { | 'start' | 'center' | 'end' + | 'stretch' | 'spaceBetween' | 'spaceAround' - | 'spaceEvenly' - | 'stretch'; + | 'spaceEvenly'; align?: 'start' | 'center' | 'end' | 'stretch'; } diff --git a/packages/genui/a2ui/src/catalog/Text/index.tsx b/packages/genui/a2ui/src/catalog/Text/index.tsx index 2a103b06e3..65405a8345 100644 --- a/packages/genui/a2ui/src/catalog/Text/index.tsx +++ b/packages/genui/a2ui/src/catalog/Text/index.tsx @@ -4,6 +4,9 @@ import type { GenericComponentProps } from '../../core/types.js'; import './style.css'; +/** + * @a2uiCatalog Text + */ export interface TextProps extends GenericComponentProps { /** Literal text or path binding. */ text: string | { path: string }; diff --git a/packages/genui/a2ui/tools/catalog_generator.ts b/packages/genui/a2ui/tools/catalog_generator.ts index 4c3192b0bc..0e6e7d5ab2 100644 --- a/packages/genui/a2ui/tools/catalog_generator.ts +++ b/packages/genui/a2ui/tools/catalog_generator.ts @@ -1,8 +1,14 @@ -import * as ts from 'typescript'; -import * as fs from 'node:fs'; +// 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 * as path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + findCatalogSourceFiles, + writeComponentCatalogs, +} from '../../a2ui-catalog-extractor/src/index.ts'; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -10,270 +16,17 @@ const BASE_DIR = path.resolve(__dirname, '..'); const CATALOG_DIR = path.join(BASE_DIR, 'src/catalog'); const DIST_CATALOG_DIR = path.join(BASE_DIR, 'dist/catalog'); -// These props belong to the framework/generic component layer, not the schema. -const GENERIC_PROPS = new Set([ - 'id', - 'surface', - 'setValue', - 'sendAction', - 'dataContextPath', - '__template', - 'component', -]); - -function getComponentIndexFiles(dir: string): string[] { - const results: string[] = []; - if (!fs.existsSync(dir)) return results; - const list = fs.readdirSync(dir); - for (const file of list) { - const filePath = path.join(dir, file); - if (fs.statSync(filePath).isDirectory()) { - const indexFile = path.join(filePath, 'index.tsx'); - if (fs.existsSync(indexFile)) { - results.push(indexFile); - } - } - } - return results; -} - -function parseType(type: ts.Type, checker: ts.TypeChecker): any { - if ( - type.flags & ts.TypeFlags.Boolean - || type.flags & ts.TypeFlags.BooleanLiteral - ) return { type: 'boolean' }; - if (type.flags & ts.TypeFlags.StringLiteral) return { type: 'string' }; - if (type.flags & ts.TypeFlags.NumberLiteral) return { type: 'number' }; - - if (type.isUnion()) { - // Filter out undefined and null from the union - const actualTypes = type.types.filter(t => - !(t.flags & ts.TypeFlags.Undefined) && !(t.flags & ts.TypeFlags.Null) - ); - - // Check if union inherently represents a strict boolean type (true | false) globally - if ( - actualTypes.length === 2 - && actualTypes.some(t => (t as any).intrinsicName === 'true') - && actualTypes.some(t => (t as any).intrinsicName === 'false') - ) { - return { type: 'boolean' }; - } - - if (actualTypes.length === 1) { - return parseType(actualTypes[0], checker); - } - - // Check if it's a union of string literals (Enum) - const isEnum = actualTypes.every(t => t.isStringLiteral()); - if (isEnum && actualTypes.length > 0) { - return { - type: 'string', - enum: actualTypes.map(t => (t as ts.StringLiteralType).value), - }; - } - - const schemas = actualTypes.map(t => parseType(t, checker)); - // Deduplicate exact schemas (merges split booleans inside wider unions) - const deduplicated = []; - for (const schema of schemas) { - if ( - !deduplicated.some(d => JSON.stringify(d) === JSON.stringify(schema)) - ) { - deduplicated.push(schema); - } - } - - // Check if after deduplication we only have 1 - if (deduplicated.length === 1) return deduplicated[0]; - - return { oneOf: deduplicated }; - } - - if (type.flags & ts.TypeFlags.String) return { type: 'string' }; - if (type.flags & ts.TypeFlags.Number) return { type: 'number' }; - if (type.flags & ts.TypeFlags.Boolean) return { type: 'boolean' }; - - if (checker.isArrayType(type)) { - const elemType = type.getNumberIndexType() - || (type as any).resolvedTypeArguments?.[0]; - return { - type: 'array', - items: elemType ? parseType(elemType, checker) : { type: 'any' }, - }; - } - - if (type.flags & ts.TypeFlags.Object || type.isClassOrInterface()) { - const stringIndexType = type.getStringIndexType(); - const props = type.getProperties(); - - // If it's pure any or unknown object without props - if (props.length === 0 && !stringIndexType) { - return { type: 'object' }; - } - - const schema: any = { type: 'object' }; - - if (props.length > 0) { - schema.properties = {}; - const required = []; - for (const p of props) { - // Skip array inherited methods if somehow parsed - if (p.name === 'length' || p.name === 'push' || p.name === 'filter') { - continue; - } - - const decl = p.valueDeclaration || p.declarations?.[0]; - if (!decl) continue; - - const propType = checker.getTypeOfSymbolAtLocation(p, decl); - const propSchema = parseType(propType, checker); - - const displayParts = p.getDocumentationComment(checker); - if (displayParts.length > 0) { - propSchema.description = ts.displayPartsToString(displayParts); - } - - schema.properties[p.name] = propSchema; - - // Check if optional - let isOptional = (p.flags & ts.SymbolFlags.Optional) !== 0; - if (!isOptional && decl && (decl as any).questionToken) { - isOptional = true; - } - - // Also check if union contains undefined - if ( - propType.isUnion() - && propType.types.some(t => t.flags & ts.TypeFlags.Undefined) - ) { - isOptional = true; - } - - if (!isOptional) { - required.push(p.name); - } - } - if (required.length >= 0) { - schema.required = required; - } +const sourceFiles = findCatalogSourceFiles(CATALOG_DIR).filter(file => + path.basename(file) === 'index.tsx' +); +console.log(`Found ${sourceFiles.length} component files`); - if (!stringIndexType) { - schema.additionalProperties = false; - } - } +const components = await writeComponentCatalogs({ + cwd: BASE_DIR, + outDir: DIST_CATALOG_DIR, + sourceFiles, +}); - if (stringIndexType) { - schema.additionalProperties = parseType(stringIndexType, checker); - } - return schema; - } - - return { type: 'string' }; // Fallback +for (const component of components) { + console.log(`Generated strict schema for ${component.name}`); } - -function generateSchema() { - const indexFiles = getComponentIndexFiles(CATALOG_DIR); - console.log(`Found ${indexFiles.length} component files`); - - const program = ts.createProgram(indexFiles, { - target: ts.ScriptTarget.ESNext, - module: ts.ModuleKind.ESNext, - jsx: ts.JsxEmit.Preserve, - strict: true, - }); - const checker = program.getTypeChecker(); - - for (const file of indexFiles) { - const sourceFile = program.getSourceFile(file); - if (!sourceFile) continue; - - const componentDir = path.dirname(file); - const componentName = path.basename(componentDir); - - let baseSchema: any = null; - - function visitNode(node: ts.Node) { - if ( - ts.isFunctionDeclaration(node) && node.name?.getText() === componentName - ) { - if (node.parameters.length > 0) { - const propsParam = node.parameters[0]; - const propsType = checker.getTypeAtLocation(propsParam); - baseSchema = processComponentPropsType(propsType, checker); - } - } - ts.forEachChild(node, visitNode); - } - - function processComponentPropsType(type: ts.Type, checker: ts.TypeChecker) { - const schema: any = { properties: {} }; - const required = []; - const props = type.getProperties(); - - for (const p of props) { - const decl = p.valueDeclaration || p.declarations?.[0]; - if (!decl) continue; - - const parentInterface = decl.parent; - if ( - parentInterface - && ts.isInterfaceDeclaration(parentInterface) - && (parentInterface.name.text === 'GenericComponentProps' - || parentInterface.name.text === 'ComponentProps') - ) { - continue; - } - - const propType = checker.getTypeOfSymbolAtLocation(p, decl); - const propSchema = parseType(propType, checker); - - const displayParts = p.getDocumentationComment(checker); - if (displayParts.length > 0) { - propSchema.description = ts.displayPartsToString(displayParts); - } - - schema.properties[p.name] = propSchema; - - let isOptional = (p.flags & ts.SymbolFlags.Optional) !== 0; - if (!isOptional && decl && (decl as any).questionToken) { - isOptional = true; - } - if ( - propType.isUnion() - && propType.types.some(t => t.flags & ts.TypeFlags.Undefined) - ) { - isOptional = true; - } - - if (!isOptional) { - required.push(p.name); - } - } - - if (required.length >= 0) schema.required = required; - return schema; - } - - visitNode(sourceFile); - - if (baseSchema) { - const outDir = path.join(DIST_CATALOG_DIR, componentName); - fs.mkdirSync(outDir, { recursive: true }); - - const finalSchema = { [componentName]: baseSchema }; - const finalSchemaStr = JSON.stringify(finalSchema, null, 2); - - fs.writeFileSync( - path.join(outDir, 'catalog.json'), - finalSchemaStr + '\n', - ); - - console.log(`Generated strict schema for ${componentName}`); - } else { - console.warn(`[Warning] Could not resolve schema for ${componentName}`); - } - } -} - -generateSchema(); diff --git a/packages/genui/tsconfig.json b/packages/genui/tsconfig.json index f0cfbb28a8..b0b0c22371 100644 --- a/packages/genui/tsconfig.json +++ b/packages/genui/tsconfig.json @@ -10,6 +10,7 @@ }, "references": [ /** packages-start */ + { "path": "./a2ui-catalog-extractor/tsconfig.json" }, { "path": "./a2ui/tsconfig.json" }, /** packages-end */ ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a9cbdc525..429cdebc95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -510,6 +510,19 @@ importers: specifier: ^18.3.28 version: 18.3.28 + packages/genui/a2ui-catalog-extractor: + dependencies: + typedoc: + specifier: ^0.28.19 + version: 0.28.19(typescript@5.9.3) + devDependencies: + '@rstest/core': + specifier: catalog:rstest + version: 0.8.1(jsdom@27.4.0) + '@types/node': + specifier: ^24.10.13 + version: 24.10.13 + packages/genui/a2ui-playground: dependencies: '@codemirror/lang-json': @@ -3233,6 +3246,9 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@gerrit0/mini-shiki@3.23.0': + resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} + '@hono/node-server@1.19.9': resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} @@ -4837,21 +4853,33 @@ packages: '@shikijs/engine-oniguruma@3.22.0': resolution: {integrity: sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==} + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + '@shikijs/langs@3.22.0': resolution: {integrity: sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==} + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + '@shikijs/rehype@3.22.0': resolution: {integrity: sha512-69b2VPc6XBy/VmAJlpBU5By+bJSBdE2nvgRCZXav7zujbrjXuT0F60DIrjKuutjPqNufuizE+E8tIZr2Yn8Z+g==} '@shikijs/themes@3.22.0': resolution: {integrity: sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==} + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + '@shikijs/transformers@3.22.0': resolution: {integrity: sha512-E7eRV7mwDBjueLF6852n2oYeJYxBq3NSsDk+uyruYAXONv4U8holGmIrT+mPRJQ1J1SNOH6L8G19KRzmBawrFw==} '@shikijs/types@3.22.0': resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==} + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -8383,6 +8411,9 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -8412,6 +8443,10 @@ packages: resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -10702,6 +10737,13 @@ packages: typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} + typedoc@0.28.19: + resolution: {integrity: sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw==} + engines: {node: '>= 18', pnpm: '>= 10'} + hasBin: true + peerDependencies: + typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x + typescript-eslint@8.56.0: resolution: {integrity: sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -12288,6 +12330,14 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@gerrit0/mini-shiki@3.23.0': + dependencies: + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@hono/node-server@1.19.9(hono@4.12.12)': dependencies: hono: 4.12.12 @@ -14278,10 +14328,19 @@ snapshots: '@shikijs/types': 3.22.0 '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/langs@3.22.0': dependencies: '@shikijs/types': 3.22.0 + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/rehype@3.22.0': dependencies: '@shikijs/types': 3.22.0 @@ -14295,6 +14354,10 @@ snapshots: dependencies: '@shikijs/types': 3.22.0 + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/transformers@3.22.0': dependencies: '@shikijs/core': 3.22.0 @@ -14305,6 +14368,11 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + '@shikijs/vscode-textmate@10.0.2': {} '@sinclair/typebox@0.27.8': {} @@ -18256,6 +18324,8 @@ snapshots: dependencies: react: 19.2.4 + lunr@2.3.9: {} + lz-string@1.5.0: {} magic-string@0.30.21: @@ -18291,6 +18361,15 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + markdown-table@3.0.4: {} math-intrinsics@1.1.0: {} @@ -21000,6 +21079,15 @@ snapshots: dependencies: is-typedarray: 1.0.0 + typedoc@0.28.19(typescript@5.9.3): + dependencies: + '@gerrit0/mini-shiki': 3.23.0 + lunr: 2.3.9 + markdown-it: 14.1.1 + minimatch: 10.2.5 + typescript: 5.9.3 + yaml: 2.8.3 + typescript-eslint@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) diff --git a/rstest.config.ts b/rstest.config.ts index e5094b940a..70ac5d788e 100644 --- a/rstest.config.ts +++ b/rstest.config.ts @@ -15,5 +15,8 @@ export default defineConfig({ reporters: ['json', 'text'], }, reporters, - projects: ['packages/webpack/*/rstest.config.ts'], + projects: [ + 'packages/genui/a2ui-catalog-extractor/rstest.config.ts', + 'packages/webpack/*/rstest.config.ts', + ], }); From c65d47f83871dd0ac80604e8b0a5446330efbb1d Mon Sep 17 00:00:00 2001 From: Haoyang Wang <12288479+PupilTong@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:29:43 +0800 Subject: [PATCH 02/10] refactor: use extractor package for A2UI catalogs --- .../bin/a2ui-catalog-extractor.js | 6 ++++ .../genui/a2ui-catalog-extractor/package.json | 3 +- packages/genui/a2ui/package.json | 3 +- .../genui/a2ui/src/catalog/Image/index.tsx | 1 + .../genui/a2ui/tools/catalog_generator.ts | 32 ------------------- packages/genui/a2ui/turbo.json | 1 - pnpm-lock.yaml | 3 ++ 7 files changed, 14 insertions(+), 35 deletions(-) create mode 100755 packages/genui/a2ui-catalog-extractor/bin/a2ui-catalog-extractor.js delete mode 100644 packages/genui/a2ui/tools/catalog_generator.ts diff --git a/packages/genui/a2ui-catalog-extractor/bin/a2ui-catalog-extractor.js b/packages/genui/a2ui-catalog-extractor/bin/a2ui-catalog-extractor.js new file mode 100755 index 0000000000..6a88a1d1e9 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/bin/a2ui-catalog-extractor.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node +// 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. +// eslint-disable-next-line import/no-unresolved -- Generated by the package build. +import '../dist/cli.js'; diff --git a/packages/genui/a2ui-catalog-extractor/package.json b/packages/genui/a2ui-catalog-extractor/package.json index 07c47fb088..57c077df57 100644 --- a/packages/genui/a2ui-catalog-extractor/package.json +++ b/packages/genui/a2ui-catalog-extractor/package.json @@ -14,9 +14,10 @@ "./package.json": "./package.json" }, "bin": { - "a2ui-catalog-extractor": "./dist/cli.js" + "a2ui-catalog-extractor": "./bin/a2ui-catalog-extractor.js" }, "files": [ + "bin", "dist", "skills", "README.md" diff --git a/packages/genui/a2ui/package.json b/packages/genui/a2ui/package.json index b73f5f2f62..b92263726b 100644 --- a/packages/genui/a2ui/package.json +++ b/packages/genui/a2ui/package.json @@ -93,13 +93,14 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { - "build": "node --experimental-strip-types tools/catalog_generator.ts && tsc -b" + "build": "pnpm --filter @lynx-js/a2ui-catalog-extractor build && node ../a2ui-catalog-extractor/dist/cli.js --catalog-dir src/catalog --out-dir dist/catalog && tsc -b" }, "dependencies": { "@a2ui/web_core": "0.9.1", "@preact/signals": "^2.5.1" }, "devDependencies": { + "@lynx-js/a2ui-catalog-extractor": "workspace:*", "@lynx-js/lynx-ui": "^3.130.0", "@lynx-js/lynx-ui-input": "^3.130.0", "@lynx-js/react": "workspace:*", diff --git a/packages/genui/a2ui/src/catalog/Image/index.tsx b/packages/genui/a2ui/src/catalog/Image/index.tsx index ecdf69a3d5..26c0378304 100755 --- a/packages/genui/a2ui/src/catalog/Image/index.tsx +++ b/packages/genui/a2ui/src/catalog/Image/index.tsx @@ -37,6 +37,7 @@ export function Image( useLynxEffect(() => { setHasError(false); + return undefined; }, [url]); const finalSrc = hasError diff --git a/packages/genui/a2ui/tools/catalog_generator.ts b/packages/genui/a2ui/tools/catalog_generator.ts deleted file mode 100644 index 0e6e7d5ab2..0000000000 --- a/packages/genui/a2ui/tools/catalog_generator.ts +++ /dev/null @@ -1,32 +0,0 @@ -// 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 * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { - findCatalogSourceFiles, - writeComponentCatalogs, -} from '../../a2ui-catalog-extractor/src/index.ts'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const BASE_DIR = path.resolve(__dirname, '..'); -const CATALOG_DIR = path.join(BASE_DIR, 'src/catalog'); -const DIST_CATALOG_DIR = path.join(BASE_DIR, 'dist/catalog'); - -const sourceFiles = findCatalogSourceFiles(CATALOG_DIR).filter(file => - path.basename(file) === 'index.tsx' -); -console.log(`Found ${sourceFiles.length} component files`); - -const components = await writeComponentCatalogs({ - cwd: BASE_DIR, - outDir: DIST_CATALOG_DIR, - sourceFiles, -}); - -for (const component of components) { - console.log(`Generated strict schema for ${component.name}`); -} diff --git a/packages/genui/a2ui/turbo.json b/packages/genui/a2ui/turbo.json index d59bc77e05..ebca3be1f3 100644 --- a/packages/genui/a2ui/turbo.json +++ b/packages/genui/a2ui/turbo.json @@ -8,7 +8,6 @@ ], "inputs": [ "src/**", - "tools/**", "tsconfig.json", "package.json" ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 429cdebc95..88e38eefea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -494,6 +494,9 @@ importers: specifier: ^2.5.1 version: 2.5.1(preact@10.29.1) devDependencies: + '@lynx-js/a2ui-catalog-extractor': + specifier: workspace:* + version: link:../a2ui-catalog-extractor '@lynx-js/lynx-ui': specifier: ^3.130.0 version: 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) From 872528ccd1891ca78944a190dca3b8beedd7795d Mon Sep 17 00:00:00 2001 From: Haoyang Wang <12288479+PupilTong@users.noreply.github.com> Date: Sun, 26 Apr 2026 03:32:06 +0800 Subject: [PATCH 03/10] fix: satisfy A2UI extractor isolated declarations --- packages/genui/a2ui-catalog-extractor/rslib.config.ts | 5 ++++- packages/genui/a2ui-catalog-extractor/rstest.config.ts | 4 +++- packages/genui/a2ui-catalog-extractor/src/cli.ts | 2 +- packages/genui/a2ui-catalog-extractor/test/extractor.test.ts | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/genui/a2ui-catalog-extractor/rslib.config.ts b/packages/genui/a2ui-catalog-extractor/rslib.config.ts index 8a1d0b77cb..c85237bcf0 100644 --- a/packages/genui/a2ui-catalog-extractor/rslib.config.ts +++ b/packages/genui/a2ui-catalog-extractor/rslib.config.ts @@ -1,9 +1,10 @@ // 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 type { RslibConfig } from '@rslib/core'; import { defineConfig } from '@rslib/core'; -export default defineConfig({ +const config: RslibConfig = defineConfig({ lib: [ { format: 'esm', syntax: 'es2022', dts: { bundle: true, tsgo: true } }, ], @@ -15,3 +16,5 @@ export default defineConfig({ tsconfigPath: './tsconfig.build.json', }, }); + +export default config; diff --git a/packages/genui/a2ui-catalog-extractor/rstest.config.ts b/packages/genui/a2ui-catalog-extractor/rstest.config.ts index c8d4cce271..093336388a 100644 --- a/packages/genui/a2ui-catalog-extractor/rstest.config.ts +++ b/packages/genui/a2ui-catalog-extractor/rstest.config.ts @@ -3,8 +3,10 @@ // LICENSE file in the root directory of this source tree. import { defineConfig } from '@rstest/core'; -export default defineConfig({ +const config: ReturnType = defineConfig({ name: 'genui/a2ui-catalog-extractor', globals: true, include: ['test/**/*.test.ts'], }); + +export default config; diff --git a/packages/genui/a2ui-catalog-extractor/src/cli.ts b/packages/genui/a2ui-catalog-extractor/src/cli.ts index 9cc7e0bda2..5dd28ba12d 100644 --- a/packages/genui/a2ui-catalog-extractor/src/cli.ts +++ b/packages/genui/a2ui-catalog-extractor/src/cli.ts @@ -82,7 +82,7 @@ export function parseCliArgs(args: string[]): CliOptions { export async function runCli( args: string[], - cwd = process.cwd(), + cwd: string = process.cwd(), ): Promise { const options = parseCliArgs(args); diff --git a/packages/genui/a2ui-catalog-extractor/test/extractor.test.ts b/packages/genui/a2ui-catalog-extractor/test/extractor.test.ts index 4bb9a74d1e..f13e518480 100644 --- a/packages/genui/a2ui-catalog-extractor/test/extractor.test.ts +++ b/packages/genui/a2ui-catalog-extractor/test/extractor.test.ts @@ -240,7 +240,7 @@ export interface DemoCardProps { })).toEqual({ catalogId: 'https://example.com/catalog.json', components: { - Text: textCatalog!.json.Text, + Text: textCatalog!.json['Text'], }, }); }); From 5215e220dd49b99fc1d81582bf813b8766e7a2b0 Mon Sep 17 00:00:00 2001 From: Haoyang Wang <12288479+PupilTong@users.noreply.github.com> Date: Sun, 26 Apr 2026 03:50:14 +0800 Subject: [PATCH 04/10] fix: avoid extractor tsc source emit --- packages/genui/a2ui-catalog-extractor/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/genui/a2ui-catalog-extractor/tsconfig.json b/packages/genui/a2ui-catalog-extractor/tsconfig.json index f5a8356776..4b6e9c1c23 100644 --- a/packages/genui/a2ui-catalog-extractor/tsconfig.json +++ b/packages/genui/a2ui-catalog-extractor/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "composite": true, "rootDir": ".", + "noEmit": true, "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", From 4e589390bfaac4d7721e4cea385572b53f52e414 Mon Sep 17 00:00:00 2001 From: Haoyang Wang <12288479+PupilTong@users.noreply.github.com> Date: Sun, 26 Apr 2026 04:10:23 +0800 Subject: [PATCH 05/10] ci: ignore catalog extractor config in coverage --- codecov.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codecov.yml b/codecov.yml index ff1bbb7990..89e92302b8 100644 --- a/codecov.yml +++ b/codecov.yml @@ -25,7 +25,11 @@ coverage: threshold: "1%" ignore: + - ".github/**" + - "codecov.yml" - "packages/genui/**" + - "pnpm-lock.yaml" + - "rstest.config.ts" fixes: - "/home/runner/_work/lynx-stack::" From e93f0f631ddf19a055227ae481948be81a12b523 Mon Sep 17 00:00:00 2001 From: pupiltong <12288479+PupilTong@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:42:48 +0800 Subject: [PATCH 06/10] + fix --- packages/genui/a2ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/genui/a2ui/package.json b/packages/genui/a2ui/package.json index b92263726b..9c94946ac3 100644 --- a/packages/genui/a2ui/package.json +++ b/packages/genui/a2ui/package.json @@ -93,7 +93,7 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { - "build": "pnpm --filter @lynx-js/a2ui-catalog-extractor build && node ../a2ui-catalog-extractor/dist/cli.js --catalog-dir src/catalog --out-dir dist/catalog && tsc -b" + "build": "pnpm --filter @lynx-js/a2ui-catalog-extractor build && node ../a2ui-catalog-extractor/dist/cli.js --catalog-dir src/catalog --out-dir dist/catalog" }, "dependencies": { "@a2ui/web_core": "0.9.1", From 5e03d1e2a94fed5589ded6424bb1070b27df72a2 Mon Sep 17 00:00:00 2001 From: Haoyang Wang <12288479+PupilTong@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:38:16 +0800 Subject: [PATCH 07/10] fix: make A2UI catalog extractor CLI portable --- .../genui/a2ui-catalog-extractor/src/cli.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/genui/a2ui-catalog-extractor/src/cli.ts b/packages/genui/a2ui-catalog-extractor/src/cli.ts index 5dd28ba12d..daa803e51f 100644 --- a/packages/genui/a2ui-catalog-extractor/src/cli.ts +++ b/packages/genui/a2ui-catalog-extractor/src/cli.ts @@ -5,6 +5,7 @@ import * as fs from 'node:fs'; import { createRequire } from 'node:module'; import * as path from 'node:path'; +import { pathToFileURL } from 'node:url'; import { extractCatalogComponentsFromTypeDocJson, @@ -115,11 +116,13 @@ export async function runCli( return 0; } - const inputs = options.sourceInputs.length > 0 - ? options.sourceInputs - : (options.catalogDirs.length > 0 - ? options.catalogDirs - : ['src/catalog']); + const configuredInputs = [ + ...options.sourceInputs, + ...options.catalogDirs, + ]; + const inputs = configuredInputs.length > 0 + ? configuredInputs + : ['src/catalog']; const sourceFiles = inputs.flatMap(input => findCatalogSourceFiles(path.resolve(cwd, input)) @@ -161,7 +164,10 @@ function readValue(args: string[], index: number, option: string): string { } try { - if (import.meta.url === `file://${process.argv[1]}`) { + if ( + process.argv[1] + && import.meta.url === pathToFileURL(process.argv[1]).href + ) { process.exitCode = await runCli(process.argv.slice(2)); } } catch (error) { From 19ae319f159c2b8c041ca92b0c56981b7b9099d8 Mon Sep 17 00:00:00 2001 From: Haoyang Wang <12288479+PupilTong@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:55:22 +0800 Subject: [PATCH 08/10] fix: fail fast on ambiguous A2UI catalog types --- .../genui/a2ui-catalog-extractor/README.md | 2 +- .../genui/a2ui-catalog-extractor/src/index.ts | 90 ++++++++++++++----- .../test/extractor.test.ts | 64 +++++++++++++ packages/genui/a2ui/package.json | 2 +- 4 files changed, 132 insertions(+), 26 deletions(-) diff --git a/packages/genui/a2ui-catalog-extractor/README.md b/packages/genui/a2ui-catalog-extractor/README.md index 86fe064bf0..2b5122f0a1 100644 --- a/packages/genui/a2ui-catalog-extractor/README.md +++ b/packages/genui/a2ui-catalog-extractor/README.md @@ -82,7 +82,7 @@ The extractor generates schema from the TypeDoc type model: - inline object type literals - `Record` -Unsupported references fail with an actionable error. +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. ## CLI diff --git a/packages/genui/a2ui-catalog-extractor/src/index.ts b/packages/genui/a2ui-catalog-extractor/src/index.ts index 78fd1e3b5b..50e147c0ff 100644 --- a/packages/genui/a2ui-catalog-extractor/src/index.ts +++ b/packages/genui/a2ui-catalog-extractor/src/index.ts @@ -13,7 +13,7 @@ export interface JsonSchema { default?: unknown; deprecated?: boolean; description?: string; - enum?: string[]; + enum?: Array; items?: JsonSchema; oneOf?: JsonSchema[]; properties?: Record; @@ -327,10 +327,17 @@ function createComponentSchema( reflection: TypeDocReflection, parsedDoc: ParsedDoc, ): JsonSchema { + if (reflection.children === undefined) { + throw createReflectionError( + reflection, + `Missing interface member declarations for "${reflection.name}".`, + ); + } + const schema: JsonSchema = { properties: {}, required: [] }; applyDocToSchema(schema, parsedDoc); - for (const child of reflection.children ?? []) { + for (const child of reflection.children) { if (child.inheritedFrom) { continue; } @@ -361,9 +368,15 @@ function parseTypeDocType( case 'intrinsic': return parseIntrinsicType(type.name ?? '', owner); case 'literal': - return parseLiteralType(type.value); + return parseLiteralType(type.value, owner); case 'union': - return parseUnionType(type.types ?? [], owner); + if (!type.types) { + throw createReflectionError( + owner, + `Missing union members for "${owner.name}".`, + ); + } + return parseUnionType(type.types, owner); case 'array': if (!type.elementType) { throw createReflectionError( @@ -419,7 +432,10 @@ function parseIntrinsicType( case 'null': case 'never': case 'void': - return {}; + throw createReflectionError( + owner, + `Unsupported ambiguous intrinsic TypeDoc type "${name}" for "${owner.name}".`, + ); default: throw createReflectionError( owner, @@ -430,17 +446,25 @@ function parseIntrinsicType( function parseLiteralType( value: bigint | boolean | number | string | null | undefined, + owner: TypeDocReflection, ): JsonSchema { switch (typeof value) { case 'string': return { type: 'string', enum: [value] }; case 'number': + return { type: 'number', enum: [value] }; case 'bigint': - return { type: 'number' }; + throw createReflectionError( + owner, + `Unsupported bigint literal for "${owner.name}".`, + ); case 'boolean': - return { type: 'boolean' }; + return { type: 'boolean', enum: [value] }; default: - return {}; + throw createReflectionError( + owner, + `Unsupported nullish literal for "${owner.name}".`, + ); } } @@ -448,10 +472,27 @@ function parseUnionType( types: TypeDocType[], owner: TypeDocReflection, ): JsonSchema { - const actualTypes = types.filter(type => !isNullishType(type)); + if (types.length === 0) { + throw createReflectionError( + owner, + `Missing union members for "${owner.name}".`, + ); + } + + if (types.some(type => isNullType(type))) { + throw createReflectionError( + owner, + `Unsupported nullable union for "${owner.name}".`, + ); + } + + const actualTypes = types.filter(type => !isUndefinedType(type)); if (actualTypes.length === 0) { - return {}; + throw createReflectionError( + owner, + `Unsupported undefined-only union for "${owner.name}".`, + ); } if (actualTypes.length === 1) { return parseTypeDocType(actualTypes[0]!, owner); @@ -488,6 +529,13 @@ function parseObjectReflection( declaration: TypeDocReflection, owner: TypeDocReflection, ): JsonSchema { + if (declaration.children === undefined) { + throw createReflectionError( + owner, + `Missing object declaration for "${owner.name}".`, + ); + } + const schema: JsonSchema = { type: 'object', properties: {}, @@ -495,7 +543,7 @@ function parseObjectReflection( additionalProperties: false, }; - for (const child of declaration.children ?? []) { + for (const child of declaration.children) { if (!isPropertyReflection(child)) { continue; } @@ -512,13 +560,6 @@ function parseObjectReflection( } } - if (declaration.children === undefined) { - throw createReflectionError( - owner, - `Missing object declaration for "${owner.name}".`, - ); - } - return schema; } @@ -681,14 +722,15 @@ function typeIncludesUndefined(type: TypeDocType): boolean { if (type.type !== 'union') { return false; } - return (type.types ?? []).some(childType => - childType.type === 'intrinsic' && childType.name === 'undefined' - ); + return (type.types ?? []).some(childType => isUndefinedType(childType)); +} + +function isUndefinedType(type: TypeDocType): boolean { + return type.type === 'intrinsic' && type.name === 'undefined'; } -function isNullishType(type: TypeDocType): boolean { - return (type.type === 'intrinsic' - && (type.name === 'undefined' || type.name === 'null')) +function isNullType(type: TypeDocType): boolean { + return (type.type === 'intrinsic' && type.name === 'null') || (type.type === 'literal' && type.value === null); } diff --git a/packages/genui/a2ui-catalog-extractor/test/extractor.test.ts b/packages/genui/a2ui-catalog-extractor/test/extractor.test.ts index f13e518480..42983dd0c5 100644 --- a/packages/genui/a2ui-catalog-extractor/test/extractor.test.ts +++ b/packages/genui/a2ui-catalog-extractor/test/extractor.test.ts @@ -199,6 +199,70 @@ export interface DemoCardProps { ]); }); + test('throws for ambiguous intrinsic catalog property types', () => { + expect(() => + extractCatalogComponentsFromTypeDocJson({ + children: [ + { + name: 'DemoPayloadProps', + kindString: 'Interface', + comment: { + blockTags: [ + { + tag: '@a2uiCatalog', + content: [{ text: 'DemoPayload' }], + }, + ], + }, + children: [ + { + name: 'payload', + kindString: 'Property', + type: { type: 'intrinsic', name: 'unknown' }, + }, + ], + }, + ], + }) + ).toThrow( + 'Unsupported ambiguous intrinsic TypeDoc type "unknown" for "payload".', + ); + }); + + test('throws for nullable unions instead of silently dropping null', () => { + expect(() => + extractCatalogComponentsFromTypeDocJson({ + children: [ + { + name: 'DemoNullableProps', + kindString: 'Interface', + comment: { + blockTags: [ + { + tag: '@a2uiCatalog', + content: [{ text: 'DemoNullable' }], + }, + ], + }, + children: [ + { + name: 'label', + kindString: 'Property', + type: { + type: 'union', + types: [ + { type: 'intrinsic', name: 'string' }, + { type: 'literal', value: null }, + ], + }, + }, + ], + }, + ], + }) + ).toThrow('Unsupported nullable union for "label".'); + }); + test('generates JSON deep-equal to packages/genui/a2ui/dist/catalog', async () => { const expectedCatalogs = readDistCatalogs(); const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'a2ui-catalog-out-')); diff --git a/packages/genui/a2ui/package.json b/packages/genui/a2ui/package.json index 9c94946ac3..baf148a10b 100644 --- a/packages/genui/a2ui/package.json +++ b/packages/genui/a2ui/package.json @@ -93,7 +93,7 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { - "build": "pnpm --filter @lynx-js/a2ui-catalog-extractor build && node ../a2ui-catalog-extractor/dist/cli.js --catalog-dir src/catalog --out-dir dist/catalog" + "build": "a2ui-catalog-extractor --catalog-dir src/catalog --out-dir dist/catalog" }, "dependencies": { "@a2ui/web_core": "0.9.1", From da8c6df6211f87236d915d57e52c5a5a444633f1 Mon Sep 17 00:00:00 2001 From: pupiltong <12288479+PupilTong@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:05:18 +0800 Subject: [PATCH 09/10] + update --- packages/genui/a2ui/AGENTS.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/genui/a2ui/AGENTS.md b/packages/genui/a2ui/AGENTS.md index 564e8fd46a..cb835e6d63 100644 --- a/packages/genui/a2ui/AGENTS.md +++ b/packages/genui/a2ui/AGENTS.md @@ -90,7 +90,6 @@ pnpm -C packages/genui/a2ui build Notes: -- `build` runs `tools/catalog_generator.ts` first, then `tsc -b`. - Build outputs go to `dist/` (including `dist/catalog/...` schemas). ## Catalog Schema Generation From 99256e3cdf03f2e308a1654e06d326802c9aa08b Mon Sep 17 00:00:00 2001 From: Haoyang Wang <12288479+PupilTong@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:27:55 +0800 Subject: [PATCH 10/10] test: use local TSX fixtures for A2UI extractor --- .../test/extractor.test.ts | 389 +++++------------- .../test/fixtures/catalog/DemoCard.tsx | 35 ++ .../test/fixtures/catalog/DemoText.tsx | 19 + .../expected-catalog/DemoCard/catalog.json | 103 +++++ .../expected-catalog/DemoText/catalog.json | 38 ++ .../fixtures/invalid/AmbiguousPayload.tsx | 10 + .../test/fixtures/invalid/NullableLabel.tsx | 10 + .../test/fixtures/tsconfig.json | 10 + 8 files changed, 327 insertions(+), 287 deletions(-) create mode 100644 packages/genui/a2ui-catalog-extractor/test/fixtures/catalog/DemoCard.tsx create mode 100644 packages/genui/a2ui-catalog-extractor/test/fixtures/catalog/DemoText.tsx create mode 100644 packages/genui/a2ui-catalog-extractor/test/fixtures/expected-catalog/DemoCard/catalog.json create mode 100644 packages/genui/a2ui-catalog-extractor/test/fixtures/expected-catalog/DemoText/catalog.json create mode 100644 packages/genui/a2ui-catalog-extractor/test/fixtures/invalid/AmbiguousPayload.tsx create mode 100644 packages/genui/a2ui-catalog-extractor/test/fixtures/invalid/NullableLabel.tsx create mode 100644 packages/genui/a2ui-catalog-extractor/test/fixtures/tsconfig.json diff --git a/packages/genui/a2ui-catalog-extractor/test/extractor.test.ts b/packages/genui/a2ui-catalog-extractor/test/extractor.test.ts index 42983dd0c5..98bcf684a3 100644 --- a/packages/genui/a2ui-catalog-extractor/test/extractor.test.ts +++ b/packages/genui/a2ui-catalog-extractor/test/extractor.test.ts @@ -6,297 +6,96 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { describe, expect, test } from '@rstest/core'; +import { afterAll, describe, expect, test } from '@rstest/core'; import { createA2UICatalog, extractCatalogComponents, - extractCatalogComponentsFromTypeDocJson, + findCatalogSourceFiles, writeComponentCatalogs, } from '../src/index.js'; const __filename = fileURLToPath(import.meta.url); const packageDir = path.resolve(path.dirname(__filename), '..'); -const a2uiDir = path.resolve(packageDir, '../a2ui'); -const a2uiDistCatalogDir = path.join(a2uiDir, 'dist/catalog'); -const a2uiSourceCatalogDir = path.join(a2uiDir, 'src/catalog'); +const fixtureDir = path.join(packageDir, 'test/fixtures'); +const catalogFixtureDir = path.join(fixtureDir, 'catalog'); +const expectedCatalogDir = path.join(fixtureDir, 'expected-catalog'); +const fixtureTsconfig = 'tsconfig.json'; +const tempDirs: string[] = []; + +void afterAll(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); describe('extractCatalogComponents', () => { - test('extracts a component schema from a TypeDoc-marked interface', async () => { - const fixtureDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'a2ui-catalog-fixture-'), - ); - const fixture = path.join(fixtureDir, 'DemoCard.tsx'); - const fixtureTsconfig = path.join(fixtureDir, 'tsconfig.json'); - fs.writeFileSync( - fixture, - ` -/** - * @a2uiCatalog DemoCard - */ -export interface DemoCardProps { - /** Main title. */ - title: string | { path: string }; - /** Visual tone. */ - tone?: 'neutral' | 'accent'; - /** Extra payload. - * @defaultValue \`{}\` - */ - context?: Record; - action: { - event: { - name: string; - }; - }; -} -`, - ); - fs.writeFileSync( - fixtureTsconfig, - JSON.stringify({ - compilerOptions: { - jsx: 'preserve', - module: 'ESNext', - moduleResolution: 'Bundler', - target: 'ESNext', - }, - include: ['DemoCard.tsx'], - }), - ); + test('extracts component schemas from TSX catalog fixtures', async () => { + const sourceFiles = findCatalogSourceFiles(catalogFixtureDir); + + expect(sourceFiles.map(file => path.basename(file))).toEqual([ + 'DemoCard.tsx', + 'DemoText.tsx', + ]); const components = await extractCatalogComponents({ cwd: fixtureDir, - sourceFiles: ['DemoCard.tsx'], + sourceFiles, + tsconfig: fixtureTsconfig, }); + const componentsByName = Object.fromEntries( + components.map(component => [component.name, component]), + ); + const expectedCatalogs = readExpectedCatalogs(); - expect(components).toEqual([ - { - filePath: fixture, - interfaceName: 'DemoCardProps', - name: 'DemoCard', - schema: { - properties: { - title: { - oneOf: [ - { type: 'string' }, - { - type: 'object', - properties: { path: { type: 'string' } }, - required: ['path'], - additionalProperties: false, - }, - ], - description: 'Main title.', - }, - tone: { - type: 'string', - enum: ['neutral', 'accent'], - description: 'Visual tone.', - }, - context: { - type: 'object', - additionalProperties: { - oneOf: [ - { type: 'string' }, - { type: 'number' }, - { type: 'boolean' }, - ], - }, - description: 'Extra payload.', - default: {}, - }, - action: { - type: 'object', - properties: { - event: { - type: 'object', - properties: { - name: { type: 'string' }, - }, - required: ['name'], - additionalProperties: false, - }, - }, - required: ['event'], - additionalProperties: false, - }, - }, - required: ['title', 'action'], - }, - }, + expect(Object.keys(componentsByName).sort()).toEqual([ + 'DemoCard', + 'DemoText', ]); - }); - - test('extracts a component schema from TypeDoc JSON', () => { - const components = extractCatalogComponentsFromTypeDocJson({ - children: [ - { - name: 'DemoTextProps', - kindString: 'Interface', - comment: { - blockTags: [ - { - tag: '@a2uiCatalog', - content: [{ text: 'DemoText' }], - }, - ], - }, - children: [ - { - name: 'text', - kindString: 'Property', - comment: { - summary: [{ text: 'Literal text or path binding.' }], - }, - type: { - type: 'union', - types: [ - { type: 'intrinsic', name: 'string' }, - { - type: 'reflection', - declaration: { - name: '__type', - children: [ - { - name: 'path', - kindString: 'Property', - type: { type: 'intrinsic', name: 'string' }, - }, - ], - }, - }, - ], - }, - }, - ], - }, - ], + expect(componentsByName['DemoCard']).toMatchObject({ + filePath: path.join(catalogFixtureDir, 'DemoCard.tsx'), + interfaceName: 'DemoCardProps', + name: 'DemoCard', + schema: expectedCatalogs['DemoCard']!['DemoCard'], + }); + expect(componentsByName['DemoText']).toMatchObject({ + filePath: path.join(catalogFixtureDir, 'DemoText.tsx'), + interfaceName: 'DemoTextProps', + name: 'DemoText', + schema: expectedCatalogs['DemoText']!['DemoText'], }); - - expect(components).toEqual([ - { - filePath: '', - interfaceName: 'DemoTextProps', - name: 'DemoText', - schema: { - properties: { - text: { - description: 'Literal text or path binding.', - oneOf: [ - { type: 'string' }, - { - type: 'object', - properties: { path: { type: 'string' } }, - required: ['path'], - additionalProperties: false, - }, - ], - }, - }, - required: ['text'], - }, - }, - ]); - }); - - test('throws for ambiguous intrinsic catalog property types', () => { - expect(() => - extractCatalogComponentsFromTypeDocJson({ - children: [ - { - name: 'DemoPayloadProps', - kindString: 'Interface', - comment: { - blockTags: [ - { - tag: '@a2uiCatalog', - content: [{ text: 'DemoPayload' }], - }, - ], - }, - children: [ - { - name: 'payload', - kindString: 'Property', - type: { type: 'intrinsic', name: 'unknown' }, - }, - ], - }, - ], - }) - ).toThrow( - 'Unsupported ambiguous intrinsic TypeDoc type "unknown" for "payload".', - ); - }); - - test('throws for nullable unions instead of silently dropping null', () => { - expect(() => - extractCatalogComponentsFromTypeDocJson({ - children: [ - { - name: 'DemoNullableProps', - kindString: 'Interface', - comment: { - blockTags: [ - { - tag: '@a2uiCatalog', - content: [{ text: 'DemoNullable' }], - }, - ], - }, - children: [ - { - name: 'label', - kindString: 'Property', - type: { - type: 'union', - types: [ - { type: 'intrinsic', name: 'string' }, - { type: 'literal', value: null }, - ], - }, - }, - ], - }, - ], - }) - ).toThrow('Unsupported nullable union for "label".'); }); - test('generates JSON deep-equal to packages/genui/a2ui/dist/catalog', async () => { - const expectedCatalogs = readDistCatalogs(); - const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'a2ui-catalog-out-')); + test('writes catalog.json files from TSX catalog fixtures', async () => { + const outDir = createTempDir(); + const expectedCatalogs = readExpectedCatalogs(); - await writeComponentCatalogs({ - cwd: a2uiDir, + const components = await writeComponentCatalogs({ + cwd: fixtureDir, outDir, - sourceFiles: expectedCatalogs.map(({ componentName }) => - getSourceFileForComponent(componentName) - ), + sourceFiles: findCatalogSourceFiles(catalogFixtureDir), + tsconfig: fixtureTsconfig, }); - for (const expectedCatalog of expectedCatalogs) { - const actualJsonPath = path.join( - outDir, - expectedCatalog.componentName, - 'catalog.json', - ); - expect(JSON.parse(fs.readFileSync(actualJsonPath, 'utf8'))).toEqual( - expectedCatalog.json, + expect(components.map(component => component.name).sort()).toEqual([ + 'DemoCard', + 'DemoText', + ]); + + for (const componentName of Object.keys(expectedCatalogs)) { + expect(readCatalogJson(outDir, componentName)).toEqual( + expectedCatalogs[componentName], ); } }); - test('can create a full catalog from extracted components', async () => { - const textCatalog = readDistCatalogs().find(({ componentName }) => - componentName === 'Text' - ); - expect(textCatalog).toBeDefined(); - + test('creates a full catalog from TSX-extracted components', async () => { const components = await extractCatalogComponents({ - cwd: a2uiDir, - sourceFiles: [getSourceFileForComponent('Text')], + cwd: fixtureDir, + sourceFiles: findCatalogSourceFiles(catalogFixtureDir), + tsconfig: fixtureTsconfig, }); + const expectedCatalogs = readExpectedCatalogs(); expect(createA2UICatalog({ catalogId: 'https://example.com/catalog.json', @@ -304,39 +103,55 @@ export interface DemoCardProps { })).toEqual({ catalogId: 'https://example.com/catalog.json', components: { - Text: textCatalog!.json['Text'], + DemoCard: expectedCatalogs['DemoCard']!['DemoCard'], + DemoText: expectedCatalogs['DemoText']!['DemoText'], }, }); }); -}); -function readDistCatalogs(): { - componentName: string; - json: Record; -}[] { - expect(fs.existsSync(a2uiDistCatalogDir)).toBe(true); + test('throws for ambiguous intrinsic catalog property types in TSX fixtures', async () => { + await expect(extractCatalogComponents({ + cwd: fixtureDir, + sourceFiles: ['invalid/AmbiguousPayload.tsx'], + tsconfig: fixtureTsconfig, + })).rejects.toThrow( + 'Unsupported ambiguous intrinsic TypeDoc type "unknown" for "payload".', + ); + }); - const catalogJsonPaths = fs.readdirSync(a2uiDistCatalogDir) - .map(componentName => path.join(a2uiDistCatalogDir, componentName)) - .filter(componentPath => fs.statSync(componentPath).isDirectory()) - .map(componentPath => path.join(componentPath, 'catalog.json')) - .filter(catalogJsonPath => fs.existsSync(catalogJsonPath)) - .sort((left, right) => left.localeCompare(right)); + test('throws for nullable unions in TSX fixtures', async () => { + await expect(extractCatalogComponents({ + cwd: fixtureDir, + sourceFiles: ['invalid/NullableLabel.tsx'], + tsconfig: fixtureTsconfig, + })).rejects.toThrow('Unsupported nullable union for "label".'); + }); +}); - expect(catalogJsonPaths.length).toBeGreaterThan(0); +function createTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'a2ui-catalog-out-')); + tempDirs.push(dir); + return dir; +} - return catalogJsonPaths.map(catalogJsonPath => { - const componentName = path.basename(path.dirname(catalogJsonPath)); - return { - componentName, - json: JSON.parse(fs.readFileSync(catalogJsonPath, 'utf8')) as Record< - string, - unknown - >, - }; - }); +function readExpectedCatalogs(): Record> { + return Object.fromEntries( + fs.readdirSync(expectedCatalogDir) + .map(componentName => [ + componentName, + readCatalogJson(expectedCatalogDir, componentName), + ]), + ); } -function getSourceFileForComponent(componentName: string): string { - return path.join(a2uiSourceCatalogDir, componentName, 'index.tsx'); +function readCatalogJson( + rootDir: string, + componentName: string, +): Record { + return JSON.parse( + fs.readFileSync( + path.join(rootDir, componentName, 'catalog.json'), + 'utf8', + ), + ) as Record; } diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/catalog/DemoCard.tsx b/packages/genui/a2ui-catalog-extractor/test/fixtures/catalog/DemoCard.tsx new file mode 100644 index 0000000000..410d414719 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/catalog/DemoCard.tsx @@ -0,0 +1,35 @@ +// 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. + +/** + * Demo card fixture. + * + * @remarks Generated from a TSX fixture. + * @a2uiCatalog DemoCard + */ +export interface DemoCardProps { + /** Main title. */ + title: string | { path: string }; + /** Visual tone. */ + tone?: 'neutral' | 'accent'; + /** Number of columns. */ + columns?: 1 | 2 | 3; + /** + * Extra payload. + * + * @defaultValue `{}` + */ + context?: Record; + /** Server-dispatched action payload. */ + action: { + event: { + /** Event name. */ + name: string; + }; + }; +} + +export function DemoCard(_props: DemoCardProps): null { + return null; +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/catalog/DemoText.tsx b/packages/genui/a2ui-catalog-extractor/test/fixtures/catalog/DemoText.tsx new file mode 100644 index 0000000000..20cf676413 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/catalog/DemoText.tsx @@ -0,0 +1,19 @@ +// 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. + +/** + * Demo text fixture. + * + * @a2uiCatalog + */ +export interface DemoTextProps { + /** Literal text or data path binding. */ + text: string | { path: string }; + /** Text presentation variant. */ + variant?: 'body' | 'caption'; +} + +export function DemoText(_props: DemoTextProps): null { + return null; +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/expected-catalog/DemoCard/catalog.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/expected-catalog/DemoCard/catalog.json new file mode 100644 index 0000000000..b21ddc9299 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/expected-catalog/DemoCard/catalog.json @@ -0,0 +1,103 @@ +{ + "DemoCard": { + "properties": { + "title": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + ], + "description": "Main title." + }, + "tone": { + "type": "string", + "enum": [ + "neutral", + "accent" + ], + "description": "Visual tone." + }, + "columns": { + "oneOf": [ + { + "type": "number", + "enum": [ + 1 + ] + }, + { + "type": "number", + "enum": [ + 2 + ] + }, + { + "type": "number", + "enum": [ + 3 + ] + } + ], + "description": "Number of columns." + }, + "context": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + }, + "description": "Extra payload.", + "default": {} + }, + "action": { + "type": "object", + "properties": { + "event": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Event name." + } + }, + "required": [ + "name" + ], + "additionalProperties": false + } + }, + "required": [ + "event" + ], + "additionalProperties": false, + "description": "Server-dispatched action payload." + } + }, + "required": [ + "title", + "action" + ], + "description": "Demo card fixture.\n\nGenerated from a TSX fixture." + } +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/expected-catalog/DemoText/catalog.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/expected-catalog/DemoText/catalog.json new file mode 100644 index 0000000000..926c125566 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/expected-catalog/DemoText/catalog.json @@ -0,0 +1,38 @@ +{ + "DemoText": { + "properties": { + "text": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + ], + "description": "Literal text or data path binding." + }, + "variant": { + "type": "string", + "enum": [ + "body", + "caption" + ], + "description": "Text presentation variant." + } + }, + "required": [ + "text" + ], + "description": "Demo text fixture." + } +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/invalid/AmbiguousPayload.tsx b/packages/genui/a2ui-catalog-extractor/test/fixtures/invalid/AmbiguousPayload.tsx new file mode 100644 index 0000000000..0d8fbc20da --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/invalid/AmbiguousPayload.tsx @@ -0,0 +1,10 @@ +// 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. + +/** + * @a2uiCatalog AmbiguousPayload + */ +export interface AmbiguousPayloadProps { + payload: unknown; +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/invalid/NullableLabel.tsx b/packages/genui/a2ui-catalog-extractor/test/fixtures/invalid/NullableLabel.tsx new file mode 100644 index 0000000000..ffa79f9991 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/invalid/NullableLabel.tsx @@ -0,0 +1,10 @@ +// 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. + +/** + * @a2uiCatalog NullableLabel + */ +export interface NullableLabelProps { + label: string | null; +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/tsconfig.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsconfig.json new file mode 100644 index 0000000000..2e6721505c --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "jsx": "preserve", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "target": "ESNext", + }, + "include": ["catalog/**/*.tsx", "invalid/**/*.tsx"], +}