feat(site): generated multipart component api reference (#468)

This commit is contained in:
Darius Cepulis
2026-02-09 12:20:45 +11:00
committed by GitHub
parent 3c4152fb58
commit 4b1e863883
31 changed files with 1413 additions and 327 deletions
@@ -29,7 +29,7 @@ export function extractCore(filePath: string, program: ts.Program, componentName
let description: string | undefined;
if (propsExport?.type instanceof tae.ObjectNode) {
const formatted = formatProperties(propsExport.type.properties);
const formatted = formatProperties(propsExport.type.properties, ast.exports);
props = Object.entries(formatted).map(([name, def]) => ({
name,
...def,
@@ -40,7 +40,7 @@ export function extractCore(filePath: string, program: ts.Program, componentName
// Extract state
let state: ExtractedProp[] = [];
if (stateExport?.type instanceof tae.ObjectNode) {
const formatted = formatProperties(stateExport.type.properties);
const formatted = formatProperties(stateExport.type.properties, ast.exports);
state = Object.entries(formatted).map(([name, def]) => ({
name,
...def,
+64 -3
View File
@@ -30,7 +30,7 @@ export function getShortPropType(name: string, type: string): string | undefined
}
// Short unions (less than 3 members and under 40 chars) → no abbreviation
if (!type.includes(' | ') || (type.split(' | ').length < 3 && type.length < 40)) {
if (!type.includes(' | ') || (type.split(' | ').length < 3 && type.length < 40 && !type.includes('=>'))) {
return undefined;
}
@@ -51,7 +51,7 @@ export function getShortPropType(name: string, type: string): string | undefined
/**
* Format a list of properties into API reference format.
*/
export function formatProperties(props: tae.PropertyNode[]): Record<string, PropDef> {
export function formatProperties(props: tae.PropertyNode[], allExports?: tae.ExportNode[]): Record<string, PropDef> {
const result: Record<string, PropDef> = {};
for (const prop of props) {
@@ -60,7 +60,9 @@ export function formatProperties(props: tae.PropertyNode[]): Record<string, Prop
// Skip props marked with @ignore
if (prop.documentation?.hasTag('ignore')) continue;
const formattedType = formatType(prop.type, prop.optional);
const formattedType = allExports
? formatDetailedType(prop.type, allExports, prop.optional)
: formatType(prop.type, prop.optional);
const shortType = getShortPropType(prop.name, formattedType);
const entry: PropDef = { type: formattedType };
@@ -75,6 +77,65 @@ export function formatProperties(props: tae.PropertyNode[]): Record<string, Prop
return result;
}
/**
* Format a type into a human-readable string, expanding type aliases when possible.
*
* Resolves `ExternalTypeNode` references against `allExports` so that type aliases
* like `TimeType` are expanded to their underlying union (`'current' | 'duration' | 'remaining'`).
*/
export function formatDetailedType(
type: tae.AnyType,
allExports: tae.ExportNode[],
removeUndefined: boolean,
visited: Set<string> = new Set()
): string {
if (type instanceof tae.ExternalTypeNode) {
const name = type.typeName.name;
if (!visited.has(name)) {
const resolved = allExports.find((exp) => exp.name === name && exp.reexportedFrom === undefined);
if (resolved) {
visited.add(name);
return formatDetailedType(resolved.type, allExports, removeUndefined, visited);
}
}
return formatType(type, removeUndefined);
}
if (type instanceof tae.UnionNode) {
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) => formatDetailedType(t, allExports, removeUndefined, visited))
);
return formattedMemberTypes.join(' | ');
}
if (type instanceof tae.IntersectionNode) {
return orderMembers(type.types)
.map((t) => formatDetailedType(t, allExports, false, visited))
.join(' & ');
}
return formatType(type, removeUndefined);
}
/**
* Format a type into a human-readable string.
*/
@@ -2,14 +2,20 @@ 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 {
export function extractHtml(
filePath: string,
program: ts.Program,
componentName: string,
elementName?: string
): HtmlExtraction | null {
const sourceFile = program.getSourceFile(filePath);
if (!sourceFile) return null;
const className = elementName ?? `${componentName}Element`;
let tagName = '';
function visit(node: ts.Node) {
if (ts.isClassDeclaration(node) && node.name?.text === `${componentName}Element`) {
if (ts.isClassDeclaration(node) && node.name?.text === className) {
for (const member of node.members) {
if (
ts.isPropertyDeclaration(member) &&
+257 -45
View File
@@ -6,15 +6,61 @@ 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 { extractPartDescription, extractParts } from './parts-handler.js';
import {
type ComponentApiReference,
ComponentApiReferenceSchema,
type ComponentSource,
type CoreExtraction,
type DataAttrDef,
type DataAttrsExtraction,
type PartApiReference,
type PartSource,
type PropDef,
type StateDef,
} from './types.js';
import { kebabToPascal, sortProps } from './utils.js';
import { kebabToPascal, partKebabFromSource, sortProps } from './utils.js';
function buildProps(coreData: CoreExtraction): Record<string, PropDef> {
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,
};
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;
}
return props;
}
function buildState(coreData: CoreExtraction): Record<string, StateDef> {
const state: Record<string, StateDef> = {};
for (const s of coreData.state) {
state[s.name] = {
type: s.type,
shortType: s.shortType,
description: s.description,
};
if (state[s.name]!.shortType === undefined) delete state[s.name]!.shortType;
if (state[s.name]!.description === undefined) delete state[s.name]!.description;
}
return state;
}
function buildDataAttrs(dataAttrsData: DataAttrsExtraction): Record<string, DataAttrDef> {
const dataAttributes: Record<string, DataAttrDef> = {};
for (const attr of dataAttrsData.attrs) {
dataAttributes[attr.name] = { description: attr.description };
}
return dataAttributes;
}
// Magenta prefix - visible on both light and dark terminals
const PREFIX = '\x1b[35m[api-docs-builder]\x1b[0m';
@@ -30,6 +76,7 @@ const log = {
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 REACT_UI_PATH = path.join(MONOREPO_ROOT, 'packages/react/src/ui');
const OUTPUT_PATH = path.join(MONOREPO_ROOT, 'site/src/content/generated-api-reference');
/**
@@ -74,6 +121,12 @@ function discoverComponents(): ComponentSource[] {
source.htmlPath = htmlFile;
}
// Check for multi-part component (index.parts.ts in React package)
const partsIndexFile = path.join(REACT_UI_PATH, dir.name, 'index.parts.ts');
if (fs.existsSync(partsIndexFile)) {
source.partsIndexPath = partsIndexFile;
}
// Only include if we have at least a core file
if (source.corePath) {
components.push(source);
@@ -93,6 +146,33 @@ function createProgram(sources: ComponentSource[]): ts.Program {
if (source.corePath) files.push(source.corePath);
if (source.dataAttrsPath) files.push(source.dataAttrsPath);
if (source.htmlPath) files.push(source.htmlPath);
if (source.partsIndexPath) files.push(source.partsIndexPath);
// For multi-part components, include all element files from the HTML directory
// and React source files for JSDoc description extraction
if (source.partsIndexPath) {
const componentKebab = kebabCase(source.name);
const htmlDir = path.join(HTML_UI_PATH, componentKebab);
if (fs.existsSync(htmlDir)) {
const elementFiles = fs.readdirSync(htmlDir).filter((f) => f.endsWith('-element.ts'));
for (const file of elementFiles) {
const fullPath = path.join(htmlDir, file);
if (!files.includes(fullPath)) {
files.push(fullPath);
}
}
}
// Include React component .tsx files for JSDoc description extraction
const reactDir = path.dirname(source.partsIndexPath);
const reactFiles = fs.readdirSync(reactDir).filter((f) => f.endsWith('.tsx'));
for (const file of reactFiles) {
const fullPath = path.join(reactDir, file);
if (!files.includes(fullPath)) {
files.push(fullPath);
}
}
}
}
// Load base tsconfig - works for all packages since we only need type resolution
@@ -105,9 +185,9 @@ function createProgram(sources: ComponentSource[]): ts.Program {
}
/**
* Build the API reference for a single component.
* Build the API reference for a single-part component.
*/
function buildComponentApiReference(source: ComponentSource, program: ts.Program): ComponentApiReference | null {
function buildSingleComponentApiReference(source: ComponentSource, program: ts.Program): ComponentApiReference | null {
// Extract from core
const coreData = source.corePath ? extractCore(source.corePath, program, source.name) : null;
@@ -122,51 +202,13 @@ function buildComponentApiReference(source: ComponentSource, program: ts.Program
// 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,
props: buildProps(coreData),
state: buildState(coreData),
dataAttributes: dataAttrsData ? buildDataAttrs(dataAttrsData) : {},
platforms: {},
};
@@ -183,10 +225,178 @@ function buildComponentApiReference(source: ComponentSource, program: ts.Program
return result;
}
/**
* Discover parts and match them to HTML element files.
*
* Matching algorithm:
* 1. Parse `index.parts.ts` for named exports -> part names and source paths
* 2. Derive kebab segment from source: `./time-value` -> strip `./time-` prefix -> `value`
* 3. For each part, look for `{name}-{kebab}-element.ts` in HTML dir (e.g., `time-group-element.ts`)
* 4. The part with NO matching `{name}-{kebab}-element.ts` but where `{name}-element.ts` exists -> primary part
* 5. Primary part gets: shared core file, shared data-attrs, main element (`{name}-element.ts`)
*/
function discoverParts(source: ComponentSource, program: ts.Program): PartSource[] {
if (!source.partsIndexPath) return [];
const partExports = extractParts(source.partsIndexPath, program);
if (partExports.length === 0) return [];
const componentKebab = kebabCase(source.name);
const htmlDir = path.join(HTML_UI_PATH, componentKebab);
const parts: PartSource[] = [];
let hasPrimary = false;
for (const partExport of partExports) {
const kebab = partKebabFromSource(partExport.source, componentKebab);
// Look for sub-part element file: {component}-{part}-element.ts
const subPartElementFile = path.join(htmlDir, `${componentKebab}-${kebab}-element.ts`);
const hasSubPartElement = fs.existsSync(subPartElementFile);
// Primary part: no matching sub-part element, but main element exists
const isPrimary = !hasSubPartElement && !!source.htmlPath;
if (isPrimary) hasPrimary = true;
// Resolve React source path for JSDoc description extraction
const reactFile = path.join(path.dirname(source.partsIndexPath), `${partExport.source.replace('./', '')}.tsx`);
const reactPath = fs.existsSync(reactFile) ? reactFile : undefined;
const part: PartSource = {
name: partExport.name,
kebab,
isPrimary,
htmlPath: hasSubPartElement ? subPartElementFile : isPrimary ? source.htmlPath : undefined,
reactPath,
};
if (!part.htmlPath) {
log.warn(`${source.name}: Part "${partExport.name}" has no matching HTML element file`);
}
parts.push(part);
}
if (!hasPrimary) {
log.warn(`${source.name}: No primary part identified (expected one part to use ${componentKebab}-element.ts)`);
}
// Primary part first so it appears first in the docs.
return parts.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary));
}
/**
* Build the API reference for a multi-part component.
*
* Multi-part components have empty top-level props/state/dataAttributes.
* All data is in the `parts` record.
*
* For the primary part:
* - Props and state come from the shared core file (`{name}-core.ts`)
* - Data attributes come from the shared data-attrs file (`{name}-data-attrs.ts`)
* - HTML tag comes from the main element file (`{name}-element.ts`)
*
* For non-primary parts:
* - Props, state, and data attributes are empty (no dedicated core file)
* - HTML tag comes from their sub-part element file (`{name}-{part}-element.ts`)
*/
function buildMultiPartApiReference(
source: ComponentSource,
program: ts.Program,
parts: PartSource[]
): ComponentApiReference | null {
const partsRecord: Record<string, PartApiReference> = {};
for (const part of parts) {
// Extract JSDoc description from React component file
const description = part.reactPath ? extractPartDescription(part.reactPath, program, part.name) : undefined;
if (part.isPrimary) {
// Primary part: extract from shared core and data-attrs
const coreData = source.corePath ? extractCore(source.corePath, program, source.name) : null;
const dataAttrsData = source.dataAttrsPath ? extractDataAttrs(source.dataAttrsPath, program, source.name) : null;
const elementName = `${source.name}Element`;
const htmlData = part.htmlPath ? extractHtml(part.htmlPath, program, source.name, elementName) : null;
const partRef: PartApiReference = {
name: part.name,
description,
props: coreData ? sortProps(buildProps(coreData)) : {},
state: coreData ? buildState(coreData) : {},
dataAttributes: dataAttrsData ? buildDataAttrs(dataAttrsData) : {},
platforms: {},
};
if (!partRef.description) delete partRef.description;
if (htmlData) {
partRef.platforms.html = { tagName: htmlData.tagName };
}
partsRecord[part.kebab] = partRef;
} else {
// Non-primary part: extract only HTML tag
const elementName = `${source.name}${part.name}Element`;
const htmlData = part.htmlPath ? extractHtml(part.htmlPath, program, source.name, elementName) : null;
const partRef: PartApiReference = {
name: part.name,
description,
props: {},
state: {},
dataAttributes: {},
platforms: {},
};
if (!partRef.description) delete partRef.description;
if (htmlData) {
partRef.platforms.html = { tagName: htmlData.tagName };
}
partsRecord[part.kebab] = partRef;
}
}
return {
name: source.name,
props: {},
state: {},
dataAttributes: {},
platforms: {},
parts: partsRecord,
};
}
/**
* Build the API reference for a single component.
*/
function buildComponentApiReference(source: ComponentSource, program: ts.Program): ComponentApiReference | null {
if (source.partsIndexPath) {
const parts = discoverParts(source, program);
if (parts.length > 0) {
return buildMultiPartApiReference(source, program, parts);
}
}
return buildSingleComponentApiReference(source, program);
}
/**
* Main entry point.
*/
function main() {
// typescript-api-extractor doesn't handle the `never` TypeScript type flag
// (or a few others like ESSymbol, TemplateLiteral). It falls back to `any`
// and logs a warning for each occurrence. This is a known gap in the alpha
// library — not a bug in our types. Suppress the noise here.
// https://github.com/michaldudak/typescript-api-extractor/blob/main/src/parsers/typeResolver.ts
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => {
if (typeof args[0] === 'string' && args[0].startsWith('Unable to handle a type with flag')) return;
originalWarn.apply(console, args);
};
// Ensure output directory exists
if (!fs.existsSync(OUTPUT_PATH)) {
fs.mkdirSync(OUTPUT_PATH, { recursive: true });
@@ -213,7 +423,7 @@ function main() {
const apiRef = buildComponentApiReference(source, program);
if (apiRef) {
// Sort props
// Sort props (top-level only for single-part)
apiRef.props = sortProps(apiRef.props);
// Validate against schema before writing
@@ -243,6 +453,8 @@ function main() {
log.info(`Done! Generated ${successCount} files.`);
console.warn = originalWarn;
if (errorCount > 0) {
log.error(`${errorCount} errors occurred.`);
process.exit(1);
@@ -0,0 +1,70 @@
import * as ts from 'typescript';
import * as tae from 'typescript-api-extractor';
export interface PartExport {
/** PascalCase export name (e.g., "Value", "Group", "Separator"). */
name: string;
/** Source path (e.g., "./time-value", "./time-group"). */
source: string;
}
/**
* Extract part definitions from a React `index.parts.ts` file.
*
* Discovery algorithm:
* 1. Parses named exports from `index.parts.ts` (filters out type-only exports)
* 2. Each value export becomes a part: `export { Group } from './time-group'` -> part "Group"
* 3. Source path is preserved for HTML element matching in the main builder
*
* If a part isn't appearing in the output:
* - Ensure it's exported as a value export (not `type`-only) in `index.parts.ts`
* - Ensure the source path follows `'./{component}-{part}'` naming
*/
export function extractParts(filePath: string, program: ts.Program): PartExport[] {
const sourceFile = program.getSourceFile(filePath);
if (!sourceFile) return [];
const parts: PartExport[] = [];
function visit(node: ts.Node) {
// Match: export { Name } from './source' or export { Name, type NameProps } from './source'
if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
const source = node.moduleSpecifier.text;
// Skip type-only export declarations (export type { ... } from '...')
if (node.isTypeOnly) return;
if (node.exportClause && ts.isNamedExports(node.exportClause)) {
for (const element of node.exportClause.elements) {
// Skip type-only specifiers (e.g., `type GroupProps`)
if (element.isTypeOnly) continue;
parts.push({
name: element.name.text,
source,
});
}
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return parts;
}
/**
* Extract the JSDoc description from a React component export.
*
* Parses the file with `typescript-api-extractor`, finds the export matching
* `partName`, and returns its description (stripping `@example` blocks).
*/
export function extractPartDescription(filePath: string, program: ts.Program, partName: string): string | undefined {
const ast = tae.parseFromProgram(filePath, program);
const component = ast.exports.find((exp) => exp.name === partName);
let desc = component?.documentation?.description;
if (desc) desc = desc.replace(/\n*@example[\s\S]*$/, '').trim();
return desc || undefined;
}
@@ -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 = {
+20 -1
View File
@@ -5,11 +5,28 @@
export type {
ComponentApiReference,
DataAttrDef,
PartApiReference,
PropDef,
StateDef,
} from '../../../src/types/api-reference.js';
export { ComponentApiReferenceSchema } from '../../../src/types/api-reference.js';
export { ComponentApiReferenceSchema, PartApiReferenceSchema } from '../../../src/types/api-reference.js';
/**
* Discovered part within a multi-part component.
*/
export interface PartSource {
/** PascalCase name (e.g., "Value", "Group", "Separator"). */
name: string;
/** Kebab-case segment (e.g., "value", "group", "separator"). */
kebab: string;
/** True if this part gets the shared core/data-attrs. */
isPrimary: boolean;
/** Path to HTML element file. */
htmlPath?: string;
/** Path to React component file (for JSDoc description extraction). */
reactPath?: string;
}
/**
* Source file locations for a component across packages.
@@ -23,6 +40,8 @@ export interface ComponentSource {
dataAttrsPath?: string;
/** Path to HTML element file */
htmlPath?: string;
/** Path to index.parts.ts (if multi-part) */
partsIndexPath?: string;
}
/**
@@ -7,6 +7,21 @@ export function kebabToPascal(str: string): string {
.join('');
}
/**
* Derive the kebab-case part segment from an `index.parts.ts` source path.
*
* Strips the leading `'./{componentKebab}-'` prefix to get the part segment.
* Example: `partKebabFromSource('./time-value', 'time')` -> `'value'`
*/
export function partKebabFromSource(source: string, componentKebab: string): string {
const prefix = `./${componentKebab}-`;
if (source.startsWith(prefix)) {
return source.slice(prefix.length);
}
// Fallback: strip leading './' and the component prefix
return source.replace(/^\.\//, '').replace(new RegExp(`^${componentKebab}-`), '');
}
export function sortProps(props: Record<string, PropDef>): Record<string, PropDef> {
const entries = Object.entries(props);