diff --git a/.gitignore b/.gitignore index fff687b8c3..a657a9cb8b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules dist lib www +.github/*.instructions.md playwright-report test-results trace.zip diff --git a/packages/genui/a2ui-catalog-extractor/README.md b/packages/genui/a2ui-catalog-extractor/README.md new file mode 100644 index 0000000000..56fad23476 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/README.md @@ -0,0 +1,236 @@ +# A2UI Catalog Extractor + +`@lynx-js/a2ui-catalog-extractor` generates A2UI catalog schemas from explicit declaration syntax plus standard JSDoc, TSDoc, and TypeDoc metadata. + +It is designed for the cases where checker-driven type extraction becomes brittle. The extractor reads the shapes that authors write directly in `.tsx`, keeps `.jsx` support as a best-effort path, and can emit either: + +- legacy per-component shards such as `dist/catalog/Button/catalog.json` +- a full catalog object with `catalogId`, `components`, and optional root metadata passthrough + +## What It Supports + +The extractor reads: + +- exported component function names as catalog component keys +- explicit TypeScript syntax for: + - primitives + - string literal unions + - arrays + - object literals and interfaces + - optional properties + - local aliases + - `Record` and string index signatures + - unions such as `string | { path: string }` +- standard documentation tags for: + - property `description` + - `default` + - `deprecated` +- named local interfaces and type aliases for complex nested object graphs +- `.jsx` best-effort typedef parsing through `@typedef`, `@property`, and parameter or property JSDoc type expressions + +The legacy compatibility output covers the schema fields currently emitted by A2UI: + +- `properties` +- `required` +- property `description` +- property `type` +- property `enum` +- property `oneOf` +- property `items` +- nested `properties` +- `additionalProperties` + +## Authoring Model + +Use `.tsx` as the primary authoring path. + +```tsx +type Binding = { path: string }; +type BindableText = string | Binding; + +export interface TextProps { + /** Literal text or a binding path. */ + text: BindableText; + + /** + * Visual tone. + * @defaultValue "body" + */ + tone?: 'body' | 'caption'; +} + +export function Text(_props: TextProps): null { + return null; +} +``` + +That produces a schema shaped like: + +```json +{ + "Text": { + "properties": { + "text": { + "description": "Literal text or a binding path.", + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "properties": { + "path": { "type": "string" } + }, + "required": ["path"], + "additionalProperties": false + } + ] + }, + "tone": { + "description": "Visual tone.", + "type": "string", + "enum": ["body", "caption"], + "default": "body" + } + }, + "required": ["text"] + } +} +``` + +## Standard Tags + +Prefer standard tags first: + +| Source | Generated field | +| ---------------------------- | ------------------------- | +| summary text | `description` | +| `@remarks` | appended to `description` | +| `@defaultValue` / `@default` | `default` | +| `@deprecated` | `deprecated` | +| string literal union | `enum` | +| optional property | omitted from `required` | + +For more complex schemas, keep the structure explicit in local types instead of relying on custom tags. + +```ts +export interface ActionContextBinding { + path: string; +} + +export type ActionContextValue = + | string + | number + | boolean + | ActionContextBinding; + +export interface ActionEvent { + name: string; + /** Context is a JSON object map in v0.9. */ + context?: Record; +} + +export interface ActionPayload { + event: ActionEvent; +} + +export interface ButtonProps { + /** Host action payload. */ + action: ActionPayload; +} +``` + +See [references/tsdoc-mapping.md](./references/tsdoc-mapping.md) for the full mapping contract. + +## CLI + +Generate legacy shards: + +```bash +a2ui-catalog-extractor generate \ + --source ./src/catalog \ + --out ./dist/catalog \ + --tsconfig ./tsconfig.json \ + --format legacy-shards +``` + +Check generated output: + +```bash +a2ui-catalog-extractor check \ + --source ./src/catalog \ + --out ./dist/catalog \ + --tsconfig ./tsconfig.json \ + --format legacy-shards +``` + +Generate a full catalog object: + +```bash +a2ui-catalog-extractor generate \ + --source ./src/catalog \ + --out ./dist/catalog \ + --tsconfig ./tsconfig.json \ + --format a2ui-catalog \ + --catalog-id demo-catalog \ + --title "Demo Catalog" +``` + +## API + +```ts +import { + extractCatalog, + writeCatalogFiles, +} from '@lynx-js/a2ui-catalog-extractor'; + +const result = await extractCatalog({ + sourceDir: './src/catalog', + tsconfigPath: './tsconfig.json', + format: 'legacy-shards', +}); + +await writeCatalogFiles(result, { + outDir: './dist/catalog', +}); +``` + +## JSX Support + +`.jsx` is best-effort in v1. Prefer a typedef block that fully describes the component props: + +```jsx +/** + * @typedef {object} BadgeProps + * @property {string | { path: string }} text Literal badge text. + * @property {'info' | 'warning'} [tone] Badge tone. + */ + +/** + * @param {BadgeProps} props + */ +export function Badge(props) { + return props; +} +``` + +Complex nested schemas in `.jsx` should move to `.tsx` so the structure can stay explicit. + +## A2UI Integration + +The A2UI package uses this extractor during its build: + +```bash +node --experimental-strip-types ../a2ui-catalog-extractor/src/cli.ts generate ... +``` + +This keeps the catalog build independent from a prebuilt extractor package while still preserving a normal ESM library and CLI build for direct package consumption. + +## Validation + +Run the focused package checks with Node 24 in this repository: + +```bash +fnm exec --using v24.15.0 -- pnpm --filter @lynx-js/a2ui-catalog-extractor build +fnm exec --using v24.15.0 -- pnpm --filter @lynx-js/a2ui-catalog-extractor test +``` + +The test suite includes golden parity coverage for the current A2UI legacy catalog shards. diff --git a/packages/genui/a2ui-catalog-extractor/SKILL.md b/packages/genui/a2ui-catalog-extractor/SKILL.md new file mode 100644 index 0000000000..686c4f9432 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/SKILL.md @@ -0,0 +1,46 @@ +--- +name: a2ui-catalog-extractor +description: Use when updating the A2UI catalog extractor, annotating catalog components with JSDoc or TSDoc, or validating legacy catalog shard parity. +--- + +# A2UI Catalog Extractor + +Use this skill when you are: + +- changing `packages/genui/a2ui-catalog-extractor` +- annotating `packages/genui/a2ui/src/catalog/*/index.tsx` +- debugging generated `dist/catalog/*/catalog.json` output + +## Workflow + +1. Keep `.tsx` as the primary authoring path. +2. Prefer explicit declaration syntax and standard tags over custom annotations. +3. Model complex nested schemas with named local interfaces and type aliases. +4. Preserve legacy shard compatibility for A2UI unless the task explicitly changes the contract. + +## Key Rules + +- Component names come from exported symbols. +- Property descriptions come from normal doc comments. +- `@defaultValue` and `@default` map to JSON Schema `default`. +- String literal unions map to `enum`. +- Optional props are omitted from `required`. +- Ignore framework-only props such as `id`, `surface`, `setValue`, `sendAction`, `dataContextPath`, `__template`, and `component`. + +## Important Implementation Note + +TypeDoc is the primary source for standard documentation metadata. The extractor should rely on explicit local TypeScript syntax for schema structure instead of custom schema tags. + +## References + +- Read [references/tsdoc-mapping.md](./references/tsdoc-mapping.md) when changing extraction rules. +- Read [references/a2ui-v0.9-schema.md](./references/a2ui-v0.9-schema.md) when changing the catalog surface or compatibility targets. + +## Validation + +Use the repository's Node 24 toolchain: + +```bash +fnm exec --using v24.15.0 -- pnpm --filter @lynx-js/a2ui-catalog-extractor test +fnm exec --using v24.15.0 -- pnpm --filter @lynx-js/a2ui-reactlynx build +``` diff --git a/packages/genui/a2ui-catalog-extractor/agents/openai.yaml b/packages/genui/a2ui-catalog-extractor/agents/openai.yaml new file mode 100644 index 0000000000..1ffc3a71da --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "A2UI Catalog Extractor" + short_description: "Update A2UI catalog extraction and docs." + default_prompt: "Use $a2ui-catalog-extractor to update the A2UI catalog extractor or catalog component annotations." + +policy: + allow_implicit_invocation: true diff --git a/packages/genui/a2ui-catalog-extractor/eslint.config.js b/packages/genui/a2ui-catalog-extractor/eslint.config.js new file mode 100644 index 0000000000..4062434618 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/eslint.config.js @@ -0,0 +1,66 @@ +// 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 eslint from '@eslint/js'; +import { defineConfig, globalIgnores } from 'eslint/config'; +import headers from 'eslint-plugin-headers'; +import tseslint from 'typescript-eslint'; + +export default defineConfig( + globalIgnores([ + '.rslib/**', + 'dist/**', + 'node_modules/**', + 'rslib.config.ts', + 'rstest.config.ts', + 'test/fixtures/**', + ]), + eslint.configs.recommended, + tseslint.configs.recommended, + { + languageOptions: { + parserOptions: { + tsconfigRootDir: import.meta.dirname, + projectService: { + allowDefaultProject: ['eslint.config.js'], + defaultProject: './tsconfig.json', + }, + }, + }, + }, + { + plugins: { + headers, + }, + rules: { + 'headers/header-format': [ + 'error', + { + source: 'string', + style: 'line', + content: [ + 'Copyright (year) {authors}. All rights reserved.', + 'Licensed under the (license) that can be found in the', + 'LICENSE file in the root directory of this source tree.', + ].join('\n'), + variables: { + authors: 'The Lynx Authors', + }, + patterns: { + year: { + pattern: '\\d{4}', + defaultValue: new Date().getFullYear().toString(), + }, + license: { + pattern: [ + 'Apache License Version 2.0', + ].join('|'), + defaultValue: 'Apache License Version 2.0', + }, + }, + }, + ], + }, + }, +); diff --git a/packages/genui/a2ui-catalog-extractor/package.json b/packages/genui/a2ui-catalog-extractor/package.json new file mode 100644 index 0000000000..2969830ca8 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/package.json @@ -0,0 +1,45 @@ +{ + "name": "@lynx-js/a2ui-catalog-extractor", + "version": "0.0.0", + "private": true, + "description": "Extract A2UI catalog schemas from TypeDoc, TSDoc, and explicit declaration syntax.", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "a2ui-catalog-extractor": "./dist/cli.js" + }, + "files": [ + "dist", + "README.md", + "SKILL.md", + "agents", + "references" + ], + "scripts": { + "build": "rslib build", + "test": "rstest" + }, + "dependencies": { + "@microsoft/tsdoc": "^0.16.0", + "@microsoft/tsdoc-config": "^0.18.1", + "typedoc": "^0.28.9", + "typescript": "^5.9.3" + }, + "devDependencies": { + "@rstest/core": "catalog:rstest", + "@types/node": "^24.10.13" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/genui/a2ui-catalog-extractor/references/a2ui-v0.9-schema.md b/packages/genui/a2ui-catalog-extractor/references/a2ui-v0.9-schema.md new file mode 100644 index 0000000000..6c46fab233 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/references/a2ui-v0.9-schema.md @@ -0,0 +1,96 @@ +# A2UI v0.9 Catalog Surface + +This note summarizes the A2UI v0.9 catalog concepts that matter for the extractor. + +## Root Catalog Fields + +The reusable full-catalog API currently accepts and emits these root fields: + +- `$schema`: optional schema identifier for the catalog document +- `catalogId`: stable identifier for the catalog +- `title`: human-readable catalog title +- `description`: human-readable catalog description +- `components`: component schemas keyed by component name +- `functions`: optional function schemas exposed to the catalog runtime +- `theme`: optional theme-related schema metadata + +This is currently a root-level metadata wrapper around extracted component schemas. It does not yet synthesize richer v0.9-only structures beyond the extracted `components` map and explicit caller-provided root metadata. + +The A2UI package currently builds in legacy shard mode, where each generated file only contains the component map for one component: + +```json +{ + "Button": { + "properties": {}, + "required": [] + } +} +``` + +## Component Schema Fields + +For current A2UI compatibility, the extractor must preserve these fields: + +- `properties` +- `required` +- property `description` +- property `type` +- property `enum` +- property `oneOf` +- property `items` +- nested `properties` +- `additionalProperties` + +## How Schema Fields Reflect A2UI Capabilities + +### Binding-capable values + +A2UI commonly models data binding as a literal value or a path object. In schema form that becomes a `oneOf` branch such as: + +- literal `string`, `number`, or `boolean` +- binding object `{ path: string }` + +This is how props such as text content, URLs, or form values describe runtime data binding. + +### Child and template references + +Container-like components often accept child component identifiers, arrays of child identifiers, or template-like objects. These shapes are represented with: + +- `type: "string"` +- `type: "array"` plus `items` +- nested object `properties` +- `oneOf` when static and dynamic child forms coexist + +### Actions + +Interactive components describe host action payloads as nested objects with required fields and constrained maps. These rely on: + +- nested `properties` +- nested `required` +- `additionalProperties` +- `oneOf` for mixed scalar-or-binding map values + +### Layout controls + +Layout containers such as `Row`, `Column`, and `List` mostly use: + +- string `enum` for direction, alignment, and justification +- scalar properties for sizing, gap, padding, and weights + +### Form values + +Form-like inputs often use: + +- scalar value types +- binding unions through `oneOf` +- defaults and descriptions from standard tags + +## Compatibility Target + +The compatibility target for this repository is the existing A2UI legacy shard output in `packages/genui/a2ui/dist/catalog/*/catalog.json`. + +When changing extraction behavior: + +1. preserve the existing shard contract unless the task explicitly changes it +2. update or add golden fixtures in `packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline` +3. verify the A2UI package build still regenerates the expected catalog files diff --git a/packages/genui/a2ui-catalog-extractor/references/tsdoc-mapping.md b/packages/genui/a2ui-catalog-extractor/references/tsdoc-mapping.md new file mode 100644 index 0000000000..21e25fc132 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/references/tsdoc-mapping.md @@ -0,0 +1,74 @@ +# TSDoc and TypeDoc Mapping + +This package uses TypeDoc as the documentation front-end and explicit TypeScript syntax as the schema front-end. + +## Preferred Inputs + +Use these sources in order of preference: + +1. explicit `.tsx` type declarations +2. standard JSDoc, TSDoc, and TypeDoc tags +3. `.jsx` typedef blocks + +There are no custom schema tags in the extractor. Model complex shapes with named local interfaces and type aliases. + +## Standard Mapping + +| Authoring input | Generated schema | +| --------------------------------------------- | -------------------------------------------- | +| exported function name | catalog component key | +| property summary text | `description` | +| `@remarks` | appended to `description` | +| `@defaultValue` / `@default` | `default` | +| `@deprecated` | `deprecated: true` | +| optional property | omitted from `required` | +| string literal union | `type: "string"` plus `enum` | +| `T[]` or `Array` | `type: "array"` plus `items` | +| `Record` or string index signature | `type: "object"` plus `additionalProperties` | +| `A \| B` union of distinct shapes | `oneOf` with one entry per union branch | + +## Supported Explicit Type Shapes + +The extractor intentionally stays checker-free. It supports: + +- primitives +- string literal unions +- arrays +- object type literals +- interfaces +- local type aliases +- optional properties +- string-indexed maps +- unions such as `string | { path: string }` + +The extractor does not depend on full type resolution across complex generic graphs. When a shape gets complicated, keep it local and explicit so the parser can read it directly. + +## JSX Best-Effort Rules + +`.jsx` support is based on JSDoc typedef parsing. + +Recommended pattern: + +```jsx +/** + * @typedef {object} BadgeProps + * @property {string | { path: string }} text Literal badge text. + * @property {'info' | 'warning'} [tone] Badge tone. + */ + +/** + * @param {BadgeProps} props + */ +export function Badge(props) { + return props; +} +``` + +Use `.tsx` for reliable parity when a component has nested unions, index signatures, or complex object graphs. + +## A2UI-Specific Notes + +- Framework-only props are filtered out by name. +- Legacy shard mode emits `{ [ComponentName]: ComponentSchema }`. +- Full catalog mode emits a catalog object with `components` and optional root metadata supplied through the extractor options. +- The current A2UI ReactLynx catalogs are generated entirely from explicit local declarations plus standard documentation tags. 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..fa61bce3e4 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/rslib.config.ts @@ -0,0 +1,24 @@ +// 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: { 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..e158ac3f41 --- /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: '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 new file mode 100644 index 0000000000..85d8e436ac --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/src/cli.ts @@ -0,0 +1,127 @@ +#!/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 process from 'node:process'; +import { parseArgs } from 'node:util'; + +import { + checkCatalogFiles, + extractCatalog, + writeCatalogFiles, +} from './extractor.ts'; +import type { CatalogFormat, ExtractCatalogOptions } from './types.ts'; + +function printUsage(): void { + console.error( + [ + 'Usage:', + ' a2ui-catalog-extractor generate --source --out [--tsconfig ] [--format legacy-shards|a2ui-catalog]', + ' a2ui-catalog-extractor check --source --out [--tsconfig ] [--format legacy-shards|a2ui-catalog]', + ].join('\n'), + ); +} + +function getCommand(): string | undefined { + return process.argv[2]; +} + +function isCatalogFormat(value: string): value is CatalogFormat { + return value === 'legacy-shards' || value === 'a2ui-catalog'; +} + +function parseCatalogFormat(rawFormat: string | undefined): CatalogFormat { + if (!rawFormat) { + return 'legacy-shards'; + } + + if (isCatalogFormat(rawFormat)) { + return rawFormat; + } + + throw new Error( + `Unsupported --format "${rawFormat}". Expected "legacy-shards" or "a2ui-catalog".`, + ); +} + +function parseCatalogOptions(args: readonly string[]): { + extractOptions: ExtractCatalogOptions; + outDir: string; +} { + const { values } = parseArgs({ + args: [...args], + options: { + 'catalog-id': { type: 'string' }, + component: { multiple: true, type: 'string' }, + description: { type: 'string' }, + format: { type: 'string' }, + out: { type: 'string' }, + schema: { type: 'string' }, + source: { type: 'string' }, + title: { type: 'string' }, + tsconfig: { type: 'string' }, + }, + strict: true, + }); + + if (!values['source'] || !values['out']) { + throw new Error('Both --source and --out are required.'); + } + + const extractOptions: ExtractCatalogOptions = { + format: parseCatalogFormat(values['format']), + sourceDir: values['source'], + }; + if (values['catalog-id']) extractOptions.catalogId = values['catalog-id']; + if (values['component']) extractOptions.components = values['component']; + if (values['description']) extractOptions.description = values['description']; + if (values['schema']) extractOptions.schema = values['schema']; + if (values['title']) extractOptions.title = values['title']; + if (values['tsconfig']) extractOptions.tsconfigPath = values['tsconfig']; + + return { + extractOptions, + outDir: values['out'], + }; +} + +async function main(): Promise { + const command = getCommand(); + if (!command || (command !== 'generate' && command !== 'check')) { + printUsage(); + return 1; + } + + const { extractOptions, outDir } = parseCatalogOptions(process.argv.slice(3)); + const result = await extractCatalog(extractOptions); + + if (command === 'generate') { + const files = await writeCatalogFiles(result, { outDir }); + for (const file of files) { + console.info(`wrote ${file.path}`); + } + return 0; + } + + const checkResult = await checkCatalogFiles(result, { outDir }); + if (!checkResult.ok) { + for (const file of checkResult.missing) { + console.error(`missing ${file}`); + } + for (const file of checkResult.mismatched) { + console.error(`mismatch ${file}`); + } + return 1; + } + + console.info('catalog output is up to date'); + return 0; +} + +try { + process.exitCode = await main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} diff --git a/packages/genui/a2ui-catalog-extractor/src/docs.ts b/packages/genui/a2ui-catalog-extractor/src/docs.ts new file mode 100644 index 0000000000..414dc28ccb --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/src/docs.ts @@ -0,0 +1,299 @@ +// 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 path from 'node:path'; + +import { + Application, + Comment, + type Comment as TypeDocComment, + type DeclarationReflection, + OptionDefaults, + ReflectionKind, + TSConfigReader, + TypeDocReader, + type TypeDocOptions, +} from 'typedoc'; + +import type { + JsDocTypedef, + JsonValue, + PropertyDoc, + TypeDocIndex, + TypeDocRecord, +} from './types.ts'; + +function normalizeWhitespace(value: string): string { + return value.replace(/\s+/gu, ' ').trim(); +} + +function stripDocCodeFences(value: string): string { + const trimmed = value.trim(); + const fencedMatch = trimmed.match(/^```[A-Za-z0-9_-]*\n([\s\S]*?)\n```$/u); + if (fencedMatch?.[1]) { + return fencedMatch[1].trim(); + } + + if (trimmed.startsWith('`') && trimmed.endsWith('`') && trimmed.length >= 2) { + return trimmed.slice(1, -1).trim(); + } + + return trimmed; +} + +function combineCommentSections(comment?: TypeDocComment): string | undefined { + if (!comment) return undefined; + + const summary = normalizeWhitespace( + Comment.combineDisplayParts(comment.summary), + ); + const remarks = normalizeWhitespace( + Comment.combineDisplayParts(comment.getTag?.('@remarks')?.content), + ); + + if (summary && remarks) { + return `${summary}\n\n${remarks}`; + } + + return summary || remarks || undefined; +} + +function parseScalarToken( + value: string, + context: string, +): JsonValue | undefined { + const trimmed = stripDocCodeFences(value); + if (!trimmed) return undefined; + + if (trimmed === 'null') return null; + if (trimmed === 'true') return true; + if (trimmed === 'false') return false; + if (trimmed === 'undefined') { + throw new Error( + `The literal "undefined" is not supported in ${context}. Omit the tag instead.`, + ); + } + + if (/^-?\d+(\.\d+)?$/u.test(trimmed)) { + return Number(trimmed); + } + + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) + || (trimmed.startsWith('[') && trimmed.endsWith(']')) + || (trimmed.startsWith('{') && trimmed.endsWith('}')) + ) { + try { + return JSON.parse(trimmed) as JsonValue; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to parse ${context} value ${trimmed}: ${message}`, + ); + } + } + + if (trimmed.startsWith('\'') && trimmed.endsWith('\'')) { + return trimmed.slice(1, -1); + } + + return trimmed; +} + +function buildPropertyDoc(reflection: DeclarationReflection): PropertyDoc { + const defaultValueTag = reflection.comment?.getTag('@defaultValue'); + const defaultTag = defaultValueTag ?? reflection.comment?.getTag('@default'); + + const doc: PropertyDoc = {}; + const defaultValue = defaultTag + ? parseScalarToken( + Comment.combineDisplayParts(defaultTag.content), + defaultValueTag ? '@defaultValue' : '@default', + ) + : undefined; + const description = combineCommentSections(reflection.comment); + + if (defaultValue !== undefined) { + doc.defaultValue = defaultValue; + } + if (reflection.comment?.getTag('@deprecated')) { + doc.deprecated = true; + } + if (description) { + doc.description = description; + } + + return doc; +} + +function buildTypeRecord( + reflection: DeclarationReflection, + children: readonly DeclarationReflection[], +): TypeDocRecord { + const properties = new Map(); + + for (const child of children) { + properties.set(child.name, buildPropertyDoc(child)); + } + + const record: TypeDocRecord = { + properties, + }; + const description = combineCommentSections(reflection.comment); + if (description) { + record.description = description; + } + return record; +} + +function getPrimarySourceFile( + reflection: DeclarationReflection, +): string | undefined { + return reflection.sources?.[0]?.fullFileName; +} + +function getTypeDocKey(filePath: string, name: string): string { + return `${path.resolve(filePath)}::${name}`; +} + +function getTypeAliasChildren( + reflection: DeclarationReflection, +): DeclarationReflection[] { + const declarationLike = (reflection.type as { + declaration?: DeclarationReflection; + } | undefined)?.declaration; + return declarationLike?.children ?? []; +} + +export async function buildTypeDocIndex( + entryPoints: readonly string[], + tsconfigPath?: string, +): Promise { + const bootstrapOptions: TypeDocOptions = { + commentStyle: 'all', + emit: 'none', + entryPoints: [...entryPoints], + excludeTags: [...OptionDefaults.excludeTags], + inlineTags: [...OptionDefaults.inlineTags], + modifierTags: [...OptionDefaults.modifierTags], + plugin: [], + readme: 'none', + skipErrorChecking: true, + }; + if (tsconfigPath) { + Object.assign(bootstrapOptions, { tsconfig: tsconfigPath }); + } + + const app = await Application.bootstrap( + bootstrapOptions, + [new TSConfigReader(), new TypeDocReader()], + ); + + const project = await app.convert(); + if (!project) { + throw new Error('TypeDoc could not convert the provided entry points.'); + } + + const index: TypeDocIndex = { + types: new Map(), + }; + + for ( + const reflection of project.getReflectionsByKind( + ReflectionKind.Interface, + ) as DeclarationReflection[] + ) { + const sourceFile = getPrimarySourceFile(reflection); + if (!sourceFile) continue; + index.types.set( + getTypeDocKey(sourceFile, reflection.name), + buildTypeRecord(reflection, reflection.children ?? []), + ); + } + + for ( + const reflection of project.getReflectionsByKind( + ReflectionKind.TypeAlias, + ) as DeclarationReflection[] + ) { + const sourceFile = getPrimarySourceFile(reflection); + if (!sourceFile) continue; + index.types.set( + getTypeDocKey(sourceFile, reflection.name), + buildTypeRecord(reflection, getTypeAliasChildren(reflection)), + ); + } + + return index; +} + +export function getTypeDocRecord( + index: TypeDocIndex, + filePath: string, + typeName: string, +): TypeDocRecord | undefined { + return index.types.get(getTypeDocKey(filePath, typeName)); +} + +export function parseJsDocTypedefs( + sourceText: string, +): Map { + const typedefs = new Map(); + const blockPattern = /\/\*\*([\s\S]*?)\*\//gu; + + for (const match of sourceText.matchAll(blockPattern)) { + const block = match[1] ?? ''; + const lines = block + .split('\n') + .map(line => line.replace(/^\s*\*\s?/u, '').trim()) + .filter(Boolean); + + const typedefLine = lines.find(line => line.startsWith('@typedef ')); + if (!typedefLine) continue; + + const typedefMatch = typedefLine.match( + /^@typedef\s+\{(?.+)\}\s+(?[A-Za-z_$][\w$]*)(?:\s+(?.+))?$/u, + ); + if (!typedefMatch?.groups) continue; + + const typedef: JsDocTypedef = { + name: typedefMatch.groups['name']!, + properties: [], + }; + const typedefDescription = typedefMatch.groups['description']?.trim(); + const typedefTypeExpression = typedefMatch.groups['type']?.trim(); + if (typedefDescription) { + typedef.description = typedefDescription; + } + if (typedefTypeExpression) { + typedef.typeExpression = typedefTypeExpression; + } + + for (const line of lines) { + if (!line.startsWith('@property ')) continue; + const propertyMatch = line.match( + /^@property\s+\{(?.+)\}\s+(?\[[^\]]+\]|[A-Za-z_$][\w$]*)(?:\s+(?.+))?$/u, + ); + if (!propertyMatch?.groups) continue; + + const rawName = propertyMatch.groups['name']!; + const optional = rawName.startsWith('[') && rawName.endsWith(']'); + const propertyName = optional ? rawName.slice(1, -1) : rawName; + + const property: JsDocTypedef['properties'][number] = { + name: propertyName, + optional, + typeExpression: propertyMatch.groups['type']!.trim(), + }; + const propertyDescription = propertyMatch.groups['description']?.trim(); + if (propertyDescription) { + property.description = propertyDescription; + } + typedef.properties.push(property); + } + + typedefs.set(typedef.name, typedef); + } + + return typedefs; +} diff --git a/packages/genui/a2ui-catalog-extractor/src/extractor.ts b/packages/genui/a2ui-catalog-extractor/src/extractor.ts new file mode 100644 index 0000000000..1115e51a1b --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/src/extractor.ts @@ -0,0 +1,1017 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import * as ts from 'typescript'; + +import { + buildTypeDocIndex, + getTypeDocRecord, + parseJsDocTypedefs, +} from './docs.ts'; +import { + GENERIC_PROPS, + type CatalogComponent, + type CatalogFile, + type CheckCatalogFilesResult, + type ComponentSchema, + type ExtractCatalogOptions, + type ExtractCatalogResult, + type JsDocTypedef, + type JsonSchema, + type JsonValue, + type LoadCatalogConfigResult, + type PropertyDoc, + type RenderCatalogFilesOptions, + type TypeDocIndex, + type TypeDocRecord, +} from './types.ts'; + +interface SourceContext { + filePath: string; + jsDocTypedefs: Map; + localDeclarations: Map; + scriptKind: ts.ScriptKind; + sourceFile: ts.SourceFile; + typeDocIndex: TypeDocIndex; +} + +type LocalDeclaration = + | { + kind: 'interface'; + node: ts.InterfaceDeclaration; + } + | { + kind: 'typeAlias'; + node: ts.TypeAliasDeclaration; + } + | { + kind: 'typedef'; + typedef: JsDocTypedef; + }; + +type TypeLikeNode = ts.TypeNode; + +interface ParseState { + seen: Set; +} + +interface PropertyDefinition { + doc?: PropertyDoc; + name: string; + optional: boolean; + typeNode: TypeLikeNode; +} + +interface CollectedTypeElements { + additionalProperties?: JsonSchema | boolean; + properties: PropertyDefinition[]; +} + +function inferScriptKind(filePath: string): ts.ScriptKind { + if (filePath.endsWith('.tsx')) return ts.ScriptKind.TSX; + if (filePath.endsWith('.ts')) return ts.ScriptKind.TS; + if (filePath.endsWith('.jsx')) return ts.ScriptKind.JSX; + return ts.ScriptKind.JS; +} + +function isBestEffortScriptKind(scriptKind: ts.ScriptKind): boolean { + return scriptKind === ts.ScriptKind.JS || scriptKind === ts.ScriptKind.JSX; +} + +function normalizeLineEndings(value: string): string { + return value.replace(/\r\n/gu, '\n'); +} + +function hasExportModifier(node: ts.Node): boolean { + return Boolean( + ts.canHaveModifiers(node) + && ts.getModifiers(node)?.some(modifier => + modifier.kind === ts.SyntaxKind.ExportKeyword + ), + ); +} + +function getComponentDeclarations(sourceFile: ts.SourceFile): { + name: string; + parameter: ts.ParameterDeclaration; +}[] { + const components: { + name: string; + parameter: ts.ParameterDeclaration; + }[] = []; + + for (const statement of sourceFile.statements) { + if (ts.isExportAssignment(statement)) { + throw new Error( + `Unsupported component export in ${sourceFile.fileName}: default export assignments are not supported. Use a direct exported function or const declaration instead.`, + ); + } + + if (ts.isExportDeclaration(statement)) { + throw new Error( + `Unsupported component export in ${sourceFile.fileName}: re-exports like "${ + statement.getText(sourceFile) + }" are not supported. Use a direct exported function or const declaration instead.`, + ); + } + + if ( + ts.isFunctionDeclaration(statement) + && hasExportModifier(statement) + && statement.name + && statement.parameters.length > 0 + ) { + components.push({ + name: statement.name.text, + parameter: statement.parameters[0]!, + }); + continue; + } + + if (!ts.isVariableStatement(statement) || !hasExportModifier(statement)) { + continue; + } + + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name)) continue; + if ( + !declaration.initializer + || ( + !ts.isArrowFunction(declaration.initializer) + && !ts.isFunctionExpression(declaration.initializer) + ) + || declaration.initializer.parameters.length === 0 + ) { + continue; + } + + components.push({ + name: declaration.name.text, + parameter: declaration.initializer.parameters[0]!, + }); + } + } + + return components; +} + +function collectLocalDeclarations( + sourceFile: ts.SourceFile, + jsDocTypedefs: Map, +): Map { + const declarations = new Map(); + + for (const statement of sourceFile.statements) { + if (ts.isInterfaceDeclaration(statement)) { + declarations.set(statement.name.text, { + kind: 'interface', + node: statement, + }); + continue; + } + + if (ts.isTypeAliasDeclaration(statement)) { + declarations.set(statement.name.text, { + kind: 'typeAlias', + node: statement, + }); + } + } + + for (const [name, typedef] of jsDocTypedefs) { + declarations.set(name, { + kind: 'typedef', + typedef, + }); + } + + return declarations; +} + +function parseTypeExpressionString( + typeExpression: string, +): ts.TypeNode | undefined { + const sourceText = `type __A2UI = ${typeExpression};`; + const sourceFile = ts.createSourceFile( + '__a2ui_type__.ts', + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + + const statement = sourceFile.statements[0]; + if (!statement || !ts.isTypeAliasDeclaration(statement)) { + return undefined; + } + return statement.type; +} + +function getParameterTypeNode( + parameter: ts.ParameterDeclaration, +): TypeLikeNode | undefined { + return parameter.type ?? ts.getJSDocType(parameter); +} + +function isNullLikeTypeNode(typeNode: TypeLikeNode): boolean { + if (typeNode.kind === ts.SyntaxKind.NullKeyword) return true; + return ts.isLiteralTypeNode(typeNode) + && typeNode.literal.kind === ts.SyntaxKind.NullKeyword; +} + +function isUndefinedLikeTypeNode(typeNode: TypeLikeNode): boolean { + return typeNode.kind === ts.SyntaxKind.UndefinedKeyword; +} + +function isBooleanLiteralTypeNode(typeNode: TypeLikeNode): boolean { + return ts.isLiteralTypeNode(typeNode) + && ( + typeNode.literal.kind === ts.SyntaxKind.TrueKeyword + || typeNode.literal.kind === ts.SyntaxKind.FalseKeyword + ); +} + +function isStringLiteralTypeNode( + typeNode: TypeLikeNode, +): typeNode is ts.LiteralTypeNode { + return ts.isLiteralTypeNode(typeNode) + && ts.isStringLiteral(typeNode.literal); +} + +function isStringIndexKeyType(typeNode: TypeLikeNode): boolean { + return typeNode.kind === ts.SyntaxKind.StringKeyword + || isStringLiteralTypeNode(typeNode); +} + +function dedupeSchemas(schemas: JsonSchema[]): JsonSchema[] { + const deduped: JsonSchema[] = []; + + for (const schema of schemas) { + const serialized = JSON.stringify(schema); + if (deduped.some(existing => JSON.stringify(existing) === serialized)) { + continue; + } + deduped.push(schema); + } + + return deduped; +} + +function applyPropertyDoc(schema: JsonSchema, doc?: PropertyDoc): JsonSchema { + if (!doc) return schema; + + const next: JsonSchema = { ...schema }; + + if (doc.description) { + next.description = doc.description; + } + if (doc.defaultValue !== undefined) { + next.default = doc.defaultValue; + } + if (doc.deprecated) { + next.deprecated = true; + } + + return next; +} + +function buildObjectSchema( + properties: PropertyDefinition[], + context: SourceContext, + parseState: ParseState, + topLevel: boolean, + typeDocRecord?: TypeDocRecord, + explicitAdditionalProperties?: JsonSchema | boolean, +): JsonSchema | ComponentSchema { + const schema: JsonSchema = topLevel ? {} : { type: 'object' }; + const propertyMap: Record = {}; + const required: string[] = []; + const additionalProperties = explicitAdditionalProperties; + + for (const property of properties) { + if (GENERIC_PROPS.has(property.name)) continue; + + const doc = property.doc ?? typeDocRecord?.properties.get(property.name); + const propertySchema = applyPropertyDoc( + parseTypeNode(property.typeNode, context, parseState), + doc, + ); + + propertyMap[property.name] = propertySchema; + if (!property.optional) { + required.push(property.name); + } + } + + const propertyNames = Object.keys(propertyMap); + if (propertyNames.length > 0) { + schema.properties = propertyMap; + } else if (topLevel) { + schema.properties = {}; + } + + if (topLevel) { + return { + properties: schema.properties ?? {}, + required, + }; + } + + if (propertyNames.length === 0 && additionalProperties === undefined) { + return { type: 'object' }; + } + + if (propertyNames.length > 0) { + schema.required = required; + } else if (required.length > 0) { + schema.required = required; + } + + if (additionalProperties === undefined) { + if (propertyNames.length > 0) { + schema.additionalProperties = false; + } + } else { + schema.additionalProperties = additionalProperties; + } + + return schema; +} + +function getTypeReferenceName( + typeNode: ts.TypeReferenceNode, +): string | undefined { + if (ts.isIdentifier(typeNode.typeName)) { + return typeNode.typeName.text; + } + return undefined; +} + +function parseSyntheticTypedef( + typedef: JsDocTypedef, + context: SourceContext, + parseState: ParseState, + topLevel: boolean, +): JsonSchema | ComponentSchema { + if (typedef.properties.length === 0) { + if (!typedef.typeExpression || typedef.typeExpression === 'object') { + return topLevel + ? { properties: {}, required: [] } + : { type: 'object' }; + } + + const typeNode = parseTypeExpressionString(typedef.typeExpression); + if (!typeNode) { + throw new Error( + `Could not parse JSDoc typedef "${typedef.name}" in ${context.filePath}.`, + ); + } + + return parseTypeNode(typeNode, context, parseState); + } + + const properties: PropertyDefinition[] = []; + for (const property of typedef.properties) { + const typeNode = parseTypeExpressionString(property.typeExpression); + if (!typeNode) { + throw new Error( + `Could not parse JSDoc property "${typedef.name}.${property.name}" in ${context.filePath}.`, + ); + } + properties.push({ + name: property.name, + optional: property.optional, + typeNode, + }); + if (property.description) { + properties[properties.length - 1]!.doc = { + description: property.description, + }; + } + } + + return buildObjectSchema(properties, context, parseState, topLevel); +} + +function getPropertyName(member: ts.TypeElement): string | undefined { + if ( + (ts.isPropertySignature(member) || ts.isPropertyDeclaration(member)) + && member.name + ) { + if (ts.isIdentifier(member.name) || ts.isStringLiteral(member.name)) { + return member.name.text; + } + } + return undefined; +} + +function getPropertyTypeNode(member: ts.TypeElement): TypeLikeNode | undefined { + if (ts.isPropertySignature(member) || ts.isPropertyDeclaration(member)) { + return member.type ?? ts.getJSDocType(member); + } + return undefined; +} + +function isOptionalProperty( + member: ts.TypeElement, + typeNode: TypeLikeNode, +): boolean { + if ( + (ts.isPropertySignature(member) || ts.isPropertyDeclaration(member)) + && member.questionToken + ) { + return true; + } + + return ts.isUnionTypeNode(typeNode) + && typeNode.types.some(type => isUndefinedLikeTypeNode(type)); +} + +function collectInterfaceProperties( + declaration: ts.InterfaceDeclaration, + context: SourceContext, + parseState: ParseState, +): CollectedTypeElements { + const typeDocRecord = getTypeDocRecord( + context.typeDocIndex, + context.filePath, + declaration.name.text, + ); + + return collectTypeElementProperties( + declaration.members, + context, + parseState, + typeDocRecord, + ); +} + +function parseNamedDeclaration( + name: string, + declaration: LocalDeclaration, + context: SourceContext, + parseState: ParseState, + topLevel: boolean, +): JsonSchema | ComponentSchema { + const cacheKey = `${context.filePath}::${name}`; + if (parseState.seen.has(cacheKey)) { + return topLevel ? { properties: {}, required: [] } : { type: 'object' }; + } + + parseState.seen.add(cacheKey); + try { + switch (declaration.kind) { + case 'interface': { + const typeDocRecord = getTypeDocRecord( + context.typeDocIndex, + context.filePath, + declaration.node.name.text, + ); + const collected = collectInterfaceProperties( + declaration.node, + context, + parseState, + ); + return buildObjectSchema( + collected.properties, + context, + parseState, + topLevel, + typeDocRecord, + collected.additionalProperties, + ); + } + + case 'typeAlias': { + if (ts.isTypeLiteralNode(declaration.node.type)) { + const typeDocRecord = getTypeDocRecord( + context.typeDocIndex, + context.filePath, + declaration.node.name.text, + ); + + const properties: PropertyDefinition[] = []; + const collected = collectTypeElementProperties( + declaration.node.type.members, + context, + parseState, + typeDocRecord, + ); + properties.push(...collected.properties); + + return buildObjectSchema( + properties, + context, + parseState, + topLevel, + typeDocRecord, + collected.additionalProperties, + ); + } + + return parseTypeNode(declaration.node.type, context, parseState); + } + + case 'typedef': + return parseSyntheticTypedef( + declaration.typedef, + context, + parseState, + topLevel, + ); + } + } finally { + parseState.seen.delete(cacheKey); + } +} + +function parseRecordTypeReference( + typeNode: ts.TypeReferenceNode, + context: SourceContext, + parseState: ParseState, +): JsonSchema | undefined { + const typeName = getTypeReferenceName(typeNode); + const typeArguments = typeNode.typeArguments; + + if ( + (typeName === 'Array' || typeName === 'ReadonlyArray') + && typeArguments?.[0] + ) { + return { + type: 'array', + items: parseTypeNode(typeArguments[0], context, parseState), + }; + } + + if ( + typeName === 'Record' + && typeArguments?.[0] + && typeArguments[1] + && isStringIndexKeyType(typeArguments[0]) + ) { + return { + type: 'object', + additionalProperties: parseTypeNode( + typeArguments[1], + context, + parseState, + ), + }; + } + + return undefined; +} + +function parseTypeNode( + typeNode: TypeLikeNode, + context: SourceContext, + parseState: ParseState, +): JsonSchema { + if (ts.isParenthesizedTypeNode(typeNode)) { + return parseTypeNode(typeNode.type, context, parseState); + } + + if (typeNode.kind === ts.SyntaxKind.StringKeyword) { + return { type: 'string' }; + } + if (typeNode.kind === ts.SyntaxKind.NumberKeyword) { + return { type: 'number' }; + } + if (typeNode.kind === ts.SyntaxKind.BooleanKeyword) { + return { type: 'boolean' }; + } + + if (ts.isLiteralTypeNode(typeNode)) { + if (ts.isStringLiteral(typeNode.literal)) { + return { type: 'string' }; + } + if (ts.isNumericLiteral(typeNode.literal)) { + return { type: 'number' }; + } + if ( + typeNode.literal.kind === ts.SyntaxKind.TrueKeyword + || typeNode.literal.kind === ts.SyntaxKind.FalseKeyword + ) { + return { type: 'boolean' }; + } + } + + if (ts.isUnionTypeNode(typeNode)) { + const actualTypes = typeNode.types.filter(type => + !isNullLikeTypeNode(type) && !isUndefinedLikeTypeNode(type) + ); + + if ( + actualTypes.length === 2 + && actualTypes.every(type => isBooleanLiteralTypeNode(type)) + ) { + return { type: 'boolean' }; + } + + if (actualTypes.length === 1) { + return parseTypeNode(actualTypes[0]!, context, parseState); + } + + if ( + actualTypes.length > 0 + && actualTypes.every(type => isStringLiteralTypeNode(type)) + ) { + return { + type: 'string', + enum: actualTypes.map(type => (type.literal as ts.StringLiteral).text), + }; + } + + const schemas = dedupeSchemas( + actualTypes.map(type => parseTypeNode(type, context, parseState)), + ); + if (schemas.length === 1) { + return schemas[0]!; + } + + return { oneOf: schemas }; + } + + if (ts.isArrayTypeNode(typeNode)) { + return { + type: 'array', + items: parseTypeNode(typeNode.elementType, context, parseState), + }; + } + + if (ts.isTypeLiteralNode(typeNode)) { + const properties: PropertyDefinition[] = []; + const collected = collectTypeElementProperties( + typeNode.members, + context, + parseState, + ); + properties.push(...collected.properties); + return buildObjectSchema( + properties, + context, + parseState, + false, + undefined, + collected.additionalProperties, + ) as JsonSchema; + } + + if (ts.isTypeReferenceNode(typeNode)) { + const recordSchema = parseRecordTypeReference( + typeNode, + context, + parseState, + ); + if (recordSchema) return recordSchema; + + const typeName = getTypeReferenceName(typeNode); + if (typeName) { + const declaration = context.localDeclarations.get(typeName); + if (declaration) { + return parseNamedDeclaration( + typeName, + declaration, + context, + parseState, + false, + ) as JsonSchema; + } + } + } + + if (isBestEffortScriptKind(context.scriptKind)) { + return { type: 'string' }; + } + + throw new Error( + `Unsupported type "${ + typeNode.getText(context.sourceFile) + }" in ${context.filePath}. Use explicit local declarations that the extractor supports.`, + ); +} + +async function loadSourceContext( + filePath: string, + typeDocIndex: TypeDocIndex, +): Promise { + const text = await fs.readFile(filePath, 'utf8'); + const scriptKind = inferScriptKind(filePath); + const sourceFile = ts.createSourceFile( + filePath, + text, + ts.ScriptTarget.Latest, + true, + scriptKind, + ); + const jsDocTypedefs = parseJsDocTypedefs(text); + + return { + filePath, + jsDocTypedefs, + localDeclarations: collectLocalDeclarations(sourceFile, jsDocTypedefs), + scriptKind, + sourceFile, + typeDocIndex, + }; +} + +async function getComponentEntryFiles(sourceDir: string): Promise { + const directoryEntries = await fs.readdir(sourceDir, { withFileTypes: true }); + const entryFiles: string[] = []; + const indexCandidates = ['index.tsx', 'index.jsx', 'index.ts', 'index.js']; + + for ( + const entry of directoryEntries.sort((left, right) => + left.name.localeCompare(right.name) + ) + ) { + if (!entry.isDirectory()) continue; + + for (const candidate of indexCandidates) { + const candidatePath = path.join(sourceDir, entry.name, candidate); + try { + const stats = await fs.stat(candidatePath); + if (stats.isFile()) { + entryFiles.push(candidatePath); + break; + } + } catch { + // Ignore missing entry candidates. + } + } + } + + return entryFiles; +} + +function buildLegacyCatalog( + component: CatalogComponent, +): Record { + return { + [component.name]: component.schema as unknown as JsonValue, + }; +} + +function collectTypeElementProperties( + members: readonly ts.TypeElement[], + context: SourceContext, + parseState: ParseState, + typeDocRecord?: TypeDocRecord, +): CollectedTypeElements { + const properties: PropertyDefinition[] = []; + let additionalProperties: JsonSchema | boolean | undefined; + + for (const member of members) { + if (ts.isIndexSignatureDeclaration(member)) { + const parameter = member.parameters[0]; + if ( + parameter?.type + && isStringIndexKeyType(parameter.type) + && member.type + ) { + additionalProperties = parseTypeNode(member.type, context, parseState); + } + continue; + } + + const name = getPropertyName(member); + const typeNode = getPropertyTypeNode(member); + if (!name || !typeNode) continue; + + const property: PropertyDefinition = { + name, + optional: isOptionalProperty(member, typeNode), + typeNode, + }; + const doc = typeDocRecord?.properties.get(name); + if (doc) { + property.doc = doc; + } + properties.push(property); + } + + const collected: CollectedTypeElements = { + properties, + }; + if (additionalProperties !== undefined) { + collected.additionalProperties = additionalProperties; + } + return collected; +} + +function buildFullCatalog( + components: readonly CatalogComponent[], + options: ExtractCatalogOptions, +): Record { + const componentMap: Record = {}; + for (const component of components) { + componentMap[component.name] = component.schema as unknown as JsonValue; + } + + const catalog: Record = {}; + if (options.schema) catalog['$schema'] = options.schema; + if (options.catalogId) catalog['catalogId'] = options.catalogId; + if (options.title) catalog['title'] = options.title; + if (options.description) catalog['description'] = options.description; + catalog['components'] = componentMap; + if (options.functions) { + catalog['functions'] = options.functions as unknown as JsonValue; + } + if (options.theme) { + catalog['theme'] = options.theme; + } + return catalog; +} + +export async function extractCatalog( + options: ExtractCatalogOptions, +): Promise { + const format = options.format ?? 'legacy-shards'; + const sourceDir = path.resolve(options.sourceDir); + const entryFiles = await getComponentEntryFiles(sourceDir); + const typeDocIndex = await buildTypeDocIndex( + entryFiles, + options.tsconfigPath ? path.resolve(options.tsconfigPath) : undefined, + ); + const componentFilter = options.components + ? new Set(options.components) + : undefined; + const components: CatalogComponent[] = []; + + for (const entryFile of entryFiles) { + const context = await loadSourceContext(entryFile, typeDocIndex); + for (const component of getComponentDeclarations(context.sourceFile)) { + if (componentFilter && !componentFilter.has(component.name)) { + continue; + } + + const typeNode = getParameterTypeNode(component.parameter); + if (!typeNode) { + throw new Error( + `Component "${component.name}" in ${entryFile} does not declare props.`, + ); + } + + let schema: ComponentSchema; + if (ts.isTypeReferenceNode(typeNode)) { + const typeName = getTypeReferenceName(typeNode); + const declaration = typeName + ? context.localDeclarations.get(typeName) + : undefined; + if (!typeName || !declaration) { + throw new Error( + `Component "${component.name}" in ${entryFile} uses unsupported props type "${ + typeNode.getText(context.sourceFile) + }".`, + ); + } + schema = parseNamedDeclaration( + typeName, + declaration, + context, + { seen: new Set() }, + true, + ) as ComponentSchema; + } else if (ts.isTypeLiteralNode(typeNode)) { + const properties: PropertyDefinition[] = []; + for (const member of typeNode.members) { + const propertyName = getPropertyName(member); + const propertyType = getPropertyTypeNode(member); + if (!propertyName || !propertyType) continue; + properties.push({ + name: propertyName, + optional: isOptionalProperty(member, propertyType), + typeNode: propertyType, + }); + } + schema = buildObjectSchema( + properties, + context, + { seen: new Set() }, + true, + ) as ComponentSchema; + } else { + throw new Error( + `Component "${component.name}" in ${entryFile} must use an interface, type alias, or object literal for props.`, + ); + } + + components.push({ + entryFile, + name: component.name, + schema, + }); + } + } + + components.sort((left, right) => left.name.localeCompare(right.name)); + + const result: ExtractCatalogResult = { + components, + format, + }; + + if (format === 'a2ui-catalog') { + result.catalog = buildFullCatalog(components, options); + } + + return result; +} + +export function renderCatalogFiles( + result: ExtractCatalogResult, + options: RenderCatalogFilesOptions, +): CatalogFile[] { + const outDir = path.resolve(options.outDir); + + if (result.format === 'a2ui-catalog') { + return [{ + content: `${JSON.stringify(result.catalog, null, 2)}\n`, + path: path.join(outDir, 'catalog.json'), + }]; + } + + return result.components.map(component => ({ + content: `${JSON.stringify(buildLegacyCatalog(component), null, 2)}\n`, + path: path.join(outDir, component.name, 'catalog.json'), + })); +} + +export async function writeCatalogFiles( + result: ExtractCatalogResult, + options: RenderCatalogFilesOptions, +): Promise { + const files = renderCatalogFiles(result, options); + + await Promise.all(files.map(async (file) => { + await fs.mkdir(path.dirname(file.path), { recursive: true }); + await fs.writeFile(file.path, file.content, 'utf8'); + })); + + return files; +} + +export async function checkCatalogFiles( + result: ExtractCatalogResult, + options: RenderCatalogFilesOptions, +): Promise { + const files = renderCatalogFiles(result, options); + const missing: string[] = []; + const mismatched: string[] = []; + let actual: string | undefined; + let expected: string | undefined; + + for (const file of files) { + try { + const fileContent = normalizeLineEndings( + await fs.readFile(file.path, 'utf8'), + ); + const expectedContent = normalizeLineEndings(file.content); + if (fileContent !== expectedContent) { + mismatched.push(file.path); + actual ??= fileContent; + expected ??= expectedContent; + } + } catch { + missing.push(file.path); + } + } + + const checkResult: CheckCatalogFilesResult = { + mismatched, + missing, + ok: missing.length === 0 && mismatched.length === 0, + }; + if (actual !== undefined) { + checkResult.actual = actual; + } + if (expected !== undefined) { + checkResult.expected = expected; + } + return checkResult; +} + +export async function loadCatalogConfig( + filePath: string, +): Promise { + const resolvedPath = path.resolve(filePath); + const extension = path.extname(resolvedPath); + + const config = extension === '.json' + ? JSON.parse(await fs.readFile(resolvedPath, 'utf8')) as Record< + string, + JsonValue + > + : await import(pathToFileURL(resolvedPath).href) + .then(module => (module.default ?? module) as Record); + + return { + config, + path: resolvedPath, + }; +} 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..861a4dc569 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/src/index.ts @@ -0,0 +1,24 @@ +// 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. +export { + checkCatalogFiles, + extractCatalog, + loadCatalogConfig, + renderCatalogFiles, + writeCatalogFiles, +} from './extractor.ts'; + +export type { + CatalogComponent, + CatalogFile, + CatalogFormat, + CheckCatalogFilesResult, + ComponentSchema, + ExtractCatalogOptions, + ExtractCatalogResult, + JsonSchema, + JsonValue, + LoadCatalogConfigResult, + RenderCatalogFilesOptions, +} from './types.ts'; diff --git a/packages/genui/a2ui-catalog-extractor/src/types.ts b/packages/genui/a2ui-catalog-extractor/src/types.ts new file mode 100644 index 0000000000..8126bc5784 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/src/types.ts @@ -0,0 +1,114 @@ +// 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. +export const GENERIC_PROPS: ReadonlySet = new Set([ + 'id', + 'surface', + 'setValue', + 'sendAction', + 'dataContextPath', + '__template', + 'component', +]); + +export type JsonPrimitive = boolean | number | string | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { + [key: string]: JsonValue; +}; + +export interface JsonSchema { + additionalProperties?: boolean | JsonSchema; + const?: JsonValue; + default?: JsonValue; + deprecated?: boolean; + description?: string; + enum?: JsonPrimitive[]; + items?: JsonSchema; + oneOf?: JsonSchema[]; + properties?: Record; + required?: string[]; + type?: 'array' | 'boolean' | 'number' | 'object' | 'string'; +} + +export interface ComponentSchema { + properties: Record; + required: string[]; +} + +export interface CatalogComponent { + entryFile: string; + name: string; + schema: ComponentSchema; +} + +export type CatalogFormat = 'a2ui-catalog' | 'legacy-shards'; + +export interface ExtractCatalogOptions { + catalogId?: string; + components?: string[]; + description?: string; + format?: CatalogFormat; + functions?: Record; + schema?: string; + sourceDir: string; + theme?: Record; + title?: string; + tsconfigPath?: string; +} + +export interface ExtractCatalogResult { + catalog?: Record; + components: CatalogComponent[]; + format: CatalogFormat; +} + +export interface CatalogFile { + content: string; + path: string; +} + +export interface RenderCatalogFilesOptions { + outDir: string; +} + +export interface CheckCatalogFilesResult { + actual?: string; + expected?: string; + missing: string[]; + mismatched: string[]; + ok: boolean; +} + +export interface LoadCatalogConfigResult { + config: Record; + path: string; +} + +export interface PropertyDoc { + defaultValue?: JsonValue; + deprecated?: boolean; + description?: string; +} + +export interface TypeDocRecord { + description?: string; + properties: Map; +} + +export interface TypeDocIndex { + types: Map; +} + +export interface JsDocTypedefProperty { + description?: string; + name: string; + optional: boolean; + typeExpression: string; +} + +export interface JsDocTypedef { + description?: string; + name: string; + properties: JsDocTypedefProperty[]; + typeExpression?: string; +} diff --git a/packages/genui/a2ui-catalog-extractor/test/cli.test.ts b/packages/genui/a2ui-catalog-extractor/test/cli.test.ts new file mode 100644 index 0000000000..2477e5a664 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/cli.test.ts @@ -0,0 +1,142 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { execFile as execFileCallback } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +import { describe, expect, test } from '@rstest/core'; + +const execFile = promisify(execFileCallback); +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +function fixturePath(...segments: string[]): string { + return path.join(__dirname, 'fixtures', ...segments); +} + +function runCli(args: readonly string[]) { + return execFile(process.execPath, [ + '--experimental-strip-types', + path.join(__dirname, '..', 'src', 'cli.ts'), + ...args, + ], { + cwd: path.join(__dirname, '..'), + }); +} + +async function withTempDir( + prefix: string, + callback: (directory: string) => Promise, +): Promise { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + try { + return await callback(directory); + } finally { + await fs.rm(directory, { force: true, recursive: true }); + } +} + +describe('cli', () => { + test('generate writes catalog shards to disk', async () => { + await withTempDir('a2ui-catalog-cli-generate-', async (outputDir) => { + const { stdout } = await runCli([ + 'generate', + '--source', + fixturePath('tsx', 'catalog'), + '--out', + outputDir, + '--tsconfig', + fixturePath('tsx', 'tsconfig.json'), + ]); + + expect(stdout).toContain('wrote'); + const generated = await fs.readFile( + path.join(outputDir, 'Chip', 'catalog.json'), + 'utf8', + ); + expect(JSON.parse(generated)).toHaveProperty('Chip'); + }); + }); + + test('check exits cleanly when output is current', async () => { + await withTempDir('a2ui-catalog-cli-check-', async (outputDir) => { + await runCli([ + 'generate', + '--source', + fixturePath('tsx', 'catalog'), + '--out', + outputDir, + '--tsconfig', + fixturePath('tsx', 'tsconfig.json'), + ]); + + const { stdout } = await runCli([ + 'check', + '--source', + fixturePath('tsx', 'catalog'), + '--out', + outputDir, + '--tsconfig', + fixturePath('tsx', 'tsconfig.json'), + ]); + + expect(stdout).toContain('catalog output is up to date'); + }); + }); + + test('check fails when output has drifted', async () => { + await withTempDir('a2ui-catalog-cli-drift-', async (outputDir) => { + await runCli([ + 'generate', + '--source', + fixturePath('tsx', 'catalog'), + '--out', + outputDir, + '--tsconfig', + fixturePath('tsx', 'tsconfig.json'), + ]); + + await fs.writeFile( + path.join(outputDir, 'Chip', 'catalog.json'), + '{}\n', + 'utf8', + ); + + await expect(runCli([ + 'check', + '--source', + fixturePath('tsx', 'catalog'), + '--out', + outputDir, + '--tsconfig', + fixturePath('tsx', 'tsconfig.json'), + ])).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining('mismatch'), + }); + }); + }); + + test('generate rejects unsupported formats', async () => { + await withTempDir('a2ui-catalog-cli-format-', async (outputDir) => { + await expect(runCli([ + 'generate', + '--source', + fixturePath('tsx', 'catalog'), + '--out', + outputDir, + '--tsconfig', + fixturePath('tsx', 'tsconfig.json'), + '--format', + 'bogus', + ])).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining('Unsupported --format "bogus"'), + }); + }); + }); +}); diff --git a/packages/genui/a2ui-catalog-extractor/test/extract-catalog.test.ts b/packages/genui/a2ui-catalog-extractor/test/extract-catalog.test.ts new file mode 100644 index 0000000000..40b2611a70 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/extract-catalog.test.ts @@ -0,0 +1,320 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, test } from '@rstest/core'; + +import { + checkCatalogFiles, + extractCatalog, + renderCatalogFiles, + writeCatalogFiles, +} from '../src/index.ts'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const workspaceRoot = path.resolve(__dirname, '..', '..', '..', '..'); + +function fixturePath(...segments: string[]): string { + return path.join(__dirname, 'fixtures', ...segments); +} + +async function withTempDir( + prefix: string, + callback: (directory: string) => Promise, +): Promise { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + try { + return await callback(directory); + } finally { + await fs.rm(directory, { force: true, recursive: true }); + } +} + +describe('extractCatalog', () => { + test('extracts TSX declarations and standard tags', async () => { + const result = await extractCatalog({ + sourceDir: fixturePath('tsx', 'catalog'), + tsconfigPath: fixturePath('tsx', 'tsconfig.json'), + }); + + expect(result.components).toHaveLength(1); + expect(result.components[0]?.name).toBe('Chip'); + expect(result.components[0]?.schema).toEqual({ + properties: { + label: { + description: 'Label text or a binding path.', + oneOf: [ + { type: 'string' }, + { + additionalProperties: false, + properties: { + path: { + type: 'string', + }, + }, + required: ['path'], + type: 'object', + }, + ], + }, + tone: { + default: 'primary', + description: 'Visual tone.', + enum: ['primary', 'secondary'], + type: 'string', + }, + }, + required: ['label'], + }); + }); + + test('extracts JSX typedef/property shapes in best-effort mode', async () => { + const result = await extractCatalog({ + sourceDir: fixturePath('jsx', 'catalog'), + tsconfigPath: fixturePath('jsx', 'tsconfig.json'), + }); + + expect(result.components).toHaveLength(1); + expect(result.components[0]?.schema).toEqual({ + properties: { + text: { + description: 'Literal badge text.', + oneOf: [ + { type: 'string' }, + { + additionalProperties: false, + properties: { + path: { + type: 'string', + }, + }, + required: ['path'], + type: 'object', + }, + ], + }, + tone: { + description: 'Badge tone.', + enum: ['info', 'warning'], + type: 'string', + }, + }, + required: ['text'], + }); + }); + + test('extracts nested TSX object graphs without custom schema tags', async () => { + const result = await extractCatalog({ + sourceDir: fixturePath('tsx-complex', 'catalog'), + tsconfigPath: fixturePath('tsx-complex', 'tsconfig.json'), + }); + + expect(result.components).toHaveLength(1); + expect(result.components[0]?.schema).toEqual({ + properties: { + action: { + additionalProperties: false, + description: 'Host action payload.', + properties: { + event: { + additionalProperties: false, + properties: { + context: { + additionalProperties: { + oneOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { + additionalProperties: false, + properties: { + path: { + type: 'string', + }, + }, + required: ['path'], + type: 'object', + }, + ], + }, + description: 'Context is a JSON object map in v0.9.', + type: 'object', + }, + name: { + type: 'string', + }, + }, + required: ['name'], + type: 'object', + }, + }, + required: ['event'], + type: 'object', + }, + }, + required: ['action'], + }); + }); + + test('rejects literal undefined defaults with a clear error', async () => { + await expect(extractCatalog({ + sourceDir: fixturePath('tsx-invalid-default', 'catalog'), + tsconfigPath: fixturePath('tsx-invalid-default', 'tsconfig.json'), + })).rejects.toThrow( + 'The literal "undefined" is not supported in @defaultValue.', + ); + }); + + test('wraps malformed JSON errors with tag context', async () => { + await expect(extractCatalog({ + sourceDir: fixturePath('tsx-invalid-json', 'catalog'), + tsconfigPath: fixturePath('tsx-invalid-json', 'tsconfig.json'), + })).rejects.toThrow('Failed to parse @defaultValue value'); + }); + + test('rejects named re-export component entry files', async () => { + await expect(extractCatalog({ + sourceDir: fixturePath('tsx-invalid-export-named', 'catalog'), + tsconfigPath: fixturePath('tsx-invalid-export-named', 'tsconfig.json'), + })).rejects.toThrow('Unsupported component export'); + }); + + test('rejects default-export component entry files', async () => { + await expect(extractCatalog({ + sourceDir: fixturePath('tsx-invalid-export-default', 'catalog'), + tsconfigPath: fixturePath('tsx-invalid-export-default', 'tsconfig.json'), + })).rejects.toThrow('Unsupported component export'); + }); + + test('fails loudly on unsupported TSX type syntax', async () => { + await expect(extractCatalog({ + sourceDir: fixturePath('tsx-invalid-type', 'catalog'), + tsconfigPath: fixturePath('tsx-invalid-type', 'tsconfig.json'), + })).rejects.toThrow('Unsupported type "Map"'); + }); + + test('keeps unsupported JSX types in best-effort string mode', async () => { + const result = await extractCatalog({ + sourceDir: fixturePath('jsx-loose', 'catalog'), + tsconfigPath: fixturePath('jsx-loose', 'tsconfig.json'), + }); + + expect(result.components).toHaveLength(1); + expect(result.components[0]?.schema).toEqual({ + properties: { + value: { + description: 'Unsupported types fall back to string in JSX mode.', + type: 'string', + }, + }, + required: ['value'], + }); + }); + + test('matches the legacy A2UI catalog fixtures exactly', async () => { + const result = await extractCatalog({ + sourceDir: path.join(workspaceRoot, 'packages/genui/a2ui/src/catalog'), + tsconfigPath: path.join( + workspaceRoot, + 'packages/genui/a2ui/tsconfig.json', + ), + }); + + const renderedFiles = renderCatalogFiles(result, { + outDir: path.join(workspaceRoot, 'packages/genui/a2ui/dist/catalog'), + }); + + expect(renderedFiles).toHaveLength(10); + + for (const renderedFile of renderedFiles) { + const componentName = path.basename(path.dirname(renderedFile.path)); + const fixtureFile = fixturePath( + 'legacy-baseline', + componentName, + 'catalog.json', + ); + const expected = await fs.readFile(fixtureFile, 'utf8'); + expect(renderedFile.content).toBe(expected); + } + }); + + test('renders full catalog output from the API', async () => { + const result = await extractCatalog({ + catalogId: 'demo-catalog', + description: 'Demo catalog', + format: 'a2ui-catalog', + functions: { + open: { + type: 'string', + }, + }, + schema: 'https://example.com/catalog.schema.json', + sourceDir: fixturePath('tsx', 'catalog'), + theme: { + color: 'blue', + }, + title: 'Demo', + tsconfigPath: fixturePath('tsx', 'tsconfig.json'), + }); + + expect(result.catalog).toEqual({ + $schema: 'https://example.com/catalog.schema.json', + catalogId: 'demo-catalog', + components: { + Chip: result.components[0]?.schema, + }, + description: 'Demo catalog', + functions: { + open: { + type: 'string', + }, + }, + theme: { + color: 'blue', + }, + title: 'Demo', + }); + }); +}); + +describe('catalog file helpers', () => { + test('writeCatalogFiles and checkCatalogFiles round-trip generated shards', async () => { + await withTempDir('a2ui-catalog-roundtrip-', async (outputDir) => { + const result = await extractCatalog({ + sourceDir: fixturePath('tsx', 'catalog'), + tsconfigPath: fixturePath('tsx', 'tsconfig.json'), + }); + + await writeCatalogFiles(result, { outDir: outputDir }); + const generatedPath = path.join(outputDir, 'Chip', 'catalog.json'); + + const initialCheck = await checkCatalogFiles(result, { + outDir: outputDir, + }); + expect(initialCheck.ok).toBe(true); + + const lfContent = await fs.readFile(generatedPath, 'utf8'); + await fs.writeFile( + generatedPath, + lfContent.replace(/\n/gu, '\r\n'), + 'utf8', + ); + + const crlfCheck = await checkCatalogFiles(result, { outDir: outputDir }); + expect(crlfCheck.ok).toBe(true); + + await fs.writeFile(generatedPath, '{}\r\n', 'utf8'); + + const changedCheck = await checkCatalogFiles(result, { + outDir: outputDir, + }); + expect(changedCheck.ok).toBe(false); + expect(changedCheck.mismatched).toContain(generatedPath); + }); + }); +}); diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/jsx-loose/catalog/Loose/index.jsx b/packages/genui/a2ui-catalog-extractor/test/fixtures/jsx-loose/catalog/Loose/index.jsx new file mode 100644 index 0000000000..18e346ad25 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/jsx-loose/catalog/Loose/index.jsx @@ -0,0 +1,14 @@ +// 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. +/** + * @typedef {object} LooseProps + * @property {Map} value Unsupported types fall back to string in JSX mode. + */ + +/** + * @param {LooseProps} props Loose props. + */ +export function Loose(props) { + return props; +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/jsx-loose/tsconfig.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/jsx-loose/tsconfig.json new file mode 100644 index 0000000000..3f61d90266 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/jsx-loose/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../../../../tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "jsx": "preserve", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + }, + "include": [ + "./catalog/**/*", + ], +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/jsx/catalog/Badge/index.jsx b/packages/genui/a2ui-catalog-extractor/test/fixtures/jsx/catalog/Badge/index.jsx new file mode 100644 index 0000000000..e2fbdc434c --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/jsx/catalog/Badge/index.jsx @@ -0,0 +1,15 @@ +// 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. +/** + * @typedef {object} BadgeProps + * @property {string | { path: string }} text Literal badge text. + * @property {'info' | 'warning'} [tone] Badge tone. + */ + +/** + * @param {BadgeProps} props Badge props. + */ +export function Badge(props) { + return props; +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/jsx/tsconfig.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/jsx/tsconfig.json new file mode 100644 index 0000000000..3f61d90266 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/jsx/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../../../../tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "jsx": "preserve", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + }, + "include": [ + "./catalog/**/*", + ], +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Button/catalog.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Button/catalog.json new file mode 100644 index 0000000000..fe5811ccfe --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Button/catalog.json @@ -0,0 +1,71 @@ +{ + "Button": { + "properties": { + "child": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": [ + "primary", + "borderless" + ] + }, + "action": { + "type": "object", + "properties": { + "event": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "context": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + ] + }, + "description": "Context is a JSON object map in v0.9." + } + }, + "required": [ + "name" + ], + "additionalProperties": false + } + }, + "required": [ + "event" + ], + "additionalProperties": false, + "description": "v0.9 actions should use the `event` wrapper for server-dispatched clicks." + } + }, + "required": [ + "child", + "action" + ] + } +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Card/catalog.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Card/catalog.json new file mode 100644 index 0000000000..9fa220c8fc --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Card/catalog.json @@ -0,0 +1,12 @@ +{ + "Card": { + "properties": { + "child": { + "type": "string" + } + }, + "required": [ + "child" + ] + } +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/CheckBox/catalog.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/CheckBox/catalog.json new file mode 100644 index 0000000000..1a69897d86 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/CheckBox/catalog.json @@ -0,0 +1,48 @@ +{ + "CheckBox": { + "properties": { + "label": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + ] + }, + "value": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "label", + "value" + ] + } +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Column/catalog.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Column/catalog.json new file mode 100644 index 0000000000..377bb3ecdb --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Column/catalog.json @@ -0,0 +1,57 @@ +{ + "Column": { + "properties": { + "children": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "properties": { + "componentId": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "componentId", + "path" + ], + "additionalProperties": false + } + ], + "description": "Static child IDs array or template object." + }, + "align": { + "type": "string", + "enum": [ + "start", + "center", + "end", + "stretch" + ] + }, + "justify": { + "type": "string", + "enum": [ + "start", + "center", + "end", + "stretch", + "spaceBetween", + "spaceAround", + "spaceEvenly" + ] + } + }, + "required": [ + "children" + ] + } +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Divider/catalog.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Divider/catalog.json new file mode 100644 index 0000000000..4dc765578f --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Divider/catalog.json @@ -0,0 +1,14 @@ +{ + "Divider": { + "properties": { + "axis": { + "type": "string", + "enum": [ + "horizontal", + "vertical" + ] + } + }, + "required": [] + } +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Image/catalog.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Image/catalog.json new file mode 100644 index 0000000000..c5c3f08b81 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Image/catalog.json @@ -0,0 +1,50 @@ +{ + "Image": { + "properties": { + "url": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + ], + "description": "Image URL or path binding." + }, + "fit": { + "type": "string", + "enum": [ + "contain", + "cover", + "fill", + "none", + "scale-down" + ] + }, + "variant": { + "type": "string", + "enum": [ + "icon", + "avatar", + "smallFeature", + "mediumFeature", + "largeFeature", + "header" + ] + } + }, + "required": [ + "url" + ] + } +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/List/catalog.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/List/catalog.json new file mode 100644 index 0000000000..4125252039 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/List/catalog.json @@ -0,0 +1,52 @@ +{ + "List": { + "properties": { + "children": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "properties": { + "componentId": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "componentId", + "path" + ], + "additionalProperties": false + } + ], + "description": "Static child IDs array or template object." + }, + "direction": { + "type": "string", + "enum": [ + "horizontal", + "vertical" + ] + }, + "align": { + "type": "string", + "enum": [ + "start", + "center", + "end", + "stretch" + ] + } + }, + "required": [ + "children" + ] + } +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/RadioGroup/catalog.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/RadioGroup/catalog.json new file mode 100644 index 0000000000..eb8d4a3d65 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/RadioGroup/catalog.json @@ -0,0 +1,62 @@ +{ + "RadioGroup": { + "properties": { + "items": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + ], + "description": "The list of string options to display." + }, + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + ], + "description": "The currently selected value." + }, + "usageHint": { + "type": "string", + "enum": [ + "default", + "card", + "row" + ], + "description": "A hint for the visual style of the radio group." + } + }, + "required": [ + "items", + "value" + ] + } +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Row/catalog.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Row/catalog.json new file mode 100644 index 0000000000..4377917d5b --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Row/catalog.json @@ -0,0 +1,57 @@ +{ + "Row": { + "properties": { + "children": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "properties": { + "componentId": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "componentId", + "path" + ], + "additionalProperties": false + } + ], + "description": "Static child IDs array or template object." + }, + "justify": { + "type": "string", + "enum": [ + "start", + "center", + "end", + "stretch", + "spaceBetween", + "spaceAround", + "spaceEvenly" + ] + }, + "align": { + "type": "string", + "enum": [ + "start", + "center", + "end", + "stretch" + ] + } + }, + "required": [ + "children" + ] + } +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Text/catalog.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Text/catalog.json new file mode 100644 index 0000000000..93aa67ac9f --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/legacy-baseline/Text/catalog.json @@ -0,0 +1,41 @@ +{ + "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", + "h3", + "h4", + "h5", + "caption", + "body" + ] + } + }, + "required": [ + "text" + ] + } +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-complex/catalog/ActionButton/index.tsx b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-complex/catalog/ActionButton/index.tsx new file mode 100644 index 0000000000..15958ce201 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-complex/catalog/ActionButton/index.tsx @@ -0,0 +1,40 @@ +// 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. +interface GenericComponentProps { + id?: string; + sendAction?: (action: unknown) => void; + surface: unknown; +} + +export interface ActionContextBinding { + path: string; +} + +export type ActionContextValue = + | string + | number + | boolean + | ActionContextBinding; + +export interface ActionEvent { + name: string; + /** Context is a JSON object map in v0.9. */ + context?: Record; +} + +export interface ActionPayload { + event: ActionEvent; +} + +/** + * Props for the ActionButton catalog component. + */ +export interface ActionButtonProps extends GenericComponentProps { + /** Host action payload. */ + action: ActionPayload; +} + +export function ActionButton(_props: ActionButtonProps): null { + return null; +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-complex/tsconfig.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-complex/tsconfig.json new file mode 100644 index 0000000000..c5c915a76e --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-complex/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "@lynx-js/react", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + }, + "include": [ + "./catalog/**/*", + ], +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-default/catalog/UndefinedDefault/index.tsx b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-default/catalog/UndefinedDefault/index.tsx new file mode 100644 index 0000000000..d1fd50d5da --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-default/catalog/UndefinedDefault/index.tsx @@ -0,0 +1,14 @@ +// 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. +export interface UndefinedDefaultProps { + /** + * Invalid default marker. + * @defaultValue undefined + */ + value?: string; +} + +export function UndefinedDefault(_props: UndefinedDefaultProps): null { + return null; +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-default/tsconfig.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-default/tsconfig.json new file mode 100644 index 0000000000..c5c915a76e --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-default/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "@lynx-js/react", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + }, + "include": [ + "./catalog/**/*", + ], +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-export-default/catalog/DefaultExport/index.tsx b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-export-default/catalog/DefaultExport/index.tsx new file mode 100644 index 0000000000..e9e568e6ce --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-export-default/catalog/DefaultExport/index.tsx @@ -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. +interface DefaultExportProps { + value: string; +} + +function DefaultExport(_props: DefaultExportProps): null { + return null; +} + +export default DefaultExport; diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-export-default/tsconfig.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-export-default/tsconfig.json new file mode 100644 index 0000000000..c5c915a76e --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-export-default/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "@lynx-js/react", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + }, + "include": [ + "./catalog/**/*", + ], +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-export-named/catalog/NamedExport/index.tsx b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-export-named/catalog/NamedExport/index.tsx new file mode 100644 index 0000000000..ed24b664df --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-export-named/catalog/NamedExport/index.tsx @@ -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. +interface NamedExportProps { + value: string; +} + +function NamedExport(_props: NamedExportProps): null { + return null; +} + +export { NamedExport }; diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-export-named/tsconfig.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-export-named/tsconfig.json new file mode 100644 index 0000000000..c5c915a76e --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-export-named/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "@lynx-js/react", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + }, + "include": [ + "./catalog/**/*", + ], +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-json/catalog/BrokenJson/index.tsx b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-json/catalog/BrokenJson/index.tsx new file mode 100644 index 0000000000..afe09136b7 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-json/catalog/BrokenJson/index.tsx @@ -0,0 +1,14 @@ +// 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. +export interface BrokenJsonProps { + /** + * Invalid JSON default. + * @defaultValue {"broken":} + */ + value?: string; +} + +export function BrokenJson(_props: BrokenJsonProps): null { + return null; +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-json/tsconfig.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-json/tsconfig.json new file mode 100644 index 0000000000..c5c915a76e --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-json/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "@lynx-js/react", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + }, + "include": [ + "./catalog/**/*", + ], +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-type/catalog/Fancy/index.tsx b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-type/catalog/Fancy/index.tsx new file mode 100644 index 0000000000..ec308155ab --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-type/catalog/Fancy/index.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. +export interface FancyProps { + value: Map; +} + +export function Fancy(_props: FancyProps): null { + return null; +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-type/tsconfig.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-type/tsconfig.json new file mode 100644 index 0000000000..c5c915a76e --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx-invalid-type/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "@lynx-js/react", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + }, + "include": [ + "./catalog/**/*", + ], +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx/catalog/Chip/index.tsx b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx/catalog/Chip/index.tsx new file mode 100644 index 0000000000..9a4ada3021 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx/catalog/Chip/index.tsx @@ -0,0 +1,28 @@ +// 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. +interface GenericComponentProps { + id?: string; + sendAction?: (action: unknown) => void; + surface: unknown; +} + +type Binding = { path: string }; +type BindableText = string | Binding; + +/** + * Props for the Chip catalog component. + */ +export interface ChipProps extends GenericComponentProps { + /** Label text or a binding path. */ + label: BindableText; + /** + * Visual tone. + * @defaultValue "primary" + */ + tone?: 'primary' | 'secondary'; +} + +export function Chip(_props: ChipProps): null { + return null; +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx/tsconfig.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx/tsconfig.json new file mode 100644 index 0000000000..c5c915a76e --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsx/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../../../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "@lynx-js/react", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + }, + "include": [ + "./catalog/**/*", + ], +} 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..ab29a0dca1 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "composite": true, + "outDir": "./dist", + "rewriteRelativeImportExtensions": true, + "rootDir": "./src", + }, + "include": ["src"], +} diff --git a/packages/genui/a2ui-catalog-extractor/tsconfig.json b/packages/genui/a2ui-catalog-extractor/tsconfig.json new file mode 100644 index 0000000000..d03f28013c --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + }, + "include": [ + "src", + "test", + "rslib.config.ts", + "rstest.config.ts", + ], +} diff --git a/packages/genui/a2ui-catalog-extractor/turbo.json b/packages/genui/a2ui-catalog-extractor/turbo.json new file mode 100644 index 0000000000..f1b0d415fb --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/turbo.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "dependsOn": [ + "^build" + ], + "inputs": [ + "src/**", + "test/**", + "README.md", + "SKILL.md", + "agents/**", + "references/**", + "package.json", + "tsconfig*.json", + "rslib.config.ts", + "rstest.config.ts" + ], + "outputs": [ + "dist/**" + ] + }, + "test": { + "dependsOn": [ + "^build" + ], + "inputs": [ + "src/**", + "test/**", + "package.json", + "tsconfig*.json", + "rstest.config.ts" + ] + } + } +} diff --git a/packages/genui/a2ui/README.md b/packages/genui/a2ui/README.md index 14314c5739..8679b1c664 100644 --- a/packages/genui/a2ui/README.md +++ b/packages/genui/a2ui/README.md @@ -1,3 +1,31 @@ -# WIP: This package is inspired by Google A2UI. +# A2UI ReactLynx -Currently We're still working on it. +`@lynx-js/a2ui-reactlynx` contains the ReactLynx-side runtime and catalog components for A2UI experiments in this repository. + +## What Is Here + +- `src/core`: rendering, actions, and data-binding helpers +- `src/catalog/*`: catalog component implementations +- `dist/catalog/*/catalog.json`: generated catalog shards used by the package build + +## Catalog Generation + +Catalog JSON is generated from the component source in `src/catalog/*/index.tsx`. + +The build now uses `@lynx-js/a2ui-catalog-extractor`, which reads: + +- explicit TypeScript declaration syntax +- standard JSDoc, TSDoc, and TypeDoc tags +- the minimal `@a2uiSchema` escape hatch for exact nested schema fragments + +This keeps the catalog authoring flow close to the component code and avoids depending on fragile checker-driven type resolution for complex cases. + +## Build + +From the repository root: + +```bash +fnm exec --using v24.15.0 -- pnpm --filter @lynx-js/a2ui-reactlynx build +``` + +That build regenerates `dist/catalog/*/catalog.json` before running `tsc -b`. diff --git a/packages/genui/a2ui/package.json b/packages/genui/a2ui/package.json index b73f5f2f62..785dd2ce65 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": "node --experimental-strip-types ../a2ui-catalog-extractor/src/cli.ts generate --source ./src/catalog --out ./dist/catalog --tsconfig ./tsconfig.json --format legacy-shards && 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/Button/index.tsx b/packages/genui/a2ui/src/catalog/Button/index.tsx index 9c0b742f95..6f4e717574 100755 --- a/packages/genui/a2ui/src/catalog/Button/index.tsx +++ b/packages/genui/a2ui/src/catalog/Button/index.tsx @@ -6,19 +6,41 @@ import type { GenericComponentProps } from '../../core/types.js'; import './style.css'; +/** + * Props for the Button catalog component. + */ +export interface ButtonActionContextBinding { + path: string; +} + +export type ButtonActionContextValue = + | string + | number + | boolean + | ButtonActionContextBinding; + +export interface ButtonActionEvent { + name: string; + /** Context is a JSON object map in v0.9. */ + context?: Record; +} + +export interface ButtonAction { + event: ButtonActionEvent; +} + export interface ButtonProps extends GenericComponentProps { child: string; variant?: 'primary' | 'borderless'; - /** v0.9 actions should use the `event` wrapper for server-dispatched clicks. */ - action: { - event: { - name: string; - /** Context is a JSON object map in v0.9. */ - context?: Record; - }; - }; + /** + * v0.9 actions should use the `event` wrapper for server-dispatched clicks. + */ + action: ButtonAction; } +/** + * Render an interactive button. + */ export function Button( props: ButtonProps, ): import('@lynx-js/react').ReactNode { @@ -26,7 +48,7 @@ export function Button( const handleClick = () => { if (action) { - void sendAction?.(action as Record); + void sendAction?.(action as unknown as Record); } }; diff --git a/packages/genui/a2ui/src/catalog/Card/index.tsx b/packages/genui/a2ui/src/catalog/Card/index.tsx index b08d9905f0..0afc014a44 100755 --- a/packages/genui/a2ui/src/catalog/Card/index.tsx +++ b/packages/genui/a2ui/src/catalog/Card/index.tsx @@ -6,10 +6,16 @@ import type { GenericComponentProps } from '../../core/types.js'; import './style.css'; +/** + * Props for the Card catalog component. + */ export interface CardProps extends GenericComponentProps { child: string; } +/** + * Render a card container. + */ export function Card(props: CardProps): import('@lynx-js/react').ReactNode { const { child: childId, surface, dataContextPath } = props; const childComponent = surface.components.get(childId); diff --git a/packages/genui/a2ui/src/catalog/CheckBox/index.tsx b/packages/genui/a2ui/src/catalog/CheckBox/index.tsx index 70d1369883..68c81f25fe 100755 --- a/packages/genui/a2ui/src/catalog/CheckBox/index.tsx +++ b/packages/genui/a2ui/src/catalog/CheckBox/index.tsx @@ -5,11 +5,17 @@ import type { GenericComponentProps } from '../../core/types.js'; import './style.css'; +/** + * Props for the CheckBox catalog component. + */ export interface CheckBoxProps extends GenericComponentProps { label: string | { path: string }; value: boolean | { path: string }; } +/** + * Render a checkbox row. + */ export function CheckBox( props: CheckBoxProps, ): import('@lynx-js/react').ReactNode { diff --git a/packages/genui/a2ui/src/catalog/Column/index.tsx b/packages/genui/a2ui/src/catalog/Column/index.tsx index 0771635a46..f0d42369c1 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'; +/** + * Props for the Column catalog component. + */ export interface ColumnProps extends GenericComponentProps { /** Static child IDs array or template object. */ children: string[] | { componentId: string; path: string }; @@ -14,12 +17,15 @@ export interface ColumnProps extends GenericComponentProps { | 'start' | 'center' | 'end' + | 'stretch' | 'spaceBetween' | 'spaceAround' - | 'spaceEvenly' - | 'stretch'; + | 'spaceEvenly'; } +/** + * Render a vertical layout container. + */ export function Column( props: ColumnProps, ): import('@lynx-js/react').ReactNode { diff --git a/packages/genui/a2ui/src/catalog/Divider/index.tsx b/packages/genui/a2ui/src/catalog/Divider/index.tsx index 5073dac675..5a56ee8dc7 100755 --- a/packages/genui/a2ui/src/catalog/Divider/index.tsx +++ b/packages/genui/a2ui/src/catalog/Divider/index.tsx @@ -5,10 +5,16 @@ import type { GenericComponentProps } from '../../core/types.js'; import './style.css'; +/** + * Props for the Divider catalog component. + */ export interface DividerProps extends GenericComponentProps { axis?: 'horizontal' | 'vertical'; } +/** + * Render a divider line. + */ export function Divider( props: DividerProps, ): import('@lynx-js/react').ReactNode { diff --git a/packages/genui/a2ui/src/catalog/Image/index.tsx b/packages/genui/a2ui/src/catalog/Image/index.tsx index fc8d0af038..6a74f7ff91 100755 --- a/packages/genui/a2ui/src/catalog/Image/index.tsx +++ b/packages/genui/a2ui/src/catalog/Image/index.tsx @@ -7,6 +7,9 @@ import type { GenericComponentProps } from '../../core/types.js'; import './style.css'; +/** + * Props for the Image catalog component. + */ export interface ImageProps extends GenericComponentProps { /** Image URL or path binding. */ url: string | { path: string }; @@ -20,6 +23,9 @@ export interface ImageProps extends GenericComponentProps { | 'header'; } +/** + * Render an image resource. + */ export function Image( props: ImageProps, ): import('@lynx-js/react').ReactNode { diff --git a/packages/genui/a2ui/src/catalog/List/index.tsx b/packages/genui/a2ui/src/catalog/List/index.tsx index c18e278147..b214b31de0 100755 --- a/packages/genui/a2ui/src/catalog/List/index.tsx +++ b/packages/genui/a2ui/src/catalog/List/index.tsx @@ -9,13 +9,19 @@ import { useDataBinding } from '../../core/useDataBinding.js'; import './style.css'; +/** + * Props for the List catalog component. + */ 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'; } +/** + * Render a scrollable list container. + */ export function List( props: ListProps, ): import('@lynx-js/react').ReactNode { diff --git a/packages/genui/a2ui/src/catalog/RadioGroup/index.tsx b/packages/genui/a2ui/src/catalog/RadioGroup/index.tsx index 7a931d6bbd..f889f00ff5 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 = { }, }; +/** + * Props for the RadioGroup catalog component. + */ export interface RadioGroupComponentProps extends GenericComponentProps { /** The list of string options to display. */ items: string[] | { path: string }; @@ -25,6 +28,9 @@ export interface RadioGroupComponentProps extends GenericComponentProps { usageHint?: 'default' | 'card' | 'row'; } +/** + * Render a group of radio options. + */ export function RadioGroup( props: RadioGroupComponentProps, ): import('@lynx-js/react').ReactNode { diff --git a/packages/genui/a2ui/src/catalog/Row/index.tsx b/packages/genui/a2ui/src/catalog/Row/index.tsx index fef0d61783..81aedf94e5 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'; +/** + * Props for the Row catalog component. + */ export interface RowProps extends GenericComponentProps { /** Static child IDs array or template object. */ children: string[] | { componentId: string; path: string }; @@ -13,13 +16,16 @@ export interface RowProps extends GenericComponentProps { | 'start' | 'center' | 'end' + | 'stretch' | 'spaceBetween' | 'spaceAround' - | 'spaceEvenly' - | 'stretch'; + | 'spaceEvenly'; align?: 'start' | 'center' | 'end' | 'stretch'; } +/** + * Render a horizontal layout container. + */ export function Row(props: RowProps): import('@lynx-js/react').ReactNode { const children = props.children; const surface = props.surface; diff --git a/packages/genui/a2ui/src/catalog/Text/index.tsx b/packages/genui/a2ui/src/catalog/Text/index.tsx index 2a103b06e3..6343d0d37f 100644 --- a/packages/genui/a2ui/src/catalog/Text/index.tsx +++ b/packages/genui/a2ui/src/catalog/Text/index.tsx @@ -4,12 +4,18 @@ import type { GenericComponentProps } from '../../core/types.js'; import './style.css'; +/** + * Props for the Text catalog component. + */ export interface TextProps extends GenericComponentProps { /** Literal text or path binding. */ text: string | { path: string }; variant?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'caption' | 'body'; } +/** + * Render text content. + */ export function Text( props: TextProps, ): import('@lynx-js/react').ReactNode { 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..fb32a7250e 100644 --- a/packages/genui/a2ui/turbo.json +++ b/packages/genui/a2ui/turbo.json @@ -8,7 +8,8 @@ ], "inputs": [ "src/**", - "tools/**", + "$TURBO_ROOT$/packages/genui/a2ui-catalog-extractor/src/**", + "$TURBO_ROOT$/packages/genui/a2ui-catalog-extractor/tsconfig*.json", "tsconfig.json", "package.json" ], diff --git a/packages/genui/tsconfig.json b/packages/genui/tsconfig.json index f0cfbb28a8..99b23e98c5 100644 --- a/packages/genui/tsconfig.json +++ b/packages/genui/tsconfig.json @@ -10,6 +10,7 @@ }, "references": [ /** packages-start */ + { "path": "./a2ui-catalog-extractor/tsconfig.build.json" }, { "path": "./a2ui/tsconfig.json" }, /** packages-end */ ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88729c28dc..c046ac72df 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,28 @@ importers: specifier: ^18.3.28 version: 18.3.28 + packages/genui/a2ui-catalog-extractor: + dependencies: + '@microsoft/tsdoc': + specifier: ^0.16.0 + version: 0.16.0 + '@microsoft/tsdoc-config': + specifier: ^0.18.1 + version: 0.18.1 + typedoc: + specifier: ^0.28.9 + version: 0.28.9(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 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 +3258,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 +4865,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 +8423,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 @@ -10702,6 +10745,13 @@ packages: typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} + typedoc@0.28.9: + resolution: {integrity: sha512-aw45vwtwOl3QkUAmWCnLV9QW1xY+FSX2zzlit4MAfE99wX+Jij4ycnpbAWgBXsRrxmfs9LaYktg/eX5Bpthd3g==} + 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 + typescript-eslint@8.56.0: resolution: {integrity: sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -12288,6 +12338,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 +14336,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 +14362,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 +14376,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 +18332,8 @@ snapshots: dependencies: react: 19.2.4 + lunr@2.3.9: {} + lz-string@1.5.0: {} magic-string@0.30.21: @@ -21000,6 +21078,15 @@ snapshots: dependencies: is-typedarray: 1.0.0 + typedoc@0.28.9(typescript@5.9.3): + dependencies: + '@gerrit0/mini-shiki': 3.23.0 + lunr: 2.3.9 + markdown-it: 14.1.0 + minimatch: 9.0.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)