mirror of
https://github.com/zoriya/v10.git
synced 2026-08-13 01:19:44 +00:00
feat(site): extract api reference from components (#464)
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user