feat(site): extract api reference from components (#464)

This commit is contained in:
Darius Cepulis
2026-02-05 19:57:54 -06:00
committed by GitHub
parent 48364f17fd
commit 0991a899b2
80 changed files with 3425 additions and 1562 deletions
@@ -0,0 +1,147 @@
import * as ts from 'typescript';
import * as tae from 'typescript-api-extractor';
import { formatProperties } from './formatter.js';
import type { CoreExtraction, ExtractedProp } from './types.js';
/**
* Extract Props, State, and defaultProps from a core component file.
*
* Looks for patterns like:
* - interface PlayButtonProps { ... }
* - interface PlayButtonState { ... }
* - class PlayButtonCore { static defaultProps = { ... } }
*/
export function extractCore(filePath: string, program: ts.Program, componentName: string): CoreExtraction | null {
const ast = tae.parseFromProgram(filePath, program);
// Find the Props interface (e.g., PlayButtonProps)
const propsExport = ast.exports.find((exp) => exp.name === `${componentName}Props`);
// Find the State interface (e.g., PlayButtonState)
const stateExport = ast.exports.find((exp) => exp.name === `${componentName}State`);
if (!propsExport && !stateExport) {
return null;
}
// Extract props
let props: ExtractedProp[] = [];
let description: string | undefined;
if (propsExport?.type instanceof tae.ObjectNode) {
const formatted = formatProperties(propsExport.type.properties);
props = Object.entries(formatted).map(([name, def]) => ({
name,
...def,
}));
description = propsExport.documentation?.description;
}
// Extract state
let state: ExtractedProp[] = [];
if (stateExport?.type instanceof tae.ObjectNode) {
const formatted = formatProperties(stateExport.type.properties);
state = Object.entries(formatted).map(([name, def]) => ({
name,
...def,
}));
}
// Extract defaultProps from the Core class
const defaultProps = extractDefaultProps(filePath, program, componentName);
return {
description,
props,
state,
defaultProps,
};
}
/**
* Extract defaultProps from the Core class static property.
*
* Looks for: static readonly defaultProps = { label: '', disabled: false }
*/
export function extractDefaultProps(
filePath: string,
program: ts.Program,
componentName: string
): Record<string, string> {
const sourceFile = program.getSourceFile(filePath);
if (!sourceFile) return {};
const defaultProps: Record<string, string> = {};
function visit(node: ts.Node) {
// Look for class declaration
if (ts.isClassDeclaration(node) && node.name?.text === `${componentName}Core`) {
for (const member of node.members) {
// Look for static property named defaultProps
if (
ts.isPropertyDeclaration(member) &&
member.name &&
ts.isIdentifier(member.name) &&
member.name.text === 'defaultProps' &&
member.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) &&
member.initializer
) {
// Parse the object literal
if (ts.isObjectLiteralExpression(member.initializer)) {
for (const prop of member.initializer.properties) {
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
const propName = prop.name.text;
const propValue = getPropertyValue(prop.initializer, sourceFile);
if (propValue !== undefined) {
defaultProps[propName] = propValue;
}
}
}
}
}
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return defaultProps;
}
/**
* Get a string representation of a property value.
*/
export function getPropertyValue(node: ts.Expression, sourceFile: ts.SourceFile): string | undefined {
if (ts.isStringLiteral(node)) {
return `'${node.text}'`;
}
if (ts.isNumericLiteral(node)) {
return node.text;
}
if (node.kind === ts.SyntaxKind.TrueKeyword) {
return 'true';
}
if (node.kind === ts.SyntaxKind.FalseKeyword) {
return 'false';
}
if (node.kind === ts.SyntaxKind.NullKeyword) {
return 'null';
}
if (ts.isArrayLiteralExpression(node) && node.elements.length === 0) {
return '[]';
}
if (ts.isObjectLiteralExpression(node) && node.properties.length === 0) {
return '{}';
}
// For more complex expressions, get the source text
return node.getText(sourceFile);
}
@@ -0,0 +1,118 @@
import * as ts from 'typescript';
import type { DataAttrsExtraction } from './types.js';
/**
* Extract data attributes from a data-attrs file.
*
* Looks for patterns like:
* ```ts
* export const PlayButtonDataAttrs = {
* /** Present when the media is paused. *\/
* paused: 'data-paused',
* /** Present when the media has ended. *\/
* ended: 'data-ended',
* } as const;
* ```
*/
export function extractDataAttrs(
filePath: string,
program: ts.Program,
componentName: string
): DataAttrsExtraction | null {
const sourceFile = program.getSourceFile(filePath);
if (!sourceFile) {
return null;
}
const attrs: Array<{ name: string; description: string }> = [];
// Common naming patterns for data attributes exports
const possibleNames = [`${componentName}DataAttrs`, `${componentName}DataAttributes`];
function visit(node: ts.Node) {
// Look for variable declaration like: export const PlayButtonDataAttrs = { ... }
if (ts.isVariableStatement(node)) {
for (const decl of node.declarationList.declarations) {
if (!ts.isIdentifier(decl.name) || !possibleNames.includes(decl.name.text) || !decl.initializer) {
continue;
}
// Handle `as const` assertions
let objLiteral: ts.ObjectLiteralExpression | undefined;
if (ts.isObjectLiteralExpression(decl.initializer)) {
objLiteral = decl.initializer;
} else if (ts.isAsExpression(decl.initializer) && ts.isObjectLiteralExpression(decl.initializer.expression)) {
objLiteral = decl.initializer.expression;
}
if (!objLiteral) continue;
// Extract properties with their JSDoc comments
for (const prop of objLiteral.properties) {
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
const propName = prop.name.text;
let dataAttrValue = '';
// Get the value (e.g., 'data-paused')
if (ts.isStringLiteral(prop.initializer)) {
dataAttrValue = prop.initializer.text;
}
// Get JSDoc comment for this property
const jsDocComment = getJsDocComment(prop, sourceFile);
attrs.push({
name: dataAttrValue || `data-${propName}`,
description: jsDocComment || '',
});
}
}
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
if (attrs.length === 0) {
return null;
}
return { attrs };
}
/**
* Extract JSDoc comment from a property assignment.
*/
export function getJsDocComment(node: ts.PropertyAssignment, sourceFile: ts.SourceFile): string {
// Get leading comment ranges
const fullText = sourceFile.getFullText();
const nodeStart = node.getFullStart();
const ranges = ts.getLeadingCommentRanges(fullText, nodeStart);
if (!ranges || ranges.length === 0) return '';
// Get the last comment (closest to the property)
const lastRange = ranges[ranges.length - 1];
if (!lastRange) return '';
const commentText = fullText.substring(lastRange.pos, lastRange.end);
// Parse JSDoc comment
if (commentText.startsWith('/**')) {
return commentText
.replace(/^\/\*\*\s*/, '')
.replace(/\s*\*\/$/, '')
.replace(/^\s*\*\s?/gm, '')
.trim();
}
// Single-line comment
if (commentText.startsWith('//')) {
return commentText.replace(/^\/\/\s*/, '').trim();
}
return '';
}
@@ -0,0 +1,240 @@
import { uniq } from 'es-toolkit/array';
import * as tae from 'typescript-api-extractor';
import type { PropDef } from './types.js';
/**
* Get abbreviated type for display in collapsed rows.
*
* Returns `shortType` when abbreviation adds value, `undefined` otherwise.
*/
export function getShortPropType(name: string, type: string): string | undefined {
// Callbacks → "function"
if (/^(on|get)[A-Z]/.test(name) && type.includes('=>')) {
return 'function';
}
// className/style/render → simplified
if (name === 'className' && type.includes('=>')) {
return 'string | function';
}
if (name === 'style' && type.includes('=>')) {
return 'CSSProperties | function';
}
if (name === 'render' && type.includes('=>')) {
return 'ReactElement | function';
}
// Simple types → no abbreviation needed
if (['boolean', 'string', 'number'].includes(type)) {
return undefined;
}
// Short unions (less than 3 members and under 40 chars) → no abbreviation
if (!type.includes(' | ') || (type.split(' | ').length < 3 && type.length < 40)) {
return undefined;
}
// Function in union → "type | function"
if (type.includes('=>')) {
const parts = type.split(' | ');
const nonFunctionParts = parts.filter((p) => !p.includes('=>'));
if (nonFunctionParts.length > 0) {
return `${nonFunctionParts.join(' | ')} | function`;
}
return 'function';
}
// Complex unions → no abbreviation needed (show full type)
return undefined;
}
/**
* Format a list of properties into API reference format.
*/
export function formatProperties(props: tae.PropertyNode[]): Record<string, PropDef> {
const result: Record<string, PropDef> = {};
for (const prop of props) {
// Skip ref for components
if (prop.name === 'ref') continue;
// Skip props marked with @ignore
if (prop.documentation?.hasTag('ignore')) continue;
const formattedType = formatType(prop.type, prop.optional);
const shortType = getShortPropType(prop.name, formattedType);
const entry: PropDef = { type: formattedType };
if (shortType !== undefined) entry.shortType = shortType;
if (prop.documentation?.defaultValue !== undefined) entry.default = prop.documentation.defaultValue;
if (!prop.optional) entry.required = true;
if (prop.documentation?.description !== undefined) entry.description = prop.documentation.description;
result[prop.name] = entry;
}
return result;
}
/**
* Format a type into a human-readable string.
*/
export function formatType(type: tae.AnyType, removeUndefined: boolean): string {
if (type instanceof tae.ExternalTypeNode) {
if (/^ReactElement(<.*>)?/.test(type.typeName.name || '')) {
return 'ReactElement';
}
if (type.typeName.namespaces?.length === 1 && type.typeName.namespaces[0] === 'React') {
return createNameWithTypeArguments(type.typeName);
}
return getFullyQualifiedName(type.typeName);
}
if (type instanceof tae.IntrinsicNode) {
return type.typeName ? getFullyQualifiedName(type.typeName) : type.intrinsic;
}
if (type instanceof tae.UnionNode) {
if (type.typeName) {
return getFullyQualifiedName(type.typeName);
}
let memberTypes = type.types;
if (removeUndefined) {
memberTypes = memberTypes.filter((t) => !(t instanceof tae.IntrinsicNode && t.intrinsic === 'undefined'));
}
const flattenedMemberTypes = memberTypes.flatMap((t) => {
if (t instanceof tae.UnionNode) {
return t.typeName ? t : t.types;
}
if (t instanceof tae.TypeParameterNode && t.constraint instanceof tae.UnionNode) {
return t.constraint.types;
}
return t;
});
const formattedMemberTypes = uniq(orderMembers(flattenedMemberTypes).map((t) => formatType(t, removeUndefined)));
return formattedMemberTypes.join(' | ');
}
if (type instanceof tae.IntersectionNode) {
if (type.typeName) {
return getFullyQualifiedName(type.typeName);
}
return orderMembers(type.types)
.map((t) => formatType(t, false))
.join(' & ');
}
if (type instanceof tae.ObjectNode) {
if (type.typeName) {
return getFullyQualifiedName(type.typeName);
}
if (type.properties.length === 0) {
return '{}';
}
return `{ ${type.properties.map((m) => `${m.name}${m.optional ? '?' : ''}: ${formatType(m.type, m.optional)}`).join('; ')} }`;
}
if (type instanceof tae.LiteralNode) {
return normalizeQuotes(type.value as string);
}
if (type instanceof tae.ArrayNode) {
const formattedMemberType = formatType(type.elementType, false);
if (formattedMemberType.includes(' ')) {
return `(${formattedMemberType})[]`;
}
return `${formattedMemberType}[]`;
}
if (type instanceof tae.FunctionNode) {
if (type.typeName) {
return getFullyQualifiedName(type.typeName);
}
const functionSignature = type.callSignatures
.map((s) => {
const params = s.parameters.map((p) => `${p.name}: ${formatType(p.type, false)}`).join(', ');
const returnType = formatType(s.returnValueType, false);
return `(${params}) => ${returnType}`;
})
.join(' | ');
return `(${functionSignature})`;
}
if (type instanceof tae.TupleNode) {
if (type.typeName) {
return getFullyQualifiedName(type.typeName);
}
return `[${type.types.map((member: tae.AnyType) => formatType(member, false)).join(', ')}]`;
}
if (type instanceof tae.TypeParameterNode) {
return type.constraint !== undefined ? formatType(type.constraint, removeUndefined) : type.name;
}
return 'unknown';
}
function getFullyQualifiedName(typeName: tae.TypeName): string {
const nameWithTypeArgs = createNameWithTypeArguments(typeName);
if (!typeName.namespaces || typeName.namespaces.length === 0) {
return nameWithTypeArgs;
}
return `${typeName.namespaces.join('.')}.${nameWithTypeArgs}`;
}
function createNameWithTypeArguments(typeName: tae.TypeName): string {
if (
typeName.typeArguments &&
typeName.typeArguments.length > 0 &&
typeName.typeArguments.some((ta) => ta.equalToDefault === false)
) {
return `${typeName.name}<${typeName.typeArguments.map((ta) => formatType(ta.type, false)).join(', ')}>`;
}
return typeName.name;
}
/**
* Order members so null, undefined, and any come last.
*/
function orderMembers(members: readonly tae.AnyType[]): readonly tae.AnyType[] {
let ordered = pushToEnd(members, 'any');
ordered = pushToEnd(ordered, 'null');
ordered = pushToEnd(ordered, 'undefined');
return ordered;
}
function pushToEnd(members: readonly tae.AnyType[], name: string): readonly tae.AnyType[] {
const index = members.findIndex(
(member: tae.AnyType) => member instanceof tae.IntrinsicNode && member.intrinsic === name
);
if (index !== -1) {
const member = members[index];
return [...members.slice(0, index), ...members.slice(index + 1), member!];
}
return members;
}
function normalizeQuotes(str: string): string {
if (str.startsWith('"') && str.endsWith('"')) {
return str
.replaceAll("'", "\\'")
.replaceAll('\\"', '"')
.replace(/^"(.*)"$/, "'$1'");
}
return str;
}
@@ -0,0 +1,33 @@
import * as ts from 'typescript';
import type { HtmlExtraction } from './types.js';
/** Extract tagName from a Lit element file. */
export function extractHtml(filePath: string, program: ts.Program, componentName: string): HtmlExtraction | null {
const sourceFile = program.getSourceFile(filePath);
if (!sourceFile) return null;
let tagName = '';
function visit(node: ts.Node) {
if (ts.isClassDeclaration(node) && node.name?.text === `${componentName}Element`) {
for (const member of node.members) {
if (
ts.isPropertyDeclaration(member) &&
member.name &&
ts.isIdentifier(member.name) &&
member.name.text === 'tagName' &&
member.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) &&
member.initializer &&
ts.isStringLiteral(member.initializer)
) {
tagName = member.initializer.text;
}
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return tagName ? { tagName } : null;
}
+252
View File
@@ -0,0 +1,252 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { kebabCase } from 'es-toolkit/string';
import * as ts from 'typescript';
import * as tae from 'typescript-api-extractor';
import { extractCore } from './core-handler.js';
import { extractDataAttrs } from './data-attrs-handler.js';
import { extractHtml } from './html-handler.js';
import {
type ComponentApiReference,
ComponentApiReferenceSchema,
type ComponentSource,
type DataAttrDef,
type PropDef,
type StateDef,
} from './types.js';
import { kebabToPascal, sortProps } from './utils.js';
// Magenta prefix - visible on both light and dark terminals
const PREFIX = '\x1b[35m[api-docs-builder]\x1b[0m';
const log = {
info: (...args: unknown[]) => console.log(PREFIX, ...args),
warn: (...args: unknown[]) => console.warn(PREFIX, '\x1b[33mwarn:\x1b[0m', ...args),
error: (...args: unknown[]) => console.error(PREFIX, '\x1b[31merror:\x1b[0m', ...args),
success: (...args: unknown[]) => console.log(PREFIX, ...args),
};
// Paths relative to the monorepo root
const MONOREPO_ROOT = path.resolve(import.meta.dirname, '../../../../');
const CORE_UI_PATH = path.join(MONOREPO_ROOT, 'packages/core/src/core/ui');
const HTML_UI_PATH = path.join(MONOREPO_ROOT, 'packages/html/src/ui');
const OUTPUT_PATH = path.join(MONOREPO_ROOT, 'site/src/content/generated-api-reference');
/**
* Discover all components by scanning the core/ui directory.
*/
function discoverComponents(): ComponentSource[] {
const components: ComponentSource[] = [];
if (!fs.existsSync(CORE_UI_PATH)) {
log.error(`Core UI path not found: ${CORE_UI_PATH}`);
return components;
}
const dirs = fs.readdirSync(CORE_UI_PATH, { withFileTypes: true });
for (const dir of dirs) {
if (!dir.isDirectory()) continue;
const componentName = kebabToPascal(dir.name);
const componentDir = path.join(CORE_UI_PATH, dir.name);
// Look for core file
const coreFile = path.join(componentDir, `${dir.name}-core.ts`);
const dataAttrsFile = path.join(componentDir, `${dir.name}-data-attrs.ts`);
// Look for HTML element file
const htmlFile = path.join(HTML_UI_PATH, dir.name, `${dir.name}-element.ts`);
const source: ComponentSource = {
name: componentName,
};
if (fs.existsSync(coreFile)) {
source.corePath = coreFile;
}
if (fs.existsSync(dataAttrsFile)) {
source.dataAttrsPath = dataAttrsFile;
}
if (fs.existsSync(htmlFile)) {
source.htmlPath = htmlFile;
}
// Only include if we have at least a core file
if (source.corePath) {
components.push(source);
}
}
return components;
}
/**
* Create a TypeScript program for all relevant files.
*/
function createProgram(sources: ComponentSource[]): ts.Program {
const files: string[] = [];
for (const source of sources) {
if (source.corePath) files.push(source.corePath);
if (source.dataAttrsPath) files.push(source.dataAttrsPath);
if (source.htmlPath) files.push(source.htmlPath);
}
// Load base tsconfig - works for all packages since we only need type resolution
const tsconfigPath = path.join(MONOREPO_ROOT, 'tsconfig.base.json');
const config = tae.loadConfig(tsconfigPath);
config.options.rootDir = MONOREPO_ROOT;
return ts.createProgram(files, config.options);
}
/**
* Build the API reference for a single component.
*/
function buildComponentApiReference(source: ComponentSource, program: ts.Program): ComponentApiReference | null {
// Extract from core
const coreData = source.corePath ? extractCore(source.corePath, program, source.name) : null;
if (!coreData) {
log.warn(`No core data found for ${source.name}`);
return null;
}
// Extract data attributes
const dataAttrsData = source.dataAttrsPath ? extractDataAttrs(source.dataAttrsPath, program, source.name) : null;
// Extract HTML element info
const htmlData = source.htmlPath ? extractHtml(source.htmlPath, program, source.name) : null;
// Build props record
const props: Record<string, PropDef> = {};
for (const prop of coreData.props) {
props[prop.name] = {
type: prop.type,
shortType: prop.shortType,
description: prop.description,
default: coreData.defaultProps[prop.name] ?? prop.default,
required: prop.required,
};
// Clean up undefined values
if (props[prop.name]!.shortType === undefined) delete props[prop.name]!.shortType;
if (props[prop.name]!.description === undefined) delete props[prop.name]!.description;
if (props[prop.name]!.default === undefined) delete props[prop.name]!.default;
if (!props[prop.name]!.required) delete props[prop.name]!.required;
}
// Build state record
const state: Record<string, StateDef> = {};
for (const s of coreData.state) {
state[s.name] = {
type: s.type,
description: s.description,
};
if (state[s.name]!.description === undefined) delete state[s.name]!.description;
}
// Build data attributes record
const dataAttributes: Record<string, DataAttrDef> = {};
if (dataAttrsData) {
for (const attr of dataAttrsData.attrs) {
dataAttributes[attr.name] = {
description: attr.description,
};
}
}
// Build result
const result: ComponentApiReference = {
name: source.name,
description: coreData.description,
props,
state,
dataAttributes,
platforms: {},
};
// Add HTML platform info if available
if (htmlData) {
result.platforms.html = {
tagName: htmlData.tagName,
};
}
// Clean up undefined description
if (result.description === undefined) delete result.description;
return result;
}
/**
* Main entry point.
*/
function main() {
// Ensure output directory exists
if (!fs.existsSync(OUTPUT_PATH)) {
fs.mkdirSync(OUTPUT_PATH, { recursive: true });
}
// Discover components
const components = discoverComponents();
if (components.length === 0) {
log.info('No components found.');
return;
}
log.info(`Found ${components.length} components. Processing...`);
// Create TypeScript program
const program = createProgram(components);
// Process each component
let successCount = 0;
let errorCount = 0;
for (const source of components) {
try {
const apiRef = buildComponentApiReference(source, program);
if (apiRef) {
// Sort props
apiRef.props = sortProps(apiRef.props);
// Validate against schema before writing
const validated = ComponentApiReferenceSchema.safeParse(apiRef);
if (!validated.success) {
log.error(`Schema validation failed for ${source.name}:`);
for (const issue of validated.error.issues) {
log.error(` - ${issue.path.join('.')}: ${issue.message}`);
}
errorCount++;
continue;
}
// Write JSON file
const outputFile = path.join(OUTPUT_PATH, `${kebabCase(source.name)}.json`);
const json = `${JSON.stringify(validated.data, null, 2)}\n`;
fs.writeFileSync(outputFile, json);
log.success(`✅ Generated ${path.basename(outputFile)}`);
successCount++;
}
} catch (error) {
log.error(`⚠️ Error processing ${source.name}:`, (error as Error).message);
errorCount++;
}
}
log.info(`Done! Generated ${successCount} files.`);
if (errorCount > 0) {
log.error(`${errorCount} errors occurred.`);
process.exit(1);
}
}
main();
@@ -0,0 +1,336 @@
import * as tae from 'typescript-api-extractor';
import { describe, expect, it, type MockInstance, vi } from 'vitest';
import { extractCore, extractDefaultProps } from '../core-handler.js';
import { createTestProgram } from './test-utils.js';
vi.mock('typescript-api-extractor', async () => {
const actual = await vi.importActual<typeof tae>('typescript-api-extractor');
return {
...actual,
parseFromProgram: vi.fn(),
};
});
const mockParseFromProgram = tae.parseFromProgram as unknown as MockInstance;
describe('extractDefaultProps', () => {
it("extracts string literals with quotes ('label' → \"''\")", () => {
const code = `
export class MockComponentCore {
static readonly defaultProps = {
label: '',
};
}
`;
const program = createTestProgram(code);
const result = extractDefaultProps('test.ts', program, 'MockComponent');
expect(result.label).toBe("''");
});
it("extracts non-empty string literals ('Play' → \"'Play'\")", () => {
const code = `
export class MockComponentCore {
static readonly defaultProps = {
label: 'Play',
};
}
`;
const program = createTestProgram(code);
const result = extractDefaultProps('test.ts', program, 'MockComponent');
expect(result.label).toBe("'Play'");
});
it('extracts booleans (false → "false")', () => {
const code = `
export class MockComponentCore {
static readonly defaultProps = {
disabled: false,
};
}
`;
const program = createTestProgram(code);
const result = extractDefaultProps('test.ts', program, 'MockComponent');
expect(result.disabled).toBe('false');
});
it('extracts booleans (true → "true")', () => {
const code = `
export class MockComponentCore {
static readonly defaultProps = {
enabled: true,
};
}
`;
const program = createTestProgram(code);
const result = extractDefaultProps('test.ts', program, 'MockComponent');
expect(result.enabled).toBe('true');
});
it('extracts null values (null → "null")', () => {
const code = `
export class MockComponentCore {
static readonly defaultProps = {
value: null,
};
}
`;
const program = createTestProgram(code);
const result = extractDefaultProps('test.ts', program, 'MockComponent');
expect(result.value).toBe('null');
});
it('extracts empty arrays ([] → "[]")', () => {
const code = `
export class MockComponentCore {
static readonly defaultProps = {
items: [],
};
}
`;
const program = createTestProgram(code);
const result = extractDefaultProps('test.ts', program, 'MockComponent');
expect(result.items).toBe('[]');
});
it('extracts empty objects ({} → "{}")', () => {
const code = `
export class MockComponentCore {
static readonly defaultProps = {
config: {},
};
}
`;
const program = createTestProgram(code);
const result = extractDefaultProps('test.ts', program, 'MockComponent');
expect(result.config).toBe('{}');
});
it('extracts numeric literals', () => {
const code = `
export class MockComponentCore {
static readonly defaultProps = {
count: 42,
ratio: 1.5,
};
}
`;
const program = createTestProgram(code);
const result = extractDefaultProps('test.ts', program, 'MockComponent');
expect(result.count).toBe('42');
expect(result.ratio).toBe('1.5');
});
it('returns empty object when class not found', () => {
const code = `
export class OtherClass {
static readonly defaultProps = {
label: 'test',
};
}
`;
const program = createTestProgram(code);
const result = extractDefaultProps('test.ts', program, 'MockComponent');
expect(result).toEqual({});
});
it('returns empty object when no defaultProps property', () => {
const code = `
export class MockComponentCore {
static readonly otherProperty = {
label: 'test',
};
}
`;
const program = createTestProgram(code);
const result = extractDefaultProps('test.ts', program, 'MockComponent');
expect(result).toEqual({});
});
it('ignores non-static defaultProps', () => {
const code = `
export class MockComponentCore {
readonly defaultProps = {
label: 'test',
};
}
`;
const program = createTestProgram(code);
const result = extractDefaultProps('test.ts', program, 'MockComponent');
expect(result).toEqual({});
});
});
describe('getPropertyValue', () => {
it('falls back to getText for complex expressions', () => {
const code = `
export class MockComponentCore {
static readonly defaultProps = {
label: \`hello \${world}\`,
};
}
`;
const program = createTestProgram(code);
const result = extractDefaultProps('test.ts', program, 'MockComponent');
// biome-ignore lint/suspicious/noTemplateCurlyInString: testing template literal extraction
expect(result.label).toBe('`hello ${world}`');
});
});
describe('extractCore', () => {
function createMockAst(exports: Array<{ name: string; type: unknown; documentation?: unknown }>) {
return { exports };
}
function createMockObjectNode(properties: tae.PropertyNode[]): tae.ObjectNode {
const node = Object.create(tae.ObjectNode.prototype);
node.properties = properties;
return node;
}
function createMockIntrinsicNode(intrinsic: string): tae.IntrinsicNode {
const node = Object.create(tae.IntrinsicNode.prototype);
node.intrinsic = intrinsic;
node.typeName = undefined;
return node;
}
function createMockPropertyNode(
name: string,
typeName: string,
options: { optional?: boolean; description?: string; defaultValue?: string } = {}
): tae.PropertyNode {
const type = createMockIntrinsicNode(typeName);
const documentation =
options.description !== undefined || options.defaultValue !== undefined
? ({
description: options.description,
defaultValue: options.defaultValue,
hasTag: () => false,
} as unknown as tae.Documentation)
: undefined;
return { name, type, optional: options.optional ?? false, documentation } as tae.PropertyNode;
}
it('returns null when neither Props nor State export is found', () => {
const code = 'export const x = 1;';
const program = createTestProgram(code);
mockParseFromProgram.mockReturnValueOnce(
createMockAst([{ name: 'SomethingElse', type: createMockIntrinsicNode('string') }])
);
const result = extractCore('test.ts', program, 'MockComponent');
expect(result).toBeNull();
});
it('extracts props when propsExport.type is an ObjectNode', () => {
const code = 'export const x = 1;';
const program = createTestProgram(code);
const propsType = createMockObjectNode([
createMockPropertyNode('label', 'string', { optional: true }),
createMockPropertyNode('disabled', 'boolean', { optional: true }),
]);
mockParseFromProgram.mockReturnValueOnce(createMockAst([{ name: 'MockComponentProps', type: propsType }]));
const result = extractCore('test.ts', program, 'MockComponent');
expect(result).not.toBeNull();
expect(result!.props).toHaveLength(2);
expect(result!.props[0]!.name).toBe('label');
expect(result!.props[0]!.type).toBe('string');
expect(result!.props[1]!.name).toBe('disabled');
expect(result!.props[1]!.type).toBe('boolean');
});
it('extracts state when stateExport.type is an ObjectNode', () => {
const code = 'export const x = 1;';
const program = createTestProgram(code);
const stateType = createMockObjectNode([createMockPropertyNode('paused', 'boolean', { optional: false })]);
mockParseFromProgram.mockReturnValueOnce(createMockAst([{ name: 'MockComponentState', type: stateType }]));
const result = extractCore('test.ts', program, 'MockComponent');
expect(result).not.toBeNull();
expect(result!.state).toHaveLength(1);
expect(result!.state[0]!.name).toBe('paused');
expect(result!.state[0]!.type).toBe('boolean');
});
it('extracts description from propsExport documentation', () => {
const code = 'export const x = 1;';
const program = createTestProgram(code);
const propsType = createMockObjectNode([createMockPropertyNode('label', 'string', { optional: true })]);
mockParseFromProgram.mockReturnValueOnce(
createMockAst([
{
name: 'MockComponentProps',
type: propsType,
documentation: { description: 'Props for the play button.' },
},
])
);
const result = extractCore('test.ts', program, 'MockComponent');
expect(result).not.toBeNull();
expect(result!.description).toBe('Props for the play button.');
});
it('skips props when propsExport.type is not an ObjectNode', () => {
const code = 'export const x = 1;';
const program = createTestProgram(code);
mockParseFromProgram.mockReturnValueOnce(
createMockAst([
{ name: 'MockComponentProps', type: createMockIntrinsicNode('string') },
{ name: 'MockComponentState', type: createMockObjectNode([createMockPropertyNode('paused', 'boolean')]) },
])
);
const result = extractCore('test.ts', program, 'MockComponent');
expect(result).not.toBeNull();
expect(result!.props).toHaveLength(0);
expect(result!.state).toHaveLength(1);
});
it('merges defaultProps from extractDefaultProps into result', () => {
const code = `
export class MockComponentCore {
static readonly defaultProps = {
label: 'Play',
};
}
`;
const program = createTestProgram(code);
const propsType = createMockObjectNode([createMockPropertyNode('label', 'string', { optional: true })]);
mockParseFromProgram.mockReturnValueOnce(createMockAst([{ name: 'MockComponentProps', type: propsType }]));
const result = extractCore('test.ts', program, 'MockComponent');
expect(result).not.toBeNull();
expect(result!.defaultProps).toEqual({ label: "'Play'" });
});
});
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest';
import { extractDataAttrs } from '../data-attrs-handler.js';
import { createTestProgram } from './test-utils.js';
describe('extractDataAttrs', () => {
it('extracts from {Name}DataAttrs constant', () => {
const code = `
export const MockComponentDataAttrs = {
active: 'data-active',
disabled: 'data-disabled',
} as const;
`;
const program = createTestProgram(code);
const result = extractDataAttrs('test.ts', program, 'MockComponent');
expect(result).not.toBeNull();
expect(result!.attrs).toHaveLength(2);
expect(result!.attrs[0]!.name).toBe('data-active');
expect(result!.attrs[1]!.name).toBe('data-disabled');
});
it('extracts from {Name}DataAttributes constant (alternate naming)', () => {
const code = `
export const MockComponentDataAttributes = {
paused: 'data-paused',
} as const;
`;
const program = createTestProgram(code);
const result = extractDataAttrs('test.ts', program, 'MockComponent');
expect(result).not.toBeNull();
expect(result!.attrs).toHaveLength(1);
expect(result!.attrs[0]!.name).toBe('data-paused');
});
it('extracts JSDoc comments for each property', () => {
const code = `
export const MockComponentDataAttrs = {
/** Present when the component is active. */
active: 'data-active',
/** Present when the component is disabled. */
disabled: 'data-disabled',
} as const;
`;
const program = createTestProgram(code);
const result = extractDataAttrs('test.ts', program, 'MockComponent');
expect(result).not.toBeNull();
expect(result!.attrs[0]!.description).toBe('Present when the component is active.');
expect(result!.attrs[1]!.description).toBe('Present when the component is disabled.');
});
it('handles object without as const', () => {
const code = `
export const MockComponentDataAttrs = {
value: 'data-value',
};
`;
const program = createTestProgram(code);
const result = extractDataAttrs('test.ts', program, 'MockComponent');
expect(result).not.toBeNull();
expect(result!.attrs).toHaveLength(1);
});
it('returns null when constant not found', () => {
const code = `
export const OtherConstant = {
value: 'data-value',
};
`;
const program = createTestProgram(code);
const result = extractDataAttrs('test.ts', program, 'MockComponent');
expect(result).toBeNull();
});
it('extracts single-line // comments for properties', () => {
const code = `
export const MockComponentDataAttrs = {
// Present when the component is focused.
focused: 'data-focused',
} as const;
`;
const program = createTestProgram(code);
const result = extractDataAttrs('test.ts', program, 'MockComponent');
expect(result).not.toBeNull();
expect(result!.attrs[0]!.description).toBe('Present when the component is focused.');
});
it('falls back to data-{key} when value is not a string literal', () => {
const code = `
const PREFIX = 'data-';
export const MockComponentDataAttrs = {
active: PREFIX + 'active',
};
`;
const program = createTestProgram(code);
const result = extractDataAttrs('test.ts', program, 'MockComponent');
expect(result).not.toBeNull();
expect(result!.attrs[0]!.name).toBe('data-active');
});
});
@@ -0,0 +1,490 @@
import * as tae from 'typescript-api-extractor';
import { describe, expect, it } from 'vitest';
import { formatProperties, formatType, getShortPropType } from '../formatter';
describe('getShortPropType', () => {
it("returns 'function' for callback props (onX with =>)", () => {
expect(getShortPropType('onClick', '(event: Event) => void')).toBe('function');
expect(getShortPropType('onChange', '(value: string) => void')).toBe('function');
});
it("returns 'function' for getter props (getX with =>)", () => {
expect(getShortPropType('getValue', '() => string')).toBe('function');
expect(getShortPropType('getState', '() => State')).toBe('function');
});
it("returns 'string | function' for className with =>", () => {
expect(getShortPropType('className', 'string | ((state: State) => string)')).toBe('string | function');
});
it("returns 'CSSProperties | function' for style with =>", () => {
expect(getShortPropType('style', 'CSSProperties | ((state: State) => CSSProperties)')).toBe(
'CSSProperties | function'
);
});
it("returns 'ReactElement | function' for render with =>", () => {
expect(getShortPropType('render', 'ReactElement | ((state: State) => ReactElement)')).toBe(
'ReactElement | function'
);
});
it('returns undefined for simple types (boolean, string, number)', () => {
expect(getShortPropType('disabled', 'boolean')).toBeUndefined();
expect(getShortPropType('label', 'string')).toBeUndefined();
expect(getShortPropType('count', 'number')).toBeUndefined();
});
it('returns undefined for short unions (< 3 members and < 40 chars)', () => {
expect(getShortPropType('size', "'small' | 'large'")).toBeUndefined();
expect(getShortPropType('value', 'string | number')).toBeUndefined();
});
it("returns 'type | function' for unions containing functions", () => {
const type = "string | ((state: State) => string) | 'auto'";
expect(getShortPropType('label', type)).toBe("string | 'auto' | function");
});
it('returns undefined for complex unions (NOT "Union")', () => {
// Complex union with 3+ members, no function
const complexUnion = "'small' | 'medium' | 'large' | 'xlarge'";
expect(getShortPropType('size', complexUnion)).toBeUndefined();
});
});
describe('formatProperties', () => {
it('skips ref prop', () => {
const props: tae.PropertyNode[] = [
createPropertyNode('label', 'string', { optional: true }),
createPropertyNode('ref', 'any', { optional: true }),
];
const result = formatProperties(props);
expect(result).toHaveProperty('label');
expect(result).not.toHaveProperty('ref');
});
it('skips props with @ignore JSDoc tag', () => {
const props: tae.PropertyNode[] = [
createPropertyNode('label', 'string', { optional: true }),
createPropertyNode('ignoredProp', 'string', { optional: true, hasIgnoreTag: true }),
];
const result = formatProperties(props);
expect(result).toHaveProperty('label');
expect(result).not.toHaveProperty('ignoredProp');
});
it('sets required: true for non-optional props', () => {
const props: tae.PropertyNode[] = [
createPropertyNode('required', 'string', { optional: false }),
createPropertyNode('optional', 'string', { optional: true }),
];
const result = formatProperties(props);
expect(result.required?.required).toBe(true);
expect(result.optional?.required).toBeUndefined();
});
it('cleans up undefined values from result', () => {
const props: tae.PropertyNode[] = [createPropertyNode('simple', 'boolean', { optional: true })];
const result = formatProperties(props);
expect(result.simple).toEqual({ type: 'boolean' });
expect(Object.keys(result.simple!)).not.toContain('shortType');
expect(Object.keys(result.simple!)).not.toContain('default');
expect(Object.keys(result.simple!)).not.toContain('required');
});
it('passes through description from documentation', () => {
const props: tae.PropertyNode[] = [
createPropertyNode('label', 'string', { optional: true, description: 'The button label.' }),
];
const result = formatProperties(props);
expect(result.label?.description).toBe('The button label.');
});
it('passes through default from documentation.defaultValue', () => {
const props: tae.PropertyNode[] = [
createPropertyNode('disabled', 'boolean', { optional: true, defaultValue: 'false' }),
];
const result = formatProperties(props);
expect(result.disabled?.default).toBe('false');
});
it('sets shortType for callback props', () => {
const fnType = createFunctionNode([
{
parameters: [
{
name: 'event',
type: createIntrinsicNode('Event'),
optional: false,
documentation: undefined,
defaultValue: undefined,
} as tae.Parameter,
],
returnValueType: createIntrinsicNode('void'),
} as tae.CallSignature,
]);
const prop = {
name: 'onClick',
type: fnType,
optional: true,
documentation: undefined,
} as tae.PropertyNode;
const result = formatProperties([prop]);
expect(result.onClick?.shortType).toBe('function');
});
});
describe('formatType', () => {
it('formats IntrinsicNode (boolean, string, number)', () => {
const boolNode = createIntrinsicNode('boolean');
const strNode = createIntrinsicNode('string');
const numNode = createIntrinsicNode('number');
expect(formatType(boolNode, false)).toBe('boolean');
expect(formatType(strNode, false)).toBe('string');
expect(formatType(numNode, false)).toBe('number');
});
it('formats UnionNode and removes undefined when optional', () => {
const unionNode = createUnionNode([createIntrinsicNode('string'), createIntrinsicNode('undefined')]);
expect(formatType(unionNode, true)).toBe('string');
expect(formatType(unionNode, false)).toBe('string | undefined');
});
it('flattens nested unions', () => {
const innerUnion = createUnionNode([createIntrinsicNode('string'), createIntrinsicNode('number')]);
const outerUnion = createUnionNode([innerUnion, createIntrinsicNode('boolean')]);
expect(formatType(outerUnion, false)).toBe('string | number | boolean');
});
it('formats ObjectNode with properties', () => {
const objNode = createObjectNode([
{ name: 'x', type: createIntrinsicNode('number'), optional: false },
{ name: 'y', type: createIntrinsicNode('number'), optional: true },
]);
expect(formatType(objNode, false)).toBe('{ x: number; y?: number }');
});
it('formats ArrayNode with parentheses for complex element types', () => {
const simpleArray = createArrayNode(createIntrinsicNode('string'));
const complexArray = createArrayNode(
createUnionNode([createIntrinsicNode('string'), createIntrinsicNode('number')])
);
expect(formatType(simpleArray, false)).toBe('string[]');
expect(formatType(complexArray, false)).toBe('(string | number)[]');
});
it('orders members with null/undefined/any last', () => {
const unionNode = createUnionNode([
createIntrinsicNode('null'),
createIntrinsicNode('string'),
createIntrinsicNode('undefined'),
createIntrinsicNode('number'),
]);
expect(formatType(unionNode, false)).toBe('string | number | null | undefined');
});
it('normalizes quotes (double to single)', () => {
const literalNode = createLiteralNode('"hello"');
expect(formatType(literalNode, false)).toBe("'hello'");
});
// --- ExternalTypeNode ---
it('formats ExternalTypeNode ReactElement to just ReactElement', () => {
const node = createExternalTypeNode('ReactElement', undefined, [
{ type: createIntrinsicNode('Props'), equalToDefault: false },
]);
expect(formatType(node, false)).toBe('ReactElement');
});
it('formats ExternalTypeNode with React namespace by stripping namespace', () => {
const node = createExternalTypeNode('CSSProperties', ['React']);
expect(formatType(node, false)).toBe('CSSProperties');
});
it('formats ExternalTypeNode with fully qualified name', () => {
const node = createExternalTypeNode('Baz', ['Foo', 'Bar']);
expect(formatType(node, false)).toBe('Foo.Bar.Baz');
});
it('formats ExternalTypeNode with non-default type arguments', () => {
const node = createExternalTypeNode('Map', undefined, [
{ type: createIntrinsicNode('string'), equalToDefault: false },
{ type: createIntrinsicNode('number'), equalToDefault: false },
]);
expect(formatType(node, false)).toBe('Map<string, number>');
});
// --- IntersectionNode ---
it('formats IntersectionNode without typeName', () => {
const node = createIntersectionNode([createIntrinsicNode('string'), createIntrinsicNode('number')]);
expect(formatType(node, false)).toBe('string & number');
});
it('formats IntersectionNode with typeName as fully qualified name', () => {
const typeName = createTypeName('Combined');
const node = createIntersectionNode([createIntrinsicNode('string'), createIntrinsicNode('number')], typeName);
expect(formatType(node, false)).toBe('Combined');
});
// --- FunctionNode ---
it('formats FunctionNode without typeName', () => {
const node = createFunctionNode([
{
parameters: [
{
name: 'x',
type: createIntrinsicNode('string'),
optional: false,
documentation: undefined,
defaultValue: undefined,
} as tae.Parameter,
],
returnValueType: createIntrinsicNode('void'),
} as tae.CallSignature,
]);
expect(formatType(node, false)).toBe('((x: string) => void)');
});
it('formats FunctionNode with typeName as fully qualified name', () => {
const typeName = createTypeName('MyHandler');
const node = createFunctionNode(
[
{
parameters: [],
returnValueType: createIntrinsicNode('void'),
} as tae.CallSignature,
],
typeName
);
expect(formatType(node, false)).toBe('MyHandler');
});
// --- TupleNode ---
it('formats TupleNode without typeName', () => {
const node = createTupleNode([createIntrinsicNode('string'), createIntrinsicNode('number')]);
expect(formatType(node, false)).toBe('[string, number]');
});
it('formats TupleNode with typeName as fully qualified name', () => {
const typeName = createTypeName('Pair');
const node = createTupleNode([createIntrinsicNode('string'), createIntrinsicNode('number')], typeName);
expect(formatType(node, false)).toBe('Pair');
});
// --- TypeParameterNode ---
it('formats TypeParameterNode with constraint', () => {
const node = createTypeParameterNode('T', createIntrinsicNode('string'));
expect(formatType(node, false)).toBe('string');
});
it('formats TypeParameterNode without constraint returns the name', () => {
const node = createTypeParameterNode('T');
expect(formatType(node, false)).toBe('T');
});
// --- UnionNode with typeName ---
it('formats UnionNode with typeName as fully qualified name', () => {
const typeName = createTypeName('Status');
const node = createUnionNode([createIntrinsicNode('string'), createIntrinsicNode('number')], typeName);
expect(formatType(node, false)).toBe('Status');
});
// --- ObjectNode edge cases ---
it('formats empty ObjectNode as {}', () => {
const node = createObjectNode([]);
expect(formatType(node, false)).toBe('{}');
});
// --- Unknown node ---
it('returns unknown for unrecognized node type', () => {
const node = {} as tae.AnyType;
expect(formatType(node, false)).toBe('unknown');
});
// --- Union dedup ---
it('deduplicates union members via uniq', () => {
const node = createUnionNode([
createIntrinsicNode('string'),
createIntrinsicNode('string'),
createIntrinsicNode('number'),
]);
expect(formatType(node, false)).toBe('string | number');
});
// --- TypeParameterNode constraint flattening in union ---
it('flattens TypeParameterNode constraint in union', () => {
const constraintUnion = createUnionNode([createIntrinsicNode('string'), createIntrinsicNode('number')]);
const typeParam = createTypeParameterNode('T', constraintUnion);
const union = createUnionNode([typeParam, createIntrinsicNode('boolean')]);
expect(formatType(union, false)).toBe('string | number | boolean');
});
});
// --- Helper factories ---
function createPropertyNode(
name: string,
typeName: string,
options: { optional?: boolean; hasIgnoreTag?: boolean; description?: string; defaultValue?: string } = {}
): tae.PropertyNode {
const type = createIntrinsicNode(typeName);
const documentation =
options.hasIgnoreTag || options.description !== undefined || options.defaultValue !== undefined
? createDocumentation(options)
: undefined;
return {
name,
type,
optional: options.optional ?? false,
documentation,
} as tae.PropertyNode;
}
function createDocumentation(options: {
hasIgnoreTag?: boolean;
description?: string;
defaultValue?: string;
}): tae.Documentation {
return {
description: options.description,
defaultValue: options.defaultValue,
hasTag: (tag: string) => (tag === 'ignore' ? (options.hasIgnoreTag ?? false) : false),
} as unknown as tae.Documentation;
}
function createIntrinsicNode(intrinsic: string): tae.IntrinsicNode {
const node = Object.create(tae.IntrinsicNode.prototype);
node.intrinsic = intrinsic;
node.typeName = undefined;
return node;
}
function createUnionNode(types: tae.AnyType[], typeName?: tae.TypeName): tae.UnionNode {
const node = Object.create(tae.UnionNode.prototype);
node.types = types;
node.typeName = typeName;
return node;
}
function createObjectNode(
properties: Array<{ name: string; type: tae.AnyType; optional: boolean }>,
typeName?: tae.TypeName
): tae.ObjectNode {
const node = Object.create(tae.ObjectNode.prototype);
node.properties = properties.map((p) => ({
name: p.name,
type: p.type,
optional: p.optional,
}));
node.typeName = typeName;
return node;
}
function createArrayNode(elementType: tae.AnyType): tae.ArrayNode {
const node = Object.create(tae.ArrayNode.prototype);
node.elementType = elementType;
return node;
}
function createLiteralNode(value: string): tae.LiteralNode {
const node = Object.create(tae.LiteralNode.prototype);
node.value = value;
return node;
}
function createExternalTypeNode(
name: string,
namespaces?: string[],
typeArguments?: Array<{ type: tae.AnyType; equalToDefault: boolean }>
): tae.ExternalTypeNode {
const node = Object.create(tae.ExternalTypeNode.prototype);
node.typeName = createTypeName(name, namespaces, typeArguments);
return node;
}
function createIntersectionNode(types: tae.AnyType[], typeName?: tae.TypeName): tae.IntersectionNode {
const node = Object.create(tae.IntersectionNode.prototype);
node.types = types;
node.typeName = typeName;
node.properties = [];
return node;
}
function createFunctionNode(callSignatures: tae.CallSignature[], typeName?: tae.TypeName): tae.FunctionNode {
const node = Object.create(tae.FunctionNode.prototype);
node.callSignatures = callSignatures;
node.typeName = typeName;
return node;
}
function createTupleNode(types: tae.AnyType[], typeName?: tae.TypeName): tae.TupleNode {
const node = Object.create(tae.TupleNode.prototype);
node.types = types;
node.typeName = typeName;
return node;
}
function createTypeParameterNode(name: string, constraint?: tae.AnyType): tae.TypeParameterNode {
const node = Object.create(tae.TypeParameterNode.prototype);
node.name = name;
node.constraint = constraint;
return node;
}
function createTypeName(
name: string,
namespaces?: string[],
typeArguments?: Array<{ type: tae.AnyType; equalToDefault: boolean }>
): tae.TypeName {
return new tae.TypeName(name, namespaces, typeArguments);
}
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import { extractHtml } from '../html-handler.js';
import { createTestProgram } from './test-utils.js';
describe('extractHtml', () => {
it('extracts tagName from {Name}Element class', () => {
const code = `
export class MockComponentElement {
static readonly tagName = 'media-mock-component';
}
`;
const program = createTestProgram(code);
const result = extractHtml('test.ts', program, 'MockComponent');
expect(result).not.toBeNull();
expect(result!.tagName).toBe('media-mock-component');
});
it('extracts tagName without readonly modifier', () => {
const code = `
export class MockComponentElement {
static tagName = 'media-mock-component';
}
`;
const program = createTestProgram(code);
const result = extractHtml('test.ts', program, 'MockComponent');
expect(result).not.toBeNull();
expect(result!.tagName).toBe('media-mock-component');
});
it('returns null when Element class not found', () => {
const code = `
export class OtherClass {
static readonly tagName = 'media-other';
}
`;
const program = createTestProgram(code);
const result = extractHtml('test.ts', program, 'MockComponent');
expect(result).toBeNull();
});
it('returns null when tagName not static', () => {
const code = `
export class MockComponentElement {
readonly tagName = 'media-mock-component';
}
`;
const program = createTestProgram(code);
const result = extractHtml('test.ts', program, 'MockComponent');
expect(result).toBeNull();
});
it('returns null when tagName is not a string literal', () => {
const code = `
const TAG = 'media-mock-component';
export class MockComponentElement {
static readonly tagName = TAG;
}
`;
const program = createTestProgram(code);
const result = extractHtml('test.ts', program, 'MockComponent');
expect(result).toBeNull();
});
it('returns null when no tagName property exists', () => {
const code = `
export class MockComponentElement {
static readonly otherProperty = 'value';
}
`;
const program = createTestProgram(code);
const result = extractHtml('test.ts', program, 'MockComponent');
expect(result).toBeNull();
});
});
@@ -0,0 +1,13 @@
import * as ts from 'typescript';
/** Only suitable for AST-walking tests — no type resolution. */
export function createTestProgram(code: string, fileName = 'test.ts'): ts.Program {
const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS);
const compilerHost = ts.createCompilerHost({});
const originalGetSourceFile = compilerHost.getSourceFile;
compilerHost.getSourceFile = (name, ...args) => {
return name === fileName ? sourceFile : originalGetSourceFile.call(compilerHost, name, ...args);
};
compilerHost.fileExists = (name) => name === fileName;
return ts.createProgram([fileName], {}, compilerHost);
}
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { kebabToPascal, sortProps } from '../utils.js';
describe('kebabToPascal', () => {
it("converts 'play-button' to 'PlayButton'", () => {
expect(kebabToPascal('play-button')).toBe('PlayButton');
});
it("converts 'slider' to 'Slider'", () => {
expect(kebabToPascal('slider')).toBe('Slider');
});
it("converts 'time-display-current' to 'TimeDisplayCurrent'", () => {
expect(kebabToPascal('time-display-current')).toBe('TimeDisplayCurrent');
});
});
describe('sortProps', () => {
it('sorts required props before optional props', () => {
const props = {
optional: { type: 'string' },
required: { type: 'string', required: true as const },
};
const result = sortProps(props);
const keys = Object.keys(result);
expect(keys).toEqual(['required', 'optional']);
});
it('sorts alphabetically within each group', () => {
const props = {
zebra: { type: 'string', required: true as const },
apple: { type: 'string', required: true as const },
mango: { type: 'string' },
banana: { type: 'string' },
};
const result = sortProps(props);
const keys = Object.keys(result);
expect(keys).toEqual(['apple', 'zebra', 'banana', 'mango']);
});
it('keeps all-optional props alphabetical', () => {
const props = {
charlie: { type: 'string' },
alpha: { type: 'string' },
bravo: { type: 'string' },
};
const result = sortProps(props);
const keys = Object.keys(result);
expect(keys).toEqual(['alpha', 'bravo', 'charlie']);
});
it('keeps all-required props alphabetical', () => {
const props = {
charlie: { type: 'string', required: true as const },
alpha: { type: 'string', required: true as const },
bravo: { type: 'string', required: true as const },
};
const result = sortProps(props);
const keys = Object.keys(result);
expect(keys).toEqual(['alpha', 'bravo', 'charlie']);
});
});
@@ -0,0 +1,59 @@
/**
* Re-export types from the shared schema.
* The shared schema in src/types/api-reference.ts is the single source of truth.
*/
export type {
ComponentApiReference,
DataAttrDef,
PropDef,
StateDef,
} from '../../../src/types/api-reference.js';
export { ComponentApiReferenceSchema } from '../../../src/types/api-reference.js';
/**
* Source file locations for a component across packages.
*/
export interface ComponentSource {
/** PascalCase component name (e.g., PlayButton) */
name: string;
/** Path to core file (e.g., packages/core/src/core/ui/play-button/play-button-core.ts) */
corePath?: string;
/** Path to data attrs file */
dataAttrsPath?: string;
/** Path to HTML element file */
htmlPath?: string;
}
/**
* Extracted property from TypeScript analysis.
*/
export interface ExtractedProp {
name: string;
type: string;
shortType?: string;
description?: string;
default?: string;
required?: boolean;
}
/**
* Extraction result from core package.
*/
export interface CoreExtraction {
description?: string;
props: ExtractedProp[];
state: ExtractedProp[];
defaultProps: Record<string, string>;
}
/**
* Extraction result from data attributes file.
*/
export interface DataAttrsExtraction {
attrs: Array<{ name: string; description: string }>;
}
export interface HtmlExtraction {
tagName: string;
}
@@ -0,0 +1,26 @@
import type { PropDef } from './types.js';
export function kebabToPascal(str: string): string {
return str
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
}
export function sortProps(props: Record<string, PropDef>): Record<string, PropDef> {
const entries = Object.entries(props);
entries.sort((a, b) => {
// Required first
const aRequired = a[1].required ?? false;
const bRequired = b[1].required ?? false;
if (aRequired && !bRequired) return -1;
if (!aRequired && bRequired) return 1;
// Then alphabetical
return a[0].localeCompare(b[0]);
});
return Object.fromEntries(entries);
}