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/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::" diff --git a/packages/genui/a2ui-catalog-extractor/README.md b/packages/genui/a2ui-catalog-extractor/README.md new file mode 100644 index 0000000000..2b5122f0a1 --- /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 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 + +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/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 new file mode 100644 index 0000000000..57c077df57 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/package.json @@ -0,0 +1,39 @@ +{ + "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": "./bin/a2ui-catalog-extractor.js" + }, + "files": [ + "bin", + "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..c85237bcf0 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/rslib.config.ts @@ -0,0 +1,20 @@ +// 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'; + +const config: RslibConfig = 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', + }, +}); + +export default config; 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..093336388a --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/rstest.config.ts @@ -0,0 +1,12 @@ +// 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'; + +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/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..daa803e51f --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/src/cli.ts @@ -0,0 +1,176 @@ +#!/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 { pathToFileURL } from 'node:url'; + +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: string = 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 configuredInputs = [ + ...options.sourceInputs, + ...options.catalogDirs, + ]; + const inputs = configuredInputs.length > 0 + ? configuredInputs + : ['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 ( + process.argv[1] + && import.meta.url === pathToFileURL(process.argv[1]).href + ) { + 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..50e147c0ff --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/src/index.ts @@ -0,0 +1,806 @@ +// 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?: Array; + 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 { + 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) { + 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, owner); + case 'union': + if (!type.types) { + throw createReflectionError( + owner, + `Missing union members for "${owner.name}".`, + ); + } + 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': + throw createReflectionError( + owner, + `Unsupported ambiguous intrinsic TypeDoc type "${name}" for "${owner.name}".`, + ); + default: + throw createReflectionError( + owner, + `Unsupported intrinsic TypeDoc type "${name}" for "${owner.name}".`, + ); + } +} + +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': + throw createReflectionError( + owner, + `Unsupported bigint literal for "${owner.name}".`, + ); + case 'boolean': + return { type: 'boolean', enum: [value] }; + default: + throw createReflectionError( + owner, + `Unsupported nullish literal for "${owner.name}".`, + ); + } +} + +function parseUnionType( + types: TypeDocType[], + owner: TypeDocReflection, +): JsonSchema { + 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) { + throw createReflectionError( + owner, + `Unsupported undefined-only union for "${owner.name}".`, + ); + } + 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 { + if (declaration.children === undefined) { + throw createReflectionError( + owner, + `Missing object declaration for "${owner.name}".`, + ); + } + + 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); + } + } + + 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 => isUndefinedType(childType)); +} + +function isUndefinedType(type: TypeDocType): boolean { + return type.type === 'intrinsic' && type.name === 'undefined'; +} + +function isNullType(type: TypeDocType): boolean { + return (type.type === 'intrinsic' && 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..98bcf684a3 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/extractor.test.ts @@ -0,0 +1,157 @@ +// 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 { afterAll, describe, expect, test } from '@rstest/core'; + +import { + createA2UICatalog, + extractCatalogComponents, + findCatalogSourceFiles, + writeComponentCatalogs, +} from '../src/index.js'; + +const __filename = fileURLToPath(import.meta.url); +const packageDir = path.resolve(path.dirname(__filename), '..'); +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 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, + tsconfig: fixtureTsconfig, + }); + const componentsByName = Object.fromEntries( + components.map(component => [component.name, component]), + ); + const expectedCatalogs = readExpectedCatalogs(); + + expect(Object.keys(componentsByName).sort()).toEqual([ + 'DemoCard', + 'DemoText', + ]); + 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'], + }); + }); + + test('writes catalog.json files from TSX catalog fixtures', async () => { + const outDir = createTempDir(); + const expectedCatalogs = readExpectedCatalogs(); + + const components = await writeComponentCatalogs({ + cwd: fixtureDir, + outDir, + sourceFiles: findCatalogSourceFiles(catalogFixtureDir), + tsconfig: fixtureTsconfig, + }); + + 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('creates a full catalog from TSX-extracted components', async () => { + const components = await extractCatalogComponents({ + cwd: fixtureDir, + sourceFiles: findCatalogSourceFiles(catalogFixtureDir), + tsconfig: fixtureTsconfig, + }); + const expectedCatalogs = readExpectedCatalogs(); + + expect(createA2UICatalog({ + catalogId: 'https://example.com/catalog.json', + components, + })).toEqual({ + catalogId: 'https://example.com/catalog.json', + components: { + DemoCard: expectedCatalogs['DemoCard']!['DemoCard'], + DemoText: expectedCatalogs['DemoText']!['DemoText'], + }, + }); + }); + + 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".', + ); + }); + + 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".'); + }); +}); + +function createTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'a2ui-catalog-out-')); + tempDirs.push(dir); + return dir; +} + +function readExpectedCatalogs(): Record> { + return Object.fromEntries( + fs.readdirSync(expectedCatalogDir) + .map(componentName => [ + componentName, + readCatalogJson(expectedCatalogDir, componentName), + ]), + ); +} + +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"], +} 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..4b6e9c1c23 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "noEmit": true, + "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/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 diff --git a/packages/genui/a2ui/package.json b/packages/genui/a2ui/package.json index b73f5f2f62..baf148a10b 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": "a2ui-catalog-extractor --catalog-dir src/catalog --out-dir dist/catalog" }, "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/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..26c0378304 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,8 +35,9 @@ export function Image( const [hasError, setHasError] = useState(false); - useEffect(() => { + useLynxEffect(() => { setHasError(false); + return undefined; }, [url]); const finalSrc = hasError 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 deleted file mode 100644 index 4c3192b0bc..0000000000 --- a/packages/genui/a2ui/tools/catalog_generator.ts +++ /dev/null @@ -1,279 +0,0 @@ -import * as ts from 'typescript'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -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'); - -// 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; - } - - if (!stringIndexType) { - schema.additionalProperties = false; - } - } - - if (stringIndexType) { - schema.additionalProperties = parseType(stringIndexType, checker); - } - return schema; - } - - return { type: 'string' }; // Fallback -} - -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/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/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..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) @@ -510,6 +513,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 +3249,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 +4856,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 +8414,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 +8446,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 +10740,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 +12333,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 +14331,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 +14357,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 +14371,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 +18327,8 @@ snapshots: dependencies: react: 19.2.4 + lunr@2.3.9: {} + lz-string@1.5.0: {} magic-string@0.30.21: @@ -18291,6 +18364,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 +21082,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', + ], });