Skip to content

Commit ea59631

Browse files
HotellCopilot
andcommitted
feat(cli): add metadata command for API surface extraction
Add new `fluentui-cli metadata` command that parses .d.ts build output to extract the complete API surface of a Fluent UI package. Features: - Parses .d.ts files using ts-morph to extract all exported symbols - Classifies exports into Components, Hooks, Types, and Others - Extracts JSDoc descriptions, tags, type signatures, and member details - Supports JSON (default), Markdown, and HTML output formats - Cross-package $ref resolution via metadata.json (JSON Schema style) - Entry resolution from package.json "types" field or --entry override CLI options: --entry, -e Path to index.d.ts (default: from package.json) --reporter, -r Output format: json | markdown | html --output, -o Output file path (default: stdout) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 97dc3ab commit ea59631

15 files changed

Lines changed: 1878 additions & 0 deletions

tools/cli/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import yargs from 'yargs';
22

33
import migrateCommand from './commands/migrate';
44
import reportCommand from './commands/report';
5+
import metadataCommand from './commands/metadata';
56

67
export async function main(argv: string[]): Promise<void> {
78
await yargs(argv)
89
.scriptName('fluentui-cli')
910
.usage('$0 <command> [options]')
1011
.command(migrateCommand)
1112
.command(reportCommand)
13+
.command(metadataCommand)
1214
.demandCommand(1, 'You need to specify a command to run.')
1315
.help()
1416
.strict()
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "@fluentui/sample-button",
3+
"version": "1.0.0",
4+
"types": "./sample-button.d.ts"
5+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import * as React from 'react';
2+
3+
/**
4+
* Props for the SampleButton component.
5+
*/
6+
export declare interface SampleButtonProps {
7+
/**
8+
* The visual style of the button.
9+
*
10+
* @default 'secondary'
11+
*/
12+
appearance?: 'primary' | 'secondary' | 'outline';
13+
/**
14+
* Whether the button is disabled.
15+
*
16+
* @default false
17+
*/
18+
disabled?: boolean;
19+
/** The size of the button. */
20+
size?: 'small' | 'medium' | 'large';
21+
}
22+
23+
/**
24+
* State for the SampleButton component.
25+
*/
26+
export declare interface SampleButtonState {
27+
appearance: 'primary' | 'secondary' | 'outline';
28+
disabled: boolean;
29+
}
30+
31+
/**
32+
* Slots for the SampleButton component.
33+
*/
34+
export declare type SampleButtonSlots = {
35+
/** Root element of the button. */
36+
root: HTMLButtonElement;
37+
/** Optional icon slot. */
38+
icon?: HTMLSpanElement;
39+
};
40+
41+
/**
42+
* SampleButton gives people a way to trigger an action.
43+
*/
44+
export declare const SampleButton: React.ForwardRefExoticComponent<
45+
SampleButtonProps & React.RefAttributes<HTMLButtonElement>
46+
>;
47+
48+
export declare const sampleButtonClassNames: Record<string, string>;
49+
50+
/**
51+
* Hook to create SampleButton state.
52+
* @param props - User provided props to the SampleButton component.
53+
* @param ref - User provided ref.
54+
*/
55+
export declare const useSampleButton_unstable: (
56+
props: SampleButtonProps,
57+
ref: React.Ref<HTMLButtonElement>,
58+
) => SampleButtonState;
59+
60+
export declare const useSampleButtonStyles_unstable: (state: SampleButtonState) => SampleButtonState;
61+
62+
/**
63+
* Renders SampleButton from state.
64+
*/
65+
export declare const renderSampleButton_unstable: (state: SampleButtonState) => JSX.Element;
66+
67+
/**
68+
* @internal
69+
* Internal context value.
70+
*/
71+
export declare interface SampleButtonContextValue {
72+
size?: 'small' | 'medium' | 'large';
73+
}
74+
75+
/**
76+
* Size options for the button.
77+
*/
78+
export declare type SampleButtonSize = 'small' | 'medium' | 'large';
79+
80+
/**
81+
* @deprecated Use SampleButtonSize instead.
82+
*/
83+
export declare enum ButtonVariant {
84+
Primary = 'primary',
85+
Secondary = 'secondary',
86+
}
87+
88+
export declare function useToggleState(props: SampleButtonProps): SampleButtonState;
89+
90+
export {};
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import * as path from 'node:path';
2+
import * as fs from 'node:fs';
3+
4+
import { handler } from './handler';
5+
6+
const FIXTURES_DIR = path.resolve(__dirname, '__fixtures__');
7+
const SAMPLE_DTS = path.join(FIXTURES_DIR, 'sample-button.d.ts');
8+
9+
describe('metadata handler', () => {
10+
let logSpy: jest.SpyInstance;
11+
12+
beforeEach(() => {
13+
logSpy = jest.spyOn(console, 'log').mockImplementation();
14+
});
15+
16+
afterEach(() => {
17+
logSpy.mockRestore();
18+
});
19+
20+
it('should output JSON metadata for a .d.ts entry file', async () => {
21+
await handler({ _: ['metadata'], $0: 'fluentui-cli', entry: SAMPLE_DTS, reporter: 'json' });
22+
23+
expect(logSpy).toHaveBeenCalledTimes(1);
24+
const output = JSON.parse(logSpy.mock.calls[0][0]);
25+
26+
expect(output.package.name).toBe('@fluentui/sample-button');
27+
expect(output.categories.components).toHaveProperty('SampleButton');
28+
expect(output.categories.hooks).toHaveProperty('useSampleButton_unstable');
29+
expect(output.categories.types).toHaveProperty('SampleButtonProps');
30+
expect(output.categories.others).toHaveProperty('sampleButtonClassNames');
31+
});
32+
33+
it('should output markdown when reporter=markdown', async () => {
34+
await handler({ _: ['metadata'], $0: 'fluentui-cli', entry: SAMPLE_DTS, reporter: 'markdown' });
35+
36+
const output: string = logSpy.mock.calls[0][0];
37+
expect(output).toContain('# API Metadata:');
38+
expect(output).toContain('## Components');
39+
expect(output).toContain('SampleButton');
40+
});
41+
42+
it('should output HTML when reporter=html', async () => {
43+
await handler({ _: ['metadata'], $0: 'fluentui-cli', entry: SAMPLE_DTS, reporter: 'html' });
44+
45+
const output: string = logSpy.mock.calls[0][0];
46+
expect(output).toContain('<!DOCTYPE html>');
47+
expect(output).toContain('SampleButton');
48+
});
49+
50+
it('should write to file when --output is specified', async () => {
51+
const tmpOutput = path.join(FIXTURES_DIR, '__test-output__.json');
52+
53+
try {
54+
await handler({ _: ['metadata'], $0: 'fluentui-cli', entry: SAMPLE_DTS, reporter: 'json', output: tmpOutput });
55+
56+
expect(fs.existsSync(tmpOutput)).toBe(true);
57+
const content = JSON.parse(fs.readFileSync(tmpOutput, 'utf-8'));
58+
expect(content.package.name).toBe('@fluentui/sample-button');
59+
} finally {
60+
if (fs.existsSync(tmpOutput)) {
61+
fs.unlinkSync(tmpOutput);
62+
}
63+
}
64+
});
65+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
4+
import type { CommandHandler } from '../../utils/types';
5+
import type { MetadataArgs, MetadataOutput } from './impl/types';
6+
import { resolveEntry, readPackageInfo } from './impl/entry-resolver';
7+
import { parseDtsEntry } from './impl/dts-parser';
8+
import { loadDependencyMetadata, buildCrossPackageRef } from './impl/cross-package-resolver';
9+
import { formatMetadataAsMarkdown } from './impl/markdown-formatter';
10+
import { formatMetadataAsHtml } from './impl/html-formatter';
11+
12+
const LEGEND = {
13+
components: { name: 'Components', description: 'React components (ForwardRef, FC, class)' },
14+
hooks: { name: 'Hooks', description: 'React hooks (use* convention)' },
15+
types: { name: 'Types', description: 'Interfaces, type aliases, and enums' },
16+
others: { name: 'Others', description: 'Constants, render functions, and utilities' },
17+
};
18+
19+
export const handler: CommandHandler<MetadataArgs> = async argv => {
20+
const { entry, reporter = 'json', output } = argv;
21+
22+
// 1. Resolve entry .d.ts
23+
const entryPath = resolveEntry(entry);
24+
const packageInfo = readPackageInfo(path.dirname(entryPath));
25+
26+
// 2. Parse the .d.ts
27+
const parseResult = parseDtsEntry(entryPath);
28+
29+
// 3. Resolve cross-package $refs
30+
const cwd = process.cwd();
31+
const depMetadataCache = new Map<string, MetadataOutput | null>();
32+
33+
for (const pkgSpec of parseResult.importedPackages) {
34+
if (!depMetadataCache.has(pkgSpec)) {
35+
depMetadataCache.set(pkgSpec, loadDependencyMetadata(pkgSpec, cwd));
36+
}
37+
}
38+
39+
// Enhance component propsType refs with cross-package resolution
40+
for (const comp of Object.values(parseResult.components)) {
41+
if (comp.propsType && '$ref' in comp.propsType) {
42+
const localRef = comp.propsType.$ref;
43+
const symbolName = localRef.split('/').pop()!;
44+
45+
// If the type isn't in our own types, check dependencies
46+
if (!(symbolName in parseResult.types)) {
47+
for (const [pkgSpec, depMetadata] of depMetadataCache) {
48+
if (depMetadata) {
49+
const crossRef = buildCrossPackageRef(pkgSpec, symbolName, depMetadata);
50+
if (crossRef) {
51+
comp.propsType = crossRef;
52+
break;
53+
}
54+
}
55+
}
56+
}
57+
}
58+
}
59+
60+
// 4. Assemble output
61+
const metadataOutput: MetadataOutput = {
62+
package: packageInfo,
63+
legend: LEGEND,
64+
categories: {
65+
components: parseResult.components,
66+
hooks: parseResult.hooks,
67+
types: parseResult.types,
68+
others: parseResult.others,
69+
},
70+
};
71+
72+
// 5. Format
73+
let formatted: string;
74+
switch (reporter) {
75+
case 'markdown':
76+
formatted = formatMetadataAsMarkdown(metadataOutput);
77+
break;
78+
case 'html':
79+
formatted = formatMetadataAsHtml(metadataOutput);
80+
break;
81+
case 'json':
82+
default:
83+
formatted = JSON.stringify(metadataOutput, null, 2);
84+
break;
85+
}
86+
87+
// 6. Output
88+
if (output) {
89+
const outputPath = path.resolve(cwd, output);
90+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
91+
fs.writeFileSync(outputPath, formatted, 'utf-8');
92+
console.log(`Metadata written to ${outputPath}`);
93+
} else {
94+
console.log(formatted);
95+
}
96+
};
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { MetadataOutput } from './types';
2+
import { loadDependencyMetadata, buildCrossPackageRef } from './cross-package-resolver';
3+
4+
describe('cross-package-resolver', () => {
5+
const mockMetadata: MetadataOutput = {
6+
package: { name: '@fluentui/react-utilities', version: '1.0.0' },
7+
legend: {},
8+
categories: {
9+
components: {},
10+
hooks: {},
11+
types: {
12+
ComponentProps: {
13+
name: 'ComponentProps',
14+
description: 'Base component props type.',
15+
typeSignature: '...',
16+
tags: {},
17+
kind: 'type-alias',
18+
members: {},
19+
},
20+
},
21+
others: {
22+
slot: {
23+
name: 'slot',
24+
description: 'Slot utility.',
25+
typeSignature: '...',
26+
tags: {},
27+
kind: 'function',
28+
},
29+
},
30+
},
31+
};
32+
33+
describe('buildCrossPackageRef', () => {
34+
it('should return a $ref when the symbol exists in types', () => {
35+
const result = buildCrossPackageRef('@fluentui/react-utilities', 'ComponentProps', mockMetadata);
36+
37+
expect(result).toEqual({ $ref: '@fluentui/react-utilities#/categories/types/ComponentProps' });
38+
});
39+
40+
it('should return a $ref when the symbol exists in others', () => {
41+
const result = buildCrossPackageRef('@fluentui/react-utilities', 'slot', mockMetadata);
42+
43+
expect(result).toEqual({ $ref: '@fluentui/react-utilities#/categories/others/slot' });
44+
});
45+
46+
it('should return null when the symbol does not exist', () => {
47+
const result = buildCrossPackageRef('@fluentui/react-utilities', 'NonExistent', mockMetadata);
48+
49+
expect(result).toBeNull();
50+
});
51+
});
52+
53+
describe('loadDependencyMetadata', () => {
54+
it('should return null for a non-existent package', () => {
55+
const result = loadDependencyMetadata('__non_existent_package__', '/');
56+
57+
expect(result).toBeNull();
58+
});
59+
});
60+
});

0 commit comments

Comments
 (0)