Skip to content

Commit 29c2b3a

Browse files
committed
refactor(@angular/cli): introspect yargs to generate JSON Help
With this change we update yargs help method to output help in JSON format which is needed to generate the documents that are used to generate AIO man pages.
1 parent 2e04931 commit 29c2b3a

File tree

6 files changed

+331
-40
lines changed

6 files changed

+331
-40
lines changed

packages/angular/cli/lib/cli/command-runner.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ import { TestCommandModule } from '../../commands/test/cli';
2727
import { UpdateCommandModule } from '../../commands/update/cli';
2828
import { VersionCommandModule } from '../../commands/version/cli';
2929
import { colors } from '../../utilities/color';
30-
import { CommandContext, CommandModuleError } from '../../utilities/command-builder/command-module';
30+
import {
31+
CommandContext,
32+
CommandModuleError,
33+
CommandScope,
34+
} from '../../utilities/command-builder/command-module';
35+
import { jsonHelpUsage } from '../../utilities/command-builder/json-help';
3136
import { AngularWorkspace } from '../../utilities/config';
3237

3338
const COMMANDS = [
@@ -63,8 +68,9 @@ export async function runCommand(
6368
$0,
6469
_: positional,
6570
help = false,
71+
jsonHelp = false,
6672
...rest
67-
} = yargsParser(args, { boolean: ['help'], alias: { 'collection': 'c' } });
73+
} = yargsParser(args, { boolean: ['help', 'json-help'], alias: { 'collection': 'c' } });
6874

6975
const context: CommandContext = {
7076
workspace,
@@ -82,13 +88,27 @@ export async function runCommand(
8288

8389
let localYargs = yargs(args);
8490
for (const CommandModule of COMMANDS) {
91+
if (!jsonHelp) {
92+
// Skip scope validation when running with '--json-help' since it's easier to generate the output for all commands this way.
93+
const scope = CommandModule.scope;
94+
if ((scope === CommandScope.In && !workspace) || (scope === CommandScope.Out && workspace)) {
95+
continue;
96+
}
97+
}
98+
8599
// eslint-disable-next-line @typescript-eslint/no-explicit-any
86100
const commandModule = new CommandModule(context) as any;
101+
const describe = jsonHelp ? commandModule.fullDescribe : commandModule.describe;
87102

88103
localYargs = localYargs.command({
89104
command: commandModule.command,
90105
aliases: commandModule.aliases,
91-
describe: commandModule.describe,
106+
describe:
107+
// We cannot add custom fields in help, such as long command description which is used in AIO.
108+
// Therefore, we get around this by adding a complex object as a string which we later parse when geneerating the help files.
109+
describe !== undefined && typeof describe === 'object'
110+
? JSON.stringify(describe)
111+
: describe,
92112
deprecated: commandModule.deprecated,
93113
builder: (x) => commandModule.builder(x),
94114
handler: ({ _, $0, ...options }) => {
@@ -103,6 +123,11 @@ export async function runCommand(
103123
});
104124
}
105125

126+
if (jsonHelp) {
127+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
128+
(localYargs as any).getInternalMethods().getUsageInstance().help = () => jsonHelpUsage();
129+
}
130+
106131
await localYargs
107132
.scriptName('ng')
108133
// https://github.com/yargs/yargs/blob/main/docs/advanced.md#customizing-yargs-parser

packages/angular/cli/utilities/command-builder/command-module.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export interface CommandModuleImplementation<T extends {} = {}>
6262
export interface FullDescribe {
6363
describe?: string;
6464
longDescription?: string;
65+
longDescriptionRelativePath?: string;
6566
}
6667

6768
export abstract class CommandModule<T extends {} = {}> implements CommandModuleImplementation<T> {
@@ -86,9 +87,15 @@ export abstract class CommandModule<T extends {} = {}> implements CommandModuleI
8687
? false
8788
: {
8889
describe: this.describe,
89-
longDescription: this.longDescriptionPath
90-
? readFileSync(this.longDescriptionPath, 'utf8')
91-
: undefined,
90+
...(this.longDescriptionPath
91+
? {
92+
longDescriptionRelativePath: path.relative(
93+
path.join(__dirname, '../../../../'),
94+
this.longDescriptionPath,
95+
),
96+
longDescription: readFileSync(this.longDescriptionPath, 'utf8'),
97+
}
98+
: {}),
9299
};
93100
}
94101

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import yargs from 'yargs';
10+
import { FullDescribe } from './command-module';
11+
12+
export interface JsonHelp {
13+
name: string;
14+
description?: string;
15+
command: string;
16+
longDescription?: string;
17+
longDescriptionRelativePath?: string;
18+
options: JsonHelpOption[];
19+
subcommands?: {
20+
name: string;
21+
description: string;
22+
aliases: string[];
23+
deprecated: string | boolean;
24+
}[];
25+
}
26+
27+
interface JsonHelpOption {
28+
name: string;
29+
type?: string;
30+
deprecated: boolean | string;
31+
aliases?: string[];
32+
default?: string;
33+
required?: boolean;
34+
positional?: number;
35+
enum?: string[];
36+
description?: string;
37+
}
38+
39+
export function jsonHelpUsage(): string {
40+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
41+
const localYargs = yargs as any;
42+
const {
43+
deprecatedOptions,
44+
alias: aliases,
45+
array,
46+
string,
47+
boolean,
48+
number,
49+
choices,
50+
demandedOptions,
51+
default: defaultVal,
52+
hiddenOptions = [],
53+
} = localYargs.getOptions();
54+
55+
const internalMethods = localYargs.getInternalMethods();
56+
const usageInstance = internalMethods.getUsageInstance();
57+
const context = internalMethods.getContext();
58+
const descriptions = usageInstance.getDescriptions();
59+
const groups = localYargs.getGroups();
60+
const positional = groups[usageInstance.getPositionalGroupName()] as string[] | undefined;
61+
62+
const hidden = new Set(hiddenOptions);
63+
const normalizeOptions: JsonHelpOption[] = [];
64+
const allAliases = new Set([...Object.values<string[]>(aliases).flat()]);
65+
66+
for (const [names, type] of [
67+
[array, 'array'],
68+
[string, 'string'],
69+
[boolean, 'boolean'],
70+
[number, 'number'],
71+
]) {
72+
for (const name of names) {
73+
if (allAliases.has(name) || hidden.has(name)) {
74+
// Ignore hidden, aliases and already visited option.
75+
continue;
76+
}
77+
78+
const positionalIndex = positional?.indexOf(name) ?? -1;
79+
const alias = aliases[name];
80+
81+
normalizeOptions.push({
82+
name,
83+
type,
84+
deprecated: deprecatedOptions[name],
85+
aliases: alias?.length > 0 ? alias : undefined,
86+
default: defaultVal[name],
87+
required: demandedOptions[name],
88+
enum: choices[name],
89+
description: descriptions[name]?.replace('__yargsString__:', ''),
90+
positional: positionalIndex >= 0 ? positionalIndex : undefined,
91+
});
92+
}
93+
}
94+
95+
// https://github.com/yargs/yargs/blob/00e4ebbe3acd438e73fdb101e75b4f879eb6d345/lib/usage.ts#L124
96+
const subcommands = (
97+
usageInstance.getCommands() as [
98+
name: string,
99+
description: string,
100+
isDefault: boolean,
101+
aliases: string[],
102+
deprecated: string | boolean,
103+
][]
104+
)
105+
.map(([name, description, _, aliases, deprecated]) => ({
106+
name: name.split(' ', 1)[0],
107+
command: name,
108+
description,
109+
aliases,
110+
deprecated,
111+
}))
112+
.sort((a, b) => a.name.localeCompare(b.name));
113+
114+
const parseDescription = (rawDescription: string) => {
115+
try {
116+
const {
117+
longDescription,
118+
describe: description,
119+
longDescriptionRelativePath,
120+
} = JSON.parse(rawDescription) as FullDescribe;
121+
122+
return {
123+
description,
124+
longDescriptionRelativePath,
125+
longDescription,
126+
};
127+
} catch {
128+
return {
129+
description: rawDescription,
130+
};
131+
}
132+
};
133+
134+
const [command, rawDescription] = usageInstance.getUsage()[0] ?? [];
135+
136+
const output: JsonHelp = {
137+
name: [...context.commands].pop(),
138+
command: command?.replace('$0', localYargs['$0']),
139+
...parseDescription(rawDescription),
140+
options: normalizeOptions.sort((a, b) => a.name.localeCompare(b.name)),
141+
subcommands: subcommands.length ? subcommands : undefined,
142+
};
143+
144+
return JSON.stringify(output, undefined, 2);
145+
}

scripts/json-help.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import { logging } from '@angular-devkit/core';
10+
import { spawn } from 'child_process';
11+
import { promises as fs } from 'fs';
12+
import * as os from 'os';
13+
import { JsonHelp } from 'packages/angular/cli/utilities/command-builder/json-help';
14+
import * as path from 'path';
15+
import { packages } from '../lib/packages';
16+
import create from './create';
17+
18+
export default async function (opts = {}, logger: logging.Logger) {
19+
logger.info('Creating temporary project...');
20+
const newProjectTempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'angular-cli-create-'));
21+
const newProjectName = 'help-project';
22+
const newProjectRoot = path.join(newProjectTempRoot, newProjectName);
23+
await create({ _: [newProjectName] }, logger.createChild('create'), newProjectTempRoot);
24+
25+
logger.info('Gathering JSON Help...');
26+
const ngPath = path.join(newProjectRoot, 'node_modules/.bin/ng');
27+
const helpOutputRoot = path.join(packages['@angular/cli'].dist, 'help');
28+
await fs.mkdir(helpOutputRoot);
29+
30+
const runNgCommandJsonHelp = async (args: string[]) => {
31+
const process = spawn(ngPath, [...args, '--json-help', '--help'], {
32+
cwd: newProjectRoot,
33+
stdio: ['ignore', 'pipe', 'inherit'],
34+
});
35+
36+
let result = '';
37+
process.stdout.on('data', (data) => {
38+
result += data.toString();
39+
});
40+
41+
return new Promise<JsonHelp>((resolve, reject) => {
42+
process
43+
.on('close', (code) => {
44+
if (code === 0) {
45+
resolve(JSON.parse(result.trim()));
46+
} else {
47+
reject(
48+
new Error(
49+
`Command failed: ${ngPath} ${args.map((x) => JSON.stringify(x)).join(', ')}`,
50+
),
51+
);
52+
}
53+
})
54+
.on('error', (err) => reject(err));
55+
});
56+
};
57+
58+
const { subcommands: commands = [] } = await runNgCommandJsonHelp([]);
59+
const commandsHelp = commands.map((command) =>
60+
runNgCommandJsonHelp([command.name]).then((c) => ({
61+
...command,
62+
...c,
63+
})),
64+
);
65+
66+
for await (const command of commandsHelp) {
67+
const commandName = command.name;
68+
const commandOptionNames = new Set([...command.options.map(({ name }) => name)]);
69+
70+
const subCommandsHelp = command.subcommands?.map((subcommand) =>
71+
runNgCommandJsonHelp([command.name, subcommand.name]).then((s) => ({
72+
...s,
73+
...subcommand,
74+
// Filter options which are inherited from the parent command.
75+
// Ex: `interactive` in `ng generate lib`.
76+
options: s.options.filter((o) => !commandOptionNames.has(o.name)),
77+
})),
78+
);
79+
80+
const jsonOutput = JSON.stringify(
81+
{
82+
...command,
83+
subcommands: subCommandsHelp ? await Promise.all(subCommandsHelp) : undefined,
84+
},
85+
undefined,
86+
2,
87+
);
88+
89+
const filePath = path.join(helpOutputRoot, commandName + '.json');
90+
await fs.writeFile(filePath, jsonOutput);
91+
logger.info(filePath);
92+
}
93+
}

scripts/snapshots.ts

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import * as os from 'os';
1313
import * as path from 'path';
1414
import { PackageInfo, packages } from '../lib/packages';
1515
import build from './build-bazel';
16-
import create from './create';
16+
import jsonHelp from './json-help';
1717

1818
// Added to the README.md of the snapshot. This is markdown.
1919
const readmeHeaderFn = (pkg: PackageInfo) => `
@@ -164,31 +164,12 @@ export default async function (opts: SnapshotsOptions, logger: logging.Logger) {
164164
_exec('git', ['config', '--global', 'push.default', 'simple'], {}, logger);
165165
}
166166

167-
// Creating a new project and reading the help.
168-
logger.info('Creating temporary project...');
169-
const newProjectTempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'angular-cli-create-'));
170-
const newProjectName = 'help-project';
171-
const newProjectRoot = path.join(newProjectTempRoot, newProjectName);
172-
await create({ _: [newProjectName] }, logger.createChild('create'), newProjectTempRoot);
167+
await jsonHelp(undefined, logger);
173168

174169
// Run build.
175170
logger.info('Building...');
176171
await build({ snapshot: true }, logger.createChild('build'));
177172

178-
logger.info('Gathering JSON Help...');
179-
const ngPath = path.join(newProjectRoot, 'node_modules/.bin/ng');
180-
const helpOutputRoot = path.join(packages['@angular/cli'].dist, 'help');
181-
fs.mkdirSync(helpOutputRoot);
182-
const commands = require('../packages/angular/cli/commands.json');
183-
for (const commandName of Object.keys(commands)) {
184-
const options = { cwd: newProjectRoot };
185-
const childLogger = logger.createChild(commandName);
186-
const stdout = _exec(ngPath, [commandName, '--help=json'], options, childLogger);
187-
// Make sure the output is JSON before printing it, and format it as well.
188-
const jsonOutput = JSON.stringify(JSON.parse(stdout.trim()), undefined, 2);
189-
fs.writeFileSync(path.join(helpOutputRoot, commandName + '.json'), jsonOutput);
190-
}
191-
192173
if (!githubToken) {
193174
logger.info('No token given, skipping actual publishing...');
194175

0 commit comments

Comments
 (0)