mirror of
https://github.com/zoriya/v10.git
synced 2026-08-13 17:40:12 +00:00
feat(site): generated multipart component api reference (#468)
This commit is contained in:
@@ -314,6 +314,48 @@ describe('extractCore', () => {
|
||||
expect(result!.state).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('expands type aliases via allExports', () => {
|
||||
const code = 'export const x = 1;';
|
||||
const program = createTestProgram(code);
|
||||
|
||||
// Create an ExternalTypeNode referencing 'TimeType'
|
||||
const externalTypeNode = Object.create(tae.ExternalTypeNode.prototype);
|
||||
externalTypeNode.typeName = new tae.TypeName('TimeType');
|
||||
|
||||
const propsType = createMockObjectNode([
|
||||
{
|
||||
name: 'type',
|
||||
type: externalTypeNode,
|
||||
optional: true,
|
||||
documentation: undefined,
|
||||
} as tae.PropertyNode,
|
||||
]);
|
||||
|
||||
// TimeType is also in the exports list with its resolved union type
|
||||
const timeTypeLiteral1 = Object.create(tae.LiteralNode.prototype);
|
||||
timeTypeLiteral1.value = "'current'";
|
||||
const timeTypeLiteral2 = Object.create(tae.LiteralNode.prototype);
|
||||
timeTypeLiteral2.value = "'duration'";
|
||||
const timeTypeLiteral3 = Object.create(tae.LiteralNode.prototype);
|
||||
timeTypeLiteral3.value = "'remaining'";
|
||||
const timeTypeUnion = Object.create(tae.UnionNode.prototype);
|
||||
timeTypeUnion.types = [timeTypeLiteral1, timeTypeLiteral2, timeTypeLiteral3];
|
||||
timeTypeUnion.typeName = undefined;
|
||||
|
||||
mockParseFromProgram.mockReturnValueOnce(
|
||||
createMockAst([
|
||||
{ name: 'MockComponentProps', type: propsType },
|
||||
{ name: 'TimeType', type: timeTypeUnion },
|
||||
])
|
||||
);
|
||||
|
||||
const result = extractCore('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.props[0]!.name).toBe('type');
|
||||
expect(result!.props[0]!.type).toBe("'current' | 'duration' | 'remaining'");
|
||||
});
|
||||
|
||||
it('merges defaultProps from extractDefaultProps into result', () => {
|
||||
const code = `
|
||||
export class MockComponentCore {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as tae from 'typescript-api-extractor';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatProperties, formatType, getShortPropType } from '../formatter';
|
||||
import { formatDetailedType, formatProperties, formatType, getShortPropType } from '../formatter';
|
||||
|
||||
describe('getShortPropType', () => {
|
||||
it("returns 'function' for callback props (onX with =>)", () => {
|
||||
@@ -40,6 +40,11 @@ describe('getShortPropType', () => {
|
||||
expect(getShortPropType('value', 'string | number')).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns 'type | function' for short callback unions (< 40 chars, 2 members)", () => {
|
||||
const type = 'string | ((state: TimeState) => string)';
|
||||
expect(getShortPropType('label', type)).toBe('string | function');
|
||||
});
|
||||
|
||||
it("returns 'type | function' for unions containing functions", () => {
|
||||
const type = "string | ((state: State) => string) | 'auto'";
|
||||
expect(getShortPropType('label', type)).toBe("string | 'auto' | function");
|
||||
@@ -120,6 +125,32 @@ describe('formatProperties', () => {
|
||||
expect(result.disabled?.default).toBe('false');
|
||||
});
|
||||
|
||||
it('expands type aliases when allExports is provided', () => {
|
||||
// Create a property with an ExternalTypeNode referencing 'TimeType'
|
||||
const externalType = createExternalTypeNode('TimeType');
|
||||
const prop = {
|
||||
name: 'type',
|
||||
type: externalType,
|
||||
optional: true,
|
||||
documentation: undefined,
|
||||
} as tae.PropertyNode;
|
||||
|
||||
// Create allExports with TimeType resolved to a union
|
||||
const timeTypeExport = {
|
||||
name: 'TimeType',
|
||||
type: createUnionNode([
|
||||
createLiteralNode("'current'"),
|
||||
createLiteralNode("'duration'"),
|
||||
createLiteralNode("'remaining'"),
|
||||
]),
|
||||
documentation: undefined,
|
||||
} as tae.ExportNode;
|
||||
|
||||
const result = formatProperties([prop], [timeTypeExport]);
|
||||
|
||||
expect(result.type?.type).toBe("'current' | 'duration' | 'remaining'");
|
||||
});
|
||||
|
||||
it('sets shortType for callback props', () => {
|
||||
const fnType = createFunctionNode([
|
||||
{
|
||||
@@ -369,6 +400,93 @@ describe('formatType', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDetailedType', () => {
|
||||
it('expands ExternalTypeNode when found in allExports', () => {
|
||||
const externalType = createExternalTypeNode('TimeType');
|
||||
const resolvedUnion = createUnionNode([
|
||||
createLiteralNode("'current'"),
|
||||
createLiteralNode("'duration'"),
|
||||
createLiteralNode("'remaining'"),
|
||||
]);
|
||||
const allExports = [{ name: 'TimeType', type: resolvedUnion, documentation: undefined }] as tae.ExportNode[];
|
||||
|
||||
expect(formatDetailedType(externalType, allExports, false)).toBe("'current' | 'duration' | 'remaining'");
|
||||
});
|
||||
|
||||
it('returns qualified name when not found in allExports', () => {
|
||||
const externalType = createExternalTypeNode('UnknownType');
|
||||
|
||||
expect(formatDetailedType(externalType, [], false)).toBe('UnknownType');
|
||||
});
|
||||
|
||||
it('skips re-exported types (reexportedFrom is set)', () => {
|
||||
const externalType = createExternalTypeNode('TimeType');
|
||||
const resolvedUnion = createUnionNode([createLiteralNode("'current'"), createLiteralNode("'duration'")]);
|
||||
const reexport = {
|
||||
name: 'TimeType',
|
||||
type: resolvedUnion,
|
||||
documentation: undefined,
|
||||
reexportedFrom: 'OriginalTimeType',
|
||||
} as unknown as tae.ExportNode;
|
||||
|
||||
expect(formatDetailedType(externalType, [reexport], false)).toBe('TimeType');
|
||||
});
|
||||
|
||||
it('expands UnionNode with typeName (ignores alias, expands members)', () => {
|
||||
const typeName = createTypeName('VolumeLevel');
|
||||
const union = createUnionNode(
|
||||
[
|
||||
createLiteralNode("'off'"),
|
||||
createLiteralNode("'low'"),
|
||||
createLiteralNode("'medium'"),
|
||||
createLiteralNode("'high'"),
|
||||
],
|
||||
typeName
|
||||
);
|
||||
const allExports: tae.ExportNode[] = [];
|
||||
|
||||
expect(formatDetailedType(union, allExports, false)).toBe("'off' | 'low' | 'medium' | 'high'");
|
||||
});
|
||||
|
||||
it('handles removeUndefined for optional props', () => {
|
||||
const union = createUnionNode([createIntrinsicNode('string'), createIntrinsicNode('undefined')]);
|
||||
|
||||
expect(formatDetailedType(union, [], true)).toBe('string');
|
||||
expect(formatDetailedType(union, [], false)).toBe('string | undefined');
|
||||
});
|
||||
|
||||
it('prevents infinite recursion via visited set', () => {
|
||||
const externalType = createExternalTypeNode('SelfRef');
|
||||
// SelfRef resolves to itself
|
||||
const selfRefExport = {
|
||||
name: 'SelfRef',
|
||||
type: createExternalTypeNode('SelfRef'),
|
||||
documentation: undefined,
|
||||
} as tae.ExportNode;
|
||||
|
||||
// Should not stack overflow; falls back to formatType
|
||||
expect(formatDetailedType(externalType, [selfRefExport], false)).toBe('SelfRef');
|
||||
});
|
||||
|
||||
it('expands IntersectionNode members', () => {
|
||||
const externalA = createExternalTypeNode('BaseProps');
|
||||
const basePropsExport = {
|
||||
name: 'BaseProps',
|
||||
type: createObjectNode([{ name: 'id', type: createIntrinsicNode('string'), optional: false }]),
|
||||
documentation: undefined,
|
||||
} as tae.ExportNode;
|
||||
const intersection = createIntersectionNode([externalA, createIntrinsicNode('number')]);
|
||||
|
||||
expect(formatDetailedType(intersection, [basePropsExport], false)).toBe('{ id: string } & number');
|
||||
});
|
||||
|
||||
it('delegates non-expandable nodes to formatType', () => {
|
||||
const intrinsic = createIntrinsicNode('boolean');
|
||||
|
||||
expect(formatDetailedType(intrinsic, [], false)).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Helper factories ---
|
||||
|
||||
function createPropertyNode(
|
||||
|
||||
@@ -77,4 +77,29 @@ describe('extractHtml', () => {
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts tagName using custom elementName override', () => {
|
||||
const code = `
|
||||
export class TimeGroupElement {
|
||||
static readonly tagName = 'media-time-group';
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractHtml('test.ts', program, 'Time', 'TimeGroupElement');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.tagName).toBe('media-time-group');
|
||||
});
|
||||
|
||||
it('returns null when elementName override does not match', () => {
|
||||
const code = `
|
||||
export class TimeGroupElement {
|
||||
static readonly tagName = 'media-time-group';
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractHtml('test.ts', program, 'Time', 'TimeSeparatorElement');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import * as tae from 'typescript-api-extractor';
|
||||
import { describe, expect, it, type MockInstance, vi } from 'vitest';
|
||||
import { extractPartDescription, extractParts } from '../parts-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('extractParts', () => {
|
||||
it('extracts value exports from index.parts.ts', () => {
|
||||
const code = `
|
||||
export { Group, type GroupProps } from './time-group';
|
||||
export { Separator, type SeparatorProps } from './time-separator';
|
||||
export { Value, type ValueProps } from './time-value';
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractParts('test.ts', program);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'Group', source: './time-group' },
|
||||
{ name: 'Separator', source: './time-separator' },
|
||||
{ name: 'Value', source: './time-value' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters out type-only exports', () => {
|
||||
const code = `
|
||||
export { Group, type GroupProps } from './time-group';
|
||||
export type { SomeType } from './types';
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractParts('test.ts', program);
|
||||
|
||||
expect(result).toEqual([{ name: 'Group', source: './time-group' }]);
|
||||
});
|
||||
|
||||
it('returns empty array for file with no exports', () => {
|
||||
const code = `const x = 1;`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractParts('test.ts', program);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles multiple value exports from same source', () => {
|
||||
const code = `
|
||||
export { Foo, Bar } from './source';
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractParts('test.ts', program);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'Foo', source: './source' },
|
||||
{ name: 'Bar', source: './source' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips type-only specifiers within a value export declaration', () => {
|
||||
const code = `
|
||||
export { Value, type ValueProps, type ValueState } from './time-value';
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractParts('test.ts', program);
|
||||
|
||||
expect(result).toEqual([{ name: 'Value', source: './time-value' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractPartDescription', () => {
|
||||
it('extracts JSDoc description from a named export', () => {
|
||||
const program = createTestProgram('');
|
||||
mockParseFromProgram.mockReturnValue({
|
||||
exports: [
|
||||
{
|
||||
name: 'Value',
|
||||
documentation: {
|
||||
description: 'Displays a formatted time value (current, duration, or remaining).',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = extractPartDescription('test.tsx', program, 'Value');
|
||||
|
||||
expect(result).toBe('Displays a formatted time value (current, duration, or remaining).');
|
||||
});
|
||||
|
||||
it('strips @example blocks from description', () => {
|
||||
const program = createTestProgram('');
|
||||
mockParseFromProgram.mockReturnValue({
|
||||
exports: [
|
||||
{
|
||||
name: 'Group',
|
||||
documentation: {
|
||||
description: 'Container for composed time displays.\n\n@example\n```tsx\n<Time.Group />\n```',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = extractPartDescription('test.tsx', program, 'Group');
|
||||
|
||||
expect(result).toBe('Container for composed time displays.');
|
||||
});
|
||||
|
||||
it('returns undefined when export is not found', () => {
|
||||
const program = createTestProgram('');
|
||||
mockParseFromProgram.mockReturnValue({
|
||||
exports: [{ name: 'OtherComponent', documentation: { description: 'Some desc.' } }],
|
||||
});
|
||||
|
||||
const result = extractPartDescription('test.tsx', program, 'Value');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when export has no documentation', () => {
|
||||
const program = createTestProgram('');
|
||||
mockParseFromProgram.mockReturnValue({
|
||||
exports: [{ name: 'Value' }],
|
||||
});
|
||||
|
||||
const result = extractPartDescription('test.tsx', program, 'Value');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty description', () => {
|
||||
const program = createTestProgram('');
|
||||
mockParseFromProgram.mockReturnValue({
|
||||
exports: [{ name: 'Value', documentation: { description: '' } }],
|
||||
});
|
||||
|
||||
const result = extractPartDescription('test.tsx', program, 'Value');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { kebabToPascal, sortProps } from '../utils.js';
|
||||
import { kebabToPascal, partKebabFromSource, sortProps } from '../utils.js';
|
||||
|
||||
describe('kebabToPascal', () => {
|
||||
it("converts 'play-button' to 'PlayButton'", () => {
|
||||
@@ -15,6 +15,24 @@ describe('kebabToPascal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('partKebabFromSource', () => {
|
||||
it("derives 'value' from './time-value' with component 'time'", () => {
|
||||
expect(partKebabFromSource('./time-value', 'time')).toBe('value');
|
||||
});
|
||||
|
||||
it("derives 'group' from './time-group' with component 'time'", () => {
|
||||
expect(partKebabFromSource('./time-group', 'time')).toBe('group');
|
||||
});
|
||||
|
||||
it("derives 'separator' from './time-separator' with component 'time'", () => {
|
||||
expect(partKebabFromSource('./time-separator', 'time')).toBe('separator');
|
||||
});
|
||||
|
||||
it("handles multi-segment component names like 'play-button'", () => {
|
||||
expect(partKebabFromSource('./play-button-icon', 'play-button')).toBe('icon');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortProps', () => {
|
||||
it('sorts required props before optional props', () => {
|
||||
const props = {
|
||||
|
||||
Reference in New Issue
Block a user