Skip to content

Commit

Permalink
add types
Browse files Browse the repository at this point in the history
  • Loading branch information
tommy-mitchell committed Mar 3, 2024
1 parent 02c9761 commit 648f69c
Show file tree
Hide file tree
Showing 25 changed files with 386 additions and 170 deletions.
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -2,3 +2,4 @@ node_modules
build
yarn.lock
test-d/build.ts
.tsimp
2 changes: 2 additions & 0 deletions package.json
Expand Up @@ -63,10 +63,12 @@
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.2.3",
"@sindresorhus/is": "^6.2.0",
"@sindresorhus/tsconfig": "^5.0.0",
"@types/common-tags": "^1.8.4",
"@types/minimist": "^1.2.5",
"@types/node": "18",
"@types/stack-utils": "^2.0.3",
"@types/yargs-parser": "^21.0.3",
"ava": "^6.1.2",
"camelcase-keys": "^9.1.3",
Expand Down
5 changes: 3 additions & 2 deletions rollup.config.js
Expand Up @@ -79,9 +79,10 @@ const dtsConfig = defineConfig({
name: 'copy-tsd',
async generateBundle() {
let tsdFile = await fs.readFile('./test-d/index.ts', 'utf8');

tsdFile = tsdFile.replace(
`import meow from '../${sourceDirectory}/index.js'`,
`import meow from '../${outputDirectory}/index.js'`,
`../${sourceDirectory}/index.js`,
`../${outputDirectory}/index.js`,
);

await fs.writeFile(`./test-d/${outputDirectory}.ts`, tsdFile);
Expand Down
98 changes: 77 additions & 21 deletions source/index.ts
@@ -1,14 +1,20 @@
import process from 'node:process';
import parseArguments from 'yargs-parser';
import parseArguments, {type Options as ParserOptions} from 'yargs-parser';
import camelCaseKeys from 'camelcase-keys';
import {trimNewlines} from 'trim-newlines';
import redent from 'redent';
import {buildOptions} from './options.js';
import {buildParserOptions} from './parser.js';
import {validate, checkUnknownFlags, checkMissingRequiredFlags} from './validate.js';

const buildResult = ({pkg: packageJson, ...options}, parserOptions) => {
const argv = parseArguments(options.argv, parserOptions);
import type {
Options,
ParsedOptions,
Result,
AnyFlags,
} from './types.js';

const buildResult = <Flags extends AnyFlags = AnyFlags>({pkg: packageJson, ...options}: ParsedOptions, parserOptions: ParserOptions): Result<Flags> => {
const {_: input, ...argv} = parseArguments(options.argv as string[], parserOptions);
let help = '';

if (options.help) {
Expand All @@ -32,7 +38,7 @@ const buildResult = ({pkg: packageJson, ...options}, parserOptions) => {

help += '\n';

const showHelp = code => {
const showHelp = (code?: number) => {
console.log(help);
process.exit(typeof code === 'number' ? code : 2); // Default to code 2 for incorrect usage (#47)
};
Expand All @@ -42,17 +48,14 @@ const buildResult = ({pkg: packageJson, ...options}, parserOptions) => {
process.exit(0);
};

if (argv._.length === 0 && options.argv.length === 1) {
if (argv.version === true && options.autoVersion) {
if (input.length === 0 && options.argv.length === 1) {
if (argv['version'] === true && options.autoVersion) {
showVersion();
} else if (argv.help === true && options.autoHelp) {
} else if (argv['help'] === true && options.autoHelp) {
showHelp(0);
}
}

const input = argv._;
delete argv._;

if (!options.allowUnknownFlags) {
checkUnknownFlags(input);
}
Expand All @@ -69,12 +72,14 @@ const buildResult = ({pkg: packageJson, ...options}, parserOptions) => {
}
}

delete flags[flagValue.shortFlag];
if (flagValue.shortFlag) {
delete flags[flagValue.shortFlag];
}
}

checkMissingRequiredFlags(options.flags, flags, input);
checkMissingRequiredFlags(options.flags, flags, input as string[]);

return {
const result = {
input,
flags,
unnormalizedFlags,
Expand All @@ -83,16 +88,67 @@ const buildResult = ({pkg: packageJson, ...options}, parserOptions) => {
showHelp,
showVersion,
};

return result as unknown as Result<Flags>;
};

const meow = (helpText, options = {}) => {
const parsedOptions = buildOptions(helpText, options);
/**
@param helpMessage - Shortcut for the `help` option.
@example
```
#!/usr/bin/env node
import meow from 'meow';
import foo from './index.js';
const cli = meow(`
Usage
$ foo <input>
Options
--rainbow, -r Include a rainbow
Examples
$ foo unicorns --rainbow
🌈 unicorns 🌈
`, {
importMeta: import.meta, // This is required
flags: {
rainbow: {
type: 'boolean',
shortFlag: 'r'
}
}
});
//{
// input: ['unicorns'],
// flags: {rainbow: true},
// ...
//}
foo(cli.input.at(0), cli.flags);
```
*/
export default function meow<Flags extends AnyFlags>(helpMessage: string, options: Options<Flags>): Result<Flags>;
export default function meow<Flags extends AnyFlags>(options: Options<Flags>): Result<Flags>;

export default function meow<Flags extends AnyFlags = AnyFlags>(helpMessage: string | Options<Flags>, options?: Options<Flags>): Result<Flags> {
if (typeof helpMessage !== 'string') {
options = helpMessage;
helpMessage = '';
}

const parsedOptions = buildOptions(helpMessage, options!);
const parserOptions = buildParserOptions(parsedOptions);
const result = buildResult(parsedOptions, parserOptions);
const result = buildResult<Flags>(parsedOptions, parserOptions);

process.title = result.pkg.bin ? Object.keys(result.pkg.bin).at(0) : result.pkg.name;
const packageTitle = result.pkg.bin ? Object.keys(result.pkg.bin).at(0) : result.pkg.name;

return result;
};
// TODO: move to separate PR?
if (packageTitle) {
process.title = packageTitle;
}

export default meow;
return result;
}
67 changes: 67 additions & 0 deletions source/minimist-options.d.ts
@@ -0,0 +1,67 @@
/* eslint-disable import/no-extraneous-dependencies */
declare module 'minimist-options' {
import type {Opts as MinimistOptions} from 'minimist';

export type {Opts as MinimistOptions} from 'minimist';

export type OptionType = 'string' | 'boolean' | 'number' | 'array' | 'string-array' | 'boolean-array' | 'number-array';

export type BaseOption<
TypeOptionType extends OptionType,
DefaultOptionType,
> = {
/**
* The data type the option should be parsed to.
*/
readonly type?: TypeOptionType;

/**
* An alias/list of aliases for the option.
*/
readonly alias?: string | readonly string[];

/**
* The default value for the option.
*/
readonly default?: DefaultOptionType;
};

export type StringOption = BaseOption<'string', string>;
export type BooleanOption = BaseOption<'boolean', boolean>;
export type NumberOption = BaseOption<'number', number>;
export type DefaultArrayOption = BaseOption<'array', readonly string[]>;
export type StringArrayOption = BaseOption<'string-array', readonly string[]>;
export type BooleanArrayOption = BaseOption<'boolean-array', readonly boolean[]>;
export type NumberArrayOption = BaseOption<'number-array', readonly number[]>;

export type AnyOption = (
| StringOption
| BooleanOption
| NumberOption
| DefaultArrayOption
| StringArrayOption
| BooleanArrayOption
| NumberArrayOption
);

export type MinimistOption = Pick<MinimistOptions, 'stopEarly' | 'unknown' | '--'>;

export type Options = MinimistOption & {
[key: string]: (
| OptionType
| StringOption
| BooleanOption
| NumberOption
| DefaultArrayOption
| StringArrayOption
| BooleanArrayOption
| NumberArrayOption
);
arguments?: string;
};

/**
* Write options for [minimist](https://npmjs.org/package/minimist) in a comfortable way. Support string, boolean, number and array options.
*/
export default function buildOptions(options?: Options): MinimistOptions;
}
49 changes: 30 additions & 19 deletions source/options.ts
@@ -1,12 +1,27 @@
import process from 'node:process';
import {dirname} from 'node:path';
import {fileURLToPath} from 'node:url';
import {readPackageUpSync} from 'read-package-up';
import {readPackageUpSync, type PackageJson} from 'read-package-up';
import normalizePackageData from 'normalize-package-data';
import {decamelizeFlagKey, joinFlagKeys} from './utils.js';
import type {
Options,
ParsedOptions,
AnyFlag,
AnyFlags,
} from './types.js';

const validateOptions = options => {
const invalidOptionFilters = {
type InvalidOptionFilter = {
filter: (flag: [flagKey: string, flag: AnyFlag]) => boolean;
message: (flagKeys: string[]) => string;
};

type InvalidOptionFilters = {
flags: Record<string, InvalidOptionFilter>;
};

const validateOptions = (options: ParsedOptions): void => {
const invalidOptionFilters: InvalidOptionFilters = {
flags: {
keyContainsDashes: {
filter: ([flagKey]) => flagKey.includes('-') && flagKey !== '--',
Expand All @@ -21,22 +36,23 @@ const validateOptions = options => {
message: flagKeys => `The option \`choices\` must be an array. Invalid flags: ${joinFlagKeys(flagKeys)}`,
},
choicesNotMatchFlagType: {
filter: ([, flag]) => flag.type && Array.isArray(flag.choices) && flag.choices.some(choice => typeof choice !== flag.type),
filter: ([, flag]) => flag.type !== undefined && Array.isArray(flag.choices) && flag.choices.some(choice => typeof choice !== flag.type),
message(flagKeys) {
const flagKeysAndTypes = flagKeys.map(flagKey => `(\`${decamelizeFlagKey(flagKey)}\`, type: '${options.flags[flagKey].type}')`);
const flagKeysAndTypes = flagKeys.map(flagKey => `(\`${decamelizeFlagKey(flagKey)}\`, type: '${options.flags[flagKey]!.type}')`);
return `Each value of the option \`choices\` must be of the same type as its flag. Invalid flags: ${flagKeysAndTypes.join(', ')}`;
},
},
defaultNotInChoices: {
filter: ([, flag]) => flag.default && Array.isArray(flag.choices) && ![flag.default].flat().every(value => flag.choices.includes(value)),
filter: ([, flag]) => flag.default !== undefined && Array.isArray(flag.choices) && ![flag.default].flat().every(value => flag.choices!.includes(value as never)), // TODO: never?
message: flagKeys => `Each value of the option \`default\` must exist within the option \`choices\`. Invalid flags: ${joinFlagKeys(flagKeys)}`,
},
},
};

const errorMessages = [];
type Entry = ['flags', Record<string, InvalidOptionFilter>];

for (const [optionKey, filters] of Object.entries(invalidOptionFilters)) {
for (const [optionKey, filters] of Object.entries(invalidOptionFilters) as Entry[]) {
const optionEntries = Object.entries(options[optionKey]);

for (const {filter, message} of Object.values(filters)) {
Expand All @@ -54,17 +70,12 @@ const validateOptions = options => {
}
};

export const buildOptions = (helpText, options) => {
if (typeof helpText !== 'string') {
options = helpText;
helpText = '';
}

if (!options.importMeta?.url) {
export const buildOptions = (helpMessage: string, options: Options<AnyFlags>): ParsedOptions => {
if (!options?.importMeta?.url) {
throw new TypeError('The `importMeta` option is required. Its value must be `import.meta`.');
}

const foundPackage = options.pkg ?? readPackageUpSync({
const foundPackage = options.pkg as PackageJson ?? readPackageUpSync({
cwd: dirname(fileURLToPath(options.importMeta.url)),
normalize: false,
})?.packageJson;
Expand All @@ -73,14 +84,14 @@ export const buildOptions = (helpText, options) => {
const pkg = foundPackage ?? {};
normalizePackageData(pkg);

const parsedOptions = {
const parsedOptions: ParsedOptions = {
argv: process.argv.slice(2),
flags: {},
inferType: false,
input: 'string',
input: 'string', // TODO: undocumented option?
description: pkg.description ?? false,
help: helpText,
version: pkg.version || 'No version found',
help: helpMessage,
version: pkg.version || 'No version found', // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing
autoHelp: true,
autoVersion: true,
booleanDefault: false,
Expand Down

0 comments on commit 648f69c

Please sign in to comment.