Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c6cb897
feat(cli): implement <report> command
Hotell Mar 5, 2026
90fb7f7
feat(cli): report cmd - improve types categorization into typeof/genr…
Hotell Mar 6, 2026
32964e2
fix(cli): properly count type category usage for occurences
Hotell Mar 6, 2026
8a0f87e
feat(cli): report cmd - add legend metadata
Hotell Mar 6, 2026
a92ec42
feat(cli): report cmd - decouple logic into 2 subcommands
Hotell Mar 6, 2026
552cd51
docs(cli): add documentation for <report>
Hotell Mar 6, 2026
8cfe546
chore: remove debug data
Hotell Mar 6, 2026
a017199
chore(cli): resolve all lint issues
Hotell Mar 6, 2026
ce7a8d2
fix(cli): add missing props field to FunctionUsage test data
Hotell Mar 6, 2026
540a79b
test(cli): refactor tests to avoid fluent monorepo scope and node_mod…
Hotell Mar 6, 2026
16cc335
feat(cli): report cmd - add '--output' flag to subcommands
Hotell Mar 6, 2026
4863d4d
refactor(cli): update impl module naming to reflect sub-commands refa…
Hotell Mar 6, 2026
2885751
feat(cli): add metadata command for API surface extraction
Hotell Mar 6, 2026
6a5b90c
fix(cli): metadata - apply package.json#types resolution when --entry…
Hotell Mar 6, 2026
51d9f4d
feat(cli): metadata cmd - collapse categories in reports by default
Hotell Mar 6, 2026
85a54b4
feat(cli): metadata - add annotation groupping in md/html reporter
Hotell Mar 6, 2026
312aa9e
feat(cli): metadata - add external references support
Hotell Mar 6, 2026
dcca125
docs(cli): add readme for metadata
Hotell Mar 6, 2026
fc22253
style(cli): make targets pass for metadata cmd
Hotell Mar 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions tools/cli/.swcrc
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"sourceMaps": true,
"exclude": [
"jest.config.ts",
"__fixtures__",
".*\\.spec.tsx?$",
".*\\.test.tsx?$",
"./src/jest-setup.ts$",
Expand Down
2 changes: 1 addition & 1 deletion tools/cli/eslint.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const nodeConfig = fluentPlugin.configs['flat/node'];
/** @type {import("eslint").Linter.Config[]} */
module.exports = [
{
ignores: ['src/commands/migrate/v8-to-v9/__tests__/fixtures/**'],
ignores: ['src/commands/migrate/v8-to-v9/__tests__/fixtures/**', 'src/**/__fixtures__/**'],
},
...(Array.isArray(nodeConfig) ? nodeConfig : [nodeConfig]),
];
2 changes: 2 additions & 0 deletions tools/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import yargs from 'yargs';

import migrateCommand from './commands/migrate';
import reportCommand from './commands/report';
import metadataCommand from './commands/metadata';

const BANNER = `
███████╗██╗ ██╗ ██╗███████╗███╗ ██╗████████╗ ██╗ ██╗██╗
Expand All @@ -19,6 +20,7 @@ export async function main(argv: string[]): Promise<void> {
.usage(`${BANNER}\n $0 <command> [options]`)
.command(migrateCommand)
.command(reportCommand)
.command(metadataCommand)
.demandCommand(1, 'You need to specify a command to run.')
.help()
.strict()
Expand Down
146 changes: 146 additions & 0 deletions tools/cli/src/commands/metadata/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# `metadata` command

The `metadata` command extracts the full public API surface from a Fluent UI package's `.d.ts` build output and produces structured metadata. It is similar in spirit to [react-docgen-typescript](https://github.com/styleguidist/react-docgen-typescript) but covers **all exports** — components, hooks, types, and utilities — with full type signatures and JSDoc documentation.

## Usage

```bash
fluentui metadata [--entry <path>] [--reporter json|markdown|html] [--output <path>]
```

| Flag | Alias | Default | Description |
| ------------ | ----- | ---------------------------- | ----------------------------------------------- |
| `--entry` | `-e` | resolved from `package.json` | Path to `.d.ts` entry file or package directory |
| `--reporter` | `-r` | `json` | Output format: `json`, `markdown`, or `html` |
| `--output` | `-o` | stdout | Output file path |

### Entry resolution

By default the command reads the closest `package.json`, looks for the `"types"` (or `"typings"`) field, and resolves the `.d.ts` entry file. The `--entry` flag accepts either:

- A direct path to an `index.d.ts` file
- A directory containing a `package.json` (the types field is read from it)

> **Prerequisite**: The package must be built first (`yarn nx run <pkg>:build`) so that `.d.ts` output exists.

## Categories

Every exported symbol is classified into one of four categories:

| Category | Criteria |
| -------------- | --------------------------------------------------------------------------------------- |
| **Components** | `ForwardRefComponent<>`, `React.FC<>`, functions returning JSX, PascalCase + JSX return |
| **Hooks** | `use*` naming convention (functions starting with `use` + uppercase letter) |
| **Types** | Interfaces, type aliases, enums |
| **Others** | Constants, render functions, utility functions, class-name objects |

Within each category, symbols are further grouped by annotation:

| Group | JSDoc tags |
| -------------- | ------------------ |
| **Stable** | _(no special tag)_ |
| **Deprecated** | `@deprecated` |
| **Internal** | `@internal` |
| **Preview** | `@alpha`, `@beta` |

## Output formats

- **JSON** — machine-readable metadata with `package`, `legend`, `categories`, and `externalReferences`. Default.
- **Markdown** — collapsible sections per category with summary tables, annotation sub-groups, and clickable `$ref` links.
- **HTML** — self-contained report with collapsible categories, annotation sub-groups with colored borders, clickable anchor links, and dark-mode support.

### JSON schema overview

```jsonc
{
"package": { "name": "@fluentui/react-button", "version": "9.8.2" },
"legend": {
/* category descriptions */
},
"categories": {
"components": {
"Button": {
"name": "Button",
"description": "Buttons give people a way to trigger an action.",
"typeSignature": "ForwardRefComponent<ButtonProps>",
"tags": {},
"propsType": { "$ref": "#/categories/types/ButtonProps" }
}
},
"hooks": {
/* ... */
},
"types": {
/* ... */
},
"others": {
/* ... */
}
},
"externalReferences": {
"@fluentui/react-utilities": {
"metadataRef": "@fluentui/react-utilities/metadata.json",
"symbols": {
"ForwardRefComponent": { "$ref": "@fluentui/react-utilities#/categories/types/ForwardRefComponent" },
"Slot": { "$ref": "@fluentui/react-utilities#/categories/types/Slot" }
}
}
}
}
```

### Cross-package references (`$ref`)

- **Within the same package**: JSON Pointer — `{ "$ref": "#/categories/types/ButtonSlots" }`
- **Across packages**: URI-style — `{ "$ref": "@fluentui/react-utilities#/categories/types/ComponentProps" }`

When a dependency does not have a `metadata.json`, the symbol falls back to `{ "inline": "SymbolName" }`.

## Architecture

```
metadata/
├── index.ts # Yargs command definition (--entry, --reporter, --output)
├── handler.ts # Main handler — orchestrates resolve → parse → refs → format → output
├── handler.spec.ts # Integration tests
├── impl/
│ ├── types.ts # All TypeScript interfaces (MetadataOutput, *Doc, ExternalPackageRef)
│ ├── entry-resolver.ts # Resolves .d.ts from package.json or --entry flag
│ ├── entry-resolver.spec.ts # Entry resolution tests
│ ├── dts-parser.ts # ts-morph parser — extracts all exports with full type signatures
│ ├── dts-parser.spec.ts # Parser tests (classification, JSDoc, members, params)
│ ├── cross-package-resolver.ts # Loads dependency metadata.json and builds $ref URIs
│ ├── cross-package-resolver.spec.ts
│ ├── annotation-groups.ts # Groups symbols by @deprecated, @internal, @alpha, @beta
│ ├── annotation-groups.spec.ts
│ ├── markdown-formatter.ts # Markdown output with collapsible sections and ref links
│ └── html-formatter.ts # Self-contained HTML output with dark mode
└── __fixtures__/ # Test fixtures (.d.ts, package.json)
```

### How it works

1. **Entry resolution** — finds the `.d.ts` entry file from `package.json` types/typings field or `--entry` flag
2. **Parsing** — ts-morph loads the `.d.ts`, iterates `getExportedDeclarations()`, classifies each symbol, extracts JSDoc, type signatures, members, parameters, and return types
3. **Cross-package resolution** — for each imported external package, checks for `metadata.json` and builds `$ref` pointers; scans exported type signatures to determine which external symbols are actually used in the public API
4. **Annotation grouping** — symbols within each category are bucketed by `@deprecated` / `@internal` / `@alpha` / `@beta` tags
5. **Formatting** — routes to JSON, markdown, or HTML formatter
6. **Output** — prints to stdout or writes to file via `--output`

### Key implementation details

- **JSDoc on `declare const`**: In `.d.ts` files, JSDoc lives on the parent `VariableStatement`, not the `VariableDeclaration` node. The parser's `getJsDocTarget()` helper walks up to find it.
- **Function-typed variables**: `declare const renderButton: (state: S) => JSX.Element` is classified as `kind: 'function'` (not `'variable'`) by checking for call signatures on the type.
- **Props type extraction**: `ForwardRefExoticComponent<ButtonProps & RefAttributes<...>>` is parsed with a regex to extract the first type argument as the props type reference.

## Testing

```bash
# Run all metadata tests
yarn nx run cli:test -- --testPathPatterns=metadata

# Run full CLI test suite
yarn nx run cli:test
```

Tests use a fixture-based approach with `__fixtures__/sample-button.d.ts` containing components, hooks, types, enums, and external imports to exercise all classification paths and formatter output.
5 changes: 5 additions & 0 deletions tools/cli/src/commands/metadata/__fixtures__/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "@fluentui/sample-button",
"version": "1.0.0",
"types": "./sample-button.d.ts"
}
92 changes: 92 additions & 0 deletions tools/cli/src/commands/metadata/__fixtures__/sample-button.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as React from 'react';
import type { Slot } from '@sample/utilities';
import type { SlotClassNames } from '@sample/utilities';

/**
* Props for the SampleButton component.
*/
export declare interface SampleButtonProps {
/**
* The visual style of the button.
*
* @default 'secondary'
*/
appearance?: 'primary' | 'secondary' | 'outline';
/**
* Whether the button is disabled.
*
* @default false
*/
disabled?: boolean;
/** The size of the button. */
size?: 'small' | 'medium' | 'large';
}

/**
* State for the SampleButton component.
*/
export declare interface SampleButtonState {
appearance: 'primary' | 'secondary' | 'outline';
disabled: boolean;
}

/**
* Slots for the SampleButton component.
*/
export declare type SampleButtonSlots = {
/** Root element of the button. */
root: Slot<HTMLButtonElement>;
/** Optional icon slot. */
icon?: Slot<HTMLSpanElement>;
};

/**
* SampleButton gives people a way to trigger an action.
*/
export declare const SampleButton: React.ForwardRefExoticComponent<
SampleButtonProps & React.RefAttributes<HTMLButtonElement>
>;

export declare const sampleButtonClassNames: SlotClassNames<SampleButtonSlots>;

/**
* Hook to create SampleButton state.
* @param props - User provided props to the SampleButton component.
* @param ref - User provided ref.
*/
export declare const useSampleButton_unstable: (
props: SampleButtonProps,
ref: React.Ref<HTMLButtonElement>,
) => SampleButtonState;

export declare const useSampleButtonStyles_unstable: (state: SampleButtonState) => SampleButtonState;

/**
* Renders SampleButton from state.
*/
export declare const renderSampleButton_unstable: (state: SampleButtonState) => JSX.Element;

/**
* @internal
* Internal context value.
*/
export declare interface SampleButtonContextValue {
size?: 'small' | 'medium' | 'large';
}

/**
* Size options for the button.
*/
export declare type SampleButtonSize = 'small' | 'medium' | 'large';

/**
* @deprecated Use SampleButtonSize instead.
*/
export declare enum ButtonVariant {
Primary = 'primary',
Secondary = 'secondary',
}

export declare function useToggleState(props: SampleButtonProps): SampleButtonState;

export {};
97 changes: 97 additions & 0 deletions tools/cli/src/commands/metadata/handler.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import * as path from 'node:path';
import * as fs from 'node:fs';

import { handler } from './handler';

const FIXTURES_DIR = path.resolve(__dirname, '__fixtures__');
const SAMPLE_DTS = path.join(FIXTURES_DIR, 'sample-button.d.ts');

describe('metadata handler', () => {
let logSpy: jest.SpyInstance;

beforeEach(() => {
logSpy = jest.spyOn(console, 'log').mockImplementation();
});

afterEach(() => {
logSpy.mockRestore();
});

it('should output JSON metadata for a .d.ts entry file', async () => {
await handler({ _: ['metadata'], $0: 'fluentui-cli', entry: SAMPLE_DTS, reporter: 'json' });

expect(logSpy).toHaveBeenCalledTimes(1);
const output = JSON.parse(logSpy.mock.calls[0][0]);

expect(output.package.name).toBe('@fluentui/sample-button');
expect(output.categories.components).toHaveProperty('SampleButton');
expect(output.categories.hooks).toHaveProperty('useSampleButton_unstable');
expect(output.categories.types).toHaveProperty('SampleButtonProps');
expect(output.categories.others).toHaveProperty('sampleButtonClassNames');
});

it('should output markdown when reporter=markdown', async () => {
await handler({ _: ['metadata'], $0: 'fluentui-cli', entry: SAMPLE_DTS, reporter: 'markdown' });

const output: string = logSpy.mock.calls[0][0];
expect(output).toContain('# API Metadata:');
expect(output).toContain('Components (');
expect(output).toContain('SampleButton');
});

it('should output HTML when reporter=html', async () => {
await handler({ _: ['metadata'], $0: 'fluentui-cli', entry: SAMPLE_DTS, reporter: 'html' });

const output: string = logSpy.mock.calls[0][0];
expect(output).toContain('<!DOCTYPE html>');
expect(output).toContain('SampleButton');
});

it('should include externalReferences for named imports used in API surface', async () => {
await handler({ _: ['metadata'], $0: 'fluentui-cli', entry: SAMPLE_DTS, reporter: 'json' });

const output = JSON.parse(logSpy.mock.calls[0][0]);

// The fixture imports Slot and SlotClassNames from @sample/utilities
// Both are used in type signatures (SampleButtonSlots uses Slot, sampleButtonClassNames uses SlotClassNames)
expect(output.externalReferences).toBeDefined();
expect(output.externalReferences['@sample/utilities']).toBeDefined();

const utilsRef = output.externalReferences['@sample/utilities'];
expect(utilsRef.metadataRef).toBe('@sample/utilities/metadata.json');
expect(utilsRef.symbols).toHaveProperty('Slot');
expect(utilsRef.symbols).toHaveProperty('SlotClassNames');
});

it('should include external references in markdown output', async () => {
await handler({ _: ['metadata'], $0: 'fluentui-cli', entry: SAMPLE_DTS, reporter: 'markdown' });

const output: string = logSpy.mock.calls[0][0];
expect(output).toContain('External References');
expect(output).toContain('@sample/utilities');
});

it('should include external references in HTML output', async () => {
await handler({ _: ['metadata'], $0: 'fluentui-cli', entry: SAMPLE_DTS, reporter: 'html' });

const output: string = logSpy.mock.calls[0][0];
expect(output).toContain('External References');
expect(output).toContain('@sample/utilities');
});

it('should write to file when --output is specified', async () => {
const tmpOutput = path.join(FIXTURES_DIR, '__test-output__.json');

try {
await handler({ _: ['metadata'], $0: 'fluentui-cli', entry: SAMPLE_DTS, reporter: 'json', output: tmpOutput });

expect(fs.existsSync(tmpOutput)).toBe(true);
const content = JSON.parse(fs.readFileSync(tmpOutput, 'utf-8'));
expect(content.package.name).toBe('@fluentui/sample-button');
} finally {
if (fs.existsSync(tmpOutput)) {
fs.unlinkSync(tmpOutput);
}
}
});
});
Loading
Loading