Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion packages/platform-api-docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Initial release of the platform-api-docs package ([#8012](https://github.com/MetaMask/core/pull/8012))
- Initial release of the platform-api-docs package ([#8012](https://github.com/MetaMask/core/pull/8012), [#9913](https://github.com/MetaMask/core/pull/9913))

[Unreleased]: https://github.com/MetaMask/core/
51 changes: 50 additions & 1 deletion packages/platform-api-docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,61 @@ Options:
--build Generate docs and build static site
--serve Generate docs, build, and serve static site
--dev Generate docs and start dev server with hot reload
--scan-dir <dir> Extra source directory to scan (repeatable)
--strategy <name> How to find actions and events: "scan" (default) or
"root-messenger" (see below)
--scan-dir <dir> Extra source directory to scan (repeatable; --strategy scan only)
--root-actions <ref> Type aliasing the union of every action, as "<file>#<TypeName>"
(required with --strategy root-messenger)
--root-events <ref> Type aliasing the union of every event, as "<file>#<TypeName>"
(required with --strategy root-messenger)
--output <dir> Output directory (default: <project-path>/.platform-api-docs)
--project-label <label> Short label identifying the project (e.g. "Core", "Extension")
--help Show this help message
```

## Strategies

Which strategy to use depends on whether the project has a single messenger
carrying every action and event.

### `scan` (default)

Parses every TypeScript source and declaration file it can find — the scan
directories, `packages/*/src`, and `node_modules/@metamask/*/dist` — and reads
every `*Messenger` type alias it encounters.

Use this when no single messenger aggregates every capability, as in a monorepo
of independently published packages.

### `root-messenger`

Resolves the two types the project declares for its root messenger — the union
of every action and the union of every event — and lets the TypeScript type
checker enumerate them. Only the files named on the command line are opened.

Use this when the project has one root messenger carrying every action and
event, as a client application built on these packages does. It is
substantially faster than `scan`, because it reads what the project already
declares instead of re-deriving it, and it documents only what is reachable
through that messenger.

```
platform-api-docs \
--strategy root-messenger \
--root-actions 'src/messenger.ts#RootActions' \
--root-events 'src/messenger.ts#RootEvents'
```

Each reference names a type alias, written by hand or computed — the type
checker resolves either.

The docs contain exactly what the named types contain, so those types should be
the ones carrying every capability rather than a narrowed subset. Capability
types that can't be documented are reported rather than dropped silently: those
declared inline in the union (with no name or JSDoc to document), and those
whose shape can't be read (most often a `type` property that isn't a namespaced
string literal).

## Contributing

This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme).
171 changes: 171 additions & 0 deletions packages/platform-api-docs/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,175 @@ export type QuxMessenger = Messenger<'Qux', QuxAction, never>;
expect(result.all).toContain('No scannable directories found');
});
});

describe('--strategy root-messenger', () => {
/**
* Write a project whose root messenger unions live in `app/types.ts`.
*
* @param directoryPath - The sandbox root.
*/
async function writeRootMessengerProject(
directoryPath: string,
): Promise<void> {
const appDir = path.join(directoryPath, 'app');
await fs.promises.mkdir(appDir, { recursive: true });
await fs.promises.writeFile(
path.join(appDir, 'types.ts'),
`
export type FooControllerGetStateAction = {
type: 'FooController:getState';
handler: () => FooState;
};

export type FooControllerStateChangeEvent = {
type: 'FooController:stateChange';
payload: [FooState, Patch[]];
};

export type GlobalActions = FooControllerGetStateAction;
export type GlobalEvents = FooControllerStateChangeEvent;
`,
);
}

it('generates docs from the named root messenger unions', async () => {
expect.assertions(3);

await withinSandbox(async ({ directoryPath }) => {
await writeRootMessengerProject(directoryPath);

const result = await runCLI([
directoryPath,
'--strategy',
'root-messenger',
'--root-actions',
'app/types.ts#GlobalActions',
'--root-events',
'app/types.ts#GlobalEvents',
]);

expect(result.exitCode).toBe(0);
expect(result.all).toContain('Found 2 messenger items total');
expect(result.all).toContain('Generated docs for 1 namespace');
});
});

it('exits with error when the root type references are missing', async () => {
expect.assertions(2);

await withinSandbox(async ({ directoryPath }) => {
await writeRootMessengerProject(directoryPath);

const result = await runCLI([
directoryPath,
'--strategy',
'root-messenger',
]);

expect(result.exitCode).not.toBe(0);
expect(result.all).toContain(
'requires both --root-actions and --root-events',
);
});
});

it('exits with error when a root type reference is malformed', async () => {
expect.assertions(2);

await withinSandbox(async ({ directoryPath }) => {
await writeRootMessengerProject(directoryPath);

const result = await runCLI([
directoryPath,
'--strategy',
'root-messenger',
'--root-actions',
'app/types.ts',
'--root-events',
'app/types.ts#GlobalEvents',
]);

expect(result.exitCode).not.toBe(0);
expect(result.all).toContain(
'Expected a reference of the form "<file>#<TypeName>"',
);
});
});

it('exits with error when the named type is not declared', async () => {
expect.assertions(2);

await withinSandbox(async ({ directoryPath }) => {
await writeRootMessengerProject(directoryPath);

const result = await runCLI([
directoryPath,
'--strategy',
'root-messenger',
'--root-actions',
'app/types.ts#NotDeclared',
'--root-events',
'app/types.ts#GlobalEvents',
]);

expect(result.exitCode).not.toBe(0);
expect(result.all).toContain('No type alias named "NotDeclared"');
});
});

it('exits with error when --scan-dir is combined with it', async () => {
expect.assertions(2);

await withinSandbox(async ({ directoryPath }) => {
await writeRootMessengerProject(directoryPath);

const result = await runCLI([
directoryPath,
'--strategy',
'root-messenger',
'--root-actions',
'app/types.ts#GlobalActions',
'--root-events',
'app/types.ts#GlobalEvents',
'--scan-dir',
'app',
]);

expect(result.exitCode).not.toBe(0);
expect(result.all).toContain(
'--scan-dir only applies to --strategy scan',
);
});
});

it('exits with error when root type references are used with --strategy scan', async () => {
expect.assertions(2);

await withinSandbox(async ({ directoryPath }) => {
await writeRootMessengerProject(directoryPath);

const result = await runCLI([
directoryPath,
'--root-actions',
'app/types.ts#GlobalActions',
]);

expect(result.exitCode).not.toBe(0);
expect(result.all).toContain(
'--root-actions and --root-events only apply to --strategy root-messenger',
);
});
});

it('rejects an unknown strategy', async () => {
expect.assertions(2);

await withinSandbox(async ({ directoryPath }) => {
const result = await runCLI([directoryPath, '--strategy', 'telepathy']);

expect(result.exitCode).not.toBe(0);
expect(result.all).toContain('Invalid values');
});
});
});
});
77 changes: 76 additions & 1 deletion packages/platform-api-docs/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import npmWhich from 'npm-which';
import yargs from 'yargs';

import { generate, resolveRepoUrl } from './generate.js';
import type { RootTypeReference } from './root-messenger-discovery.js';
import { parseRootTypeReference } from './root-messenger-discovery.js';

/**
* Locate the Docusaurus binary in this package's `node_modules/.bin`. Using
Expand Down Expand Up @@ -119,6 +121,53 @@ async function resolveCommitSha(projectPath: string): Promise<string | null> {
}
}

/** The subset of parsed arguments {@link checkStrategyArgs} validates. */
type StrategyArgs = {
strategy?: string;
'root-actions'?: unknown;
'root-events'?: unknown;
'scan-dir'?: string[];
};

/**
* Reject flag combinations that don't make sense, so a mistaken invocation
* fails instead of silently producing docs built the wrong way.
*
* Flags belonging to the strategy that wasn't selected are errors rather than
* ignored. Used as a yargs `.check`, so failures print alongside usage.
*
* @param argv - The parsed arguments.
* @returns True when the combination is valid.
*/
function checkStrategyArgs(argv: StrategyArgs): boolean {
const rootActions = argv['root-actions'];
const rootEvents = argv['root-events'];

if (argv.strategy !== 'root-messenger') {
if (rootActions !== undefined || rootEvents !== undefined) {
throw new Error(
'--root-actions and --root-events only apply to --strategy root-messenger.',
);
}
return true;
}

if (rootActions === undefined || rootEvents === undefined) {
throw new Error(
'--strategy root-messenger requires both --root-actions and --root-events, ' +
'each written as "<file>#<TypeName>".',
);
}
if ((argv['scan-dir'] ?? []).length > 0) {
throw new Error(
'--scan-dir only applies to --strategy scan; --strategy root-messenger reads ' +
'only the files named by --root-actions and --root-events.',
);
}

return true;
}

/**
* Main CLI entry point.
*/
Expand Down Expand Up @@ -153,6 +202,23 @@ async function main(): Promise<void> {
'Generate platform API docs and serve a development-only site',
default: false,
})
.option('strategy', {
type: 'string',
choices: ['scan', 'root-messenger'],
description:
'How to find messenger actions and events. "scan" parses every source and declaration file looking for messenger types. "root-messenger" instead resolves the two types the project declares for its root messenger',
default: 'scan',
})
.option('root-actions', {
type: 'string',
description:
'Type aliasing the union of every action on the root messenger, written as "<file>#<TypeName>" (required with --strategy root-messenger)',
})
.option('root-events', {
type: 'string',
description:
'Type aliasing the union of every event on the root messenger, written as "<file>#<TypeName>" (required with --strategy root-messenger)',
})
.option('scan-dir', {
type: 'string',
array: true,
Expand All @@ -179,6 +245,9 @@ async function main(): Promise<void> {
description:
'Path prefix the built site will be served under, e.g. /core/platform-api/',
})
.coerce('root-actions', parseRootTypeReference)
.coerce('root-events', parseRootTypeReference)
.check(checkStrategyArgs)
.help().argv;

const projectPathArg = argv['project-path'];
Expand All @@ -203,9 +272,15 @@ async function main(): Promise<void> {
await generate({
projectPath: resolvedProjectPath,
outputDir: resolvedOutputDir,
scanDirs,
projectLabel,
commitSha,
...(argv.strategy === 'root-messenger'
? {
strategy: 'root-messenger' as const,
rootActions: argv['root-actions'] as RootTypeReference,
rootEvents: argv['root-events'] as RootTypeReference,
}
: { strategy: 'scan' as const, scanDirs }),
});

// Step 2: If --build, --serve, or --dev, set up and run Docusaurus
Expand Down
Loading