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,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']);
});
});