Validate @babel/preset-env options (#8031)

This commit is contained in:
Serhii Nanovskyi 2018-06-10 02:38:14 +03:00 committed by Brian Ng
parent 110779e9f3
commit 3de053cc6c
10 changed files with 226 additions and 46 deletions

View File

@ -166,3 +166,9 @@ declare module "convert-source-map" {
generateMapFileComment(path: string, options?: ?{ multiline: boolean }): string, generateMapFileComment(path: string, options?: ?{ multiline: boolean }): string,
}; };
} }
declare module "js-levenshtein" {
declare module.exports: {
(string, string): number,
};
}

View File

@ -49,6 +49,7 @@
"@babel/plugin-transform-unicode-regex": "7.0.0-beta.49", "@babel/plugin-transform-unicode-regex": "7.0.0-beta.49",
"browserslist": "^3.0.0", "browserslist": "^3.0.0",
"invariant": "^2.2.2", "invariant": "^2.2.2",
"js-levenshtein": "^1.1.3",
"semver": "^5.3.0" "semver": "^5.3.0"
}, },
"peerDependencies": { "peerDependencies": {

View File

@ -5,9 +5,23 @@ import browserslist from "browserslist";
import builtInsList from "../data/built-ins.json"; import builtInsList from "../data/built-ins.json";
import { defaultWebIncludes } from "./default-includes"; import { defaultWebIncludes } from "./default-includes";
import moduleTransformations from "./module-transformations"; import moduleTransformations from "./module-transformations";
import { getValues, findSuggestion } from "./utils";
import pluginsList from "../data/plugins.json"; import pluginsList from "../data/plugins.json";
import { TopLevelOptions, ModulesOption, UseBuiltInsOption } from "./options";
import type { Targets, Options, ModuleOption, BuiltInsOption } from "./types"; import type { Targets, Options, ModuleOption, BuiltInsOption } from "./types";
const validateTopLevelOptions = (options: Options) => {
for (const option in options) {
if (!TopLevelOptions[option]) {
const validOptions = getValues(TopLevelOptions);
throw new Error(
`Invalid Option: ${option} is not a valid top-level option.
Maybe you meant to use '${findSuggestion(validOptions, option)}'?`,
);
}
}
};
const validIncludesAndExcludes = new Set([ const validIncludesAndExcludes = new Set([
...Object.keys(pluginsList), ...Object.keys(pluginsList),
...Object.keys(moduleTransformations).map(m => moduleTransformations[m]), ...Object.keys(moduleTransformations).map(m => moduleTransformations[m]),
@ -108,17 +122,17 @@ export const validateIgnoreBrowserslistConfig = (
ignoreBrowserslistConfig: boolean, ignoreBrowserslistConfig: boolean,
) => ) =>
validateBoolOption( validateBoolOption(
"ignoreBrowserslistConfig", TopLevelOptions.ignoreBrowserslistConfig,
ignoreBrowserslistConfig, ignoreBrowserslistConfig,
false, false,
); );
export const validateModulesOption = ( export const validateModulesOption = (
modulesOpt: ModuleOption = "commonjs", modulesOpt: ModuleOption = ModulesOption.commonjs,
) => { ) => {
invariant( invariant(
modulesOpt === false || ModulesOption[modulesOpt] ||
Object.keys(moduleTransformations).indexOf(modulesOpt) > -1, ModulesOption[modulesOpt] === ModulesOption.false,
`Invalid Option: The 'modules' option must be either 'false' to indicate no modules, or a `Invalid Option: The 'modules' option must be either 'false' to indicate no modules, or a
module type which can be be one of: 'commonjs' (default), 'amd', 'umd', 'systemjs'.`, module type which can be be one of: 'commonjs' (default), 'amd', 'umd', 'systemjs'.`,
); );
@ -140,7 +154,8 @@ export const validateUseBuiltInsOption = (
builtInsOpt: BuiltInsOption = false, builtInsOpt: BuiltInsOption = false,
): BuiltInsOption => { ): BuiltInsOption => {
invariant( invariant(
builtInsOpt === "usage" || builtInsOpt === false || builtInsOpt === "entry", UseBuiltInsOption[builtInsOpt] ||
UseBuiltInsOption[builtInsOpt] === UseBuiltInsOption.false,
`Invalid Option: The 'useBuiltIns' option must be either `Invalid Option: The 'useBuiltIns' option must be either
'false' (default) to indicate no polyfill, 'false' (default) to indicate no polyfill,
'"entry"' to indicate replacing the entry polyfill, or '"entry"' to indicate replacing the entry polyfill, or
@ -151,32 +166,40 @@ export const validateUseBuiltInsOption = (
}; };
export default function normalizeOptions(opts: Options) { export default function normalizeOptions(opts: Options) {
const include = expandIncludesAndExcludes(opts.include, "include"); validateTopLevelOptions(opts);
const exclude = expandIncludesAndExcludes(opts.exclude, "exclude");
const include = expandIncludesAndExcludes(
opts.include,
TopLevelOptions.include,
);
const exclude = expandIncludesAndExcludes(
opts.exclude,
TopLevelOptions.exclude,
);
checkDuplicateIncludeExcludes(include, exclude); checkDuplicateIncludeExcludes(include, exclude);
return { return {
configPath: validateConfigPathOption(opts.configPath), configPath: validateConfigPathOption(opts.configPath),
debug: opts.debug, debug: validateBoolOption(TopLevelOptions.debug, opts.debug, false),
include, include,
exclude, exclude,
forceAllTransforms: validateBoolOption( forceAllTransforms: validateBoolOption(
"forceAllTransforms", TopLevelOptions.forceAllTransforms,
opts.forceAllTransforms, opts.forceAllTransforms,
false, false,
), ),
ignoreBrowserslistConfig: validateIgnoreBrowserslistConfig( ignoreBrowserslistConfig: validateIgnoreBrowserslistConfig(
opts.ignoreBrowserslistConfig, opts.ignoreBrowserslistConfig,
), ),
loose: validateBoolOption("loose", opts.loose, false), loose: validateBoolOption(TopLevelOptions.loose, opts.loose, false),
modules: validateModulesOption(opts.modules), modules: validateModulesOption(opts.modules),
shippedProposals: validateBoolOption( shippedProposals: validateBoolOption(
"shippedProposals", TopLevelOptions.shippedProposals,
opts.shippedProposals, opts.shippedProposals,
false, false,
), ),
spec: validateBoolOption("loose", opts.spec, false), spec: validateBoolOption(TopLevelOptions.spec, opts.spec, false),
targets: { targets: {
...opts.targets, ...opts.targets,
}, },

View File

@ -0,0 +1,44 @@
export const TopLevelOptions = {
configPath: "configPath",
debug: "debug",
exclude: "exclude",
forceAllTransforms: "forceAllTransforms",
ignoreBrowserslistConfig: "ignoreBrowserslistConfig",
include: "include",
loose: "loose",
modules: "modules",
shippedProposals: "shippedProposals",
spec: "spec",
targets: "targets",
useBuiltIns: "useBuiltIns",
};
export const ModulesOption = {
false: false,
amd: "amd",
commonjs: "commonjs",
cjs: "cjs",
systemjs: "systemjs",
umd: "umd",
};
export const UseBuiltInsOption = {
false: false,
entry: "entry",
usage: "usage",
};
export const TargetNames = {
esmodules: "esmodules",
node: "node",
browsers: "browsers",
chrome: "chrome",
opera: "opera",
edge: "edge",
firefox: "firefox",
safari: "safari",
ie: "ie",
ios: "ios",
android: "android",
electron: "electron",
};

View File

@ -1,12 +1,32 @@
// @flow // @flow
import browserslist from "browserslist"; import browserslist from "browserslist";
import invariant from "invariant";
import semver from "semver"; import semver from "semver";
import { semverify, isUnreleasedVersion, getLowestUnreleased } from "./utils"; import {
semverify,
isUnreleasedVersion,
getLowestUnreleased,
getValues,
findSuggestion,
} from "./utils";
import { objectToBrowserslist } from "./normalize-options"; import { objectToBrowserslist } from "./normalize-options";
import browserModulesData from "../data/built-in-modules.json"; import browserModulesData from "../data/built-in-modules.json";
import { TargetNames } from "./options";
import type { Targets } from "./types"; import type { Targets } from "./types";
const validateTargetNames = (validTargets, targets) => {
for (const target in targets) {
if (!TargetNames[target]) {
const validOptions = getValues(TargetNames);
throw new Error(
`Invalid Option: '${target}' is not a valid target
Maybe you meant to use '${findSuggestion(validOptions, target)}'?`,
);
}
}
};
const browserNameMap = { const browserNameMap = {
android: "android", android: "android",
chrome: "chrome", chrome: "chrome",
@ -21,13 +41,21 @@ const browserNameMap = {
const isBrowsersQueryValid = (browsers: string | Array<string>): boolean => const isBrowsersQueryValid = (browsers: string | Array<string>): boolean =>
typeof browsers === "string" || Array.isArray(browsers); typeof browsers === "string" || Array.isArray(browsers);
const validateBrowsers = browsers => {
invariant(
typeof browsers === "undefined" || isBrowsersQueryValid(browsers),
`Invalid Option: '${browsers}' is not a valid browserslist query`,
);
return browsers;
};
export const semverMin = (first: ?string, second: string): string => { export const semverMin = (first: ?string, second: string): string => {
return first && semver.lt(first, second) ? first : second; return first && semver.lt(first, second) ? first : second;
}; };
const mergeBrowsers = (fromQuery: Targets, fromTarget: Targets) => { const mergeBrowsers = (fromQuery: Targets, fromTarget: Targets) => {
return Object.keys(fromTarget).reduce((queryObj, targKey) => { return Object.keys(fromTarget).reduce((queryObj, targKey) => {
if (targKey !== "browsers") { if (targKey !== TargetNames.browsers) {
queryObj[targKey] = fromTarget[targKey]; queryObj[targKey] = fromTarget[targKey];
} }
return queryObj; return queryObj;
@ -85,11 +113,21 @@ const outputDecimalWarning = (decimalTargets: Array<Object>): void => {
console.log(""); console.log("");
}; };
const semverifyTarget = (target, value) => {
try {
return semverify(value);
} catch (error) {
throw new Error(
`Invalid Option: '${value}' is not a valid value for 'targets.${target}'.`,
);
}
};
const targetParserMap = { const targetParserMap = {
__default: (target, value) => { __default: (target, value) => {
const version = isUnreleasedVersion(value, target) const version = isUnreleasedVersion(value, target)
? value.toLowerCase() ? value.toLowerCase()
: semverify(value); : semverifyTarget(target, value);
return [target, version]; return [target, version];
}, },
@ -98,8 +136,7 @@ const targetParserMap = {
const parsed = const parsed =
value === true || value === "current" value === true || value === "current"
? process.versions.node ? process.versions.node
: semverify(value); : semverifyTarget(target, value);
return [target, parsed]; return [target, parsed];
}, },
}; };
@ -108,9 +145,12 @@ type ParsedResult = {
targets: Targets, targets: Targets,
decimalWarnings: Array<Object>, decimalWarnings: Array<Object>,
}; };
const getTargets = (targets: Object = {}, options: Object = {}): Targets => { const getTargets = (targets: Object = {}, options: Object = {}): Targets => {
const targetOpts: Targets = {}; const targetOpts: Targets = {};
validateTargetNames(targets);
// `esmodules` as a target indicates the specific set of browsers supporting ES Modules. // `esmodules` as a target indicates the specific set of browsers supporting ES Modules.
// These values OVERRIDE the `browsers` field. // These values OVERRIDE the `browsers` field.
if (targets.esmodules) { if (targets.esmodules) {
@ -119,23 +159,24 @@ const getTargets = (targets: Object = {}, options: Object = {}): Targets => {
.map(browser => `${browser} ${supportsESModules[browser]}`) .map(browser => `${browser} ${supportsESModules[browser]}`)
.join(", "); .join(", ");
} }
// Parse browsers target via browserslist;
const queryIsValid = isBrowsersQueryValid(targets.browsers); // Parse browsers target via browserslist
const browsersquery = queryIsValid ? targets.browsers : null; const browsersquery = validateBrowsers(targets.browsers);
if (queryIsValid || !options.ignoreBrowserslistConfig) { if (!options.ignoreBrowserslistConfig) {
browserslist.defaults = objectToBrowserslist(targets); browserslist.defaults = objectToBrowserslist(targets);
const browsers = browserslist(browsersquery, { path: options.configPath }); const browsers = browserslist(browsersquery, { path: options.configPath });
const queryBrowsers = getLowestVersions(browsers); const queryBrowsers = getLowestVersions(browsers);
targets = mergeBrowsers(queryBrowsers, targets); targets = mergeBrowsers(queryBrowsers, targets);
} }
// Parse remaining targets // Parse remaining targets
const parsed = Object.keys(targets) const parsed = Object.keys(targets)
.filter(value => value !== "esmodules") .filter(value => value !== TargetNames.esmodules)
.sort() .sort()
.reduce( .reduce(
(results: ParsedResult, target: string): ParsedResult => { (results: ParsedResult, target: string): ParsedResult => {
if (target !== "browsers") { if (target !== TargetNames.browsers) {
const value = targets[target]; const value = targets[target];
// Warn when specifying minor/patch as a decimal // Warn when specifying minor/patch as a decimal

View File

@ -1,15 +1,17 @@
//@flow //@flow
import { TargetNames, ModulesOption, UseBuiltInsOption } from "./options";
// Targets // Targets
export type Target = string; export type Target = $Keys<typeof TargetNames>;
export type Targets = { export type Targets = {
[target: string]: Target, [target: Target]: string,
}; };
// Options // Options
// Use explicit modules to prevent typo errors. // Use explicit modules to prevent typo errors.
export type ModuleOption = false | "amd" | "commonjs" | "systemjs" | "umd"; export type ModuleOption = $Values<typeof ModulesOption>;
export type BuiltInsOption = false | "entry" | "usage"; export type BuiltInsOption = $Values<typeof UseBuiltInsOption>;
export type Options = { export type Options = {
configPath: string, configPath: string,

View File

@ -1,26 +1,51 @@
// @flow // @flow
import invariant from "invariant";
import semver from "semver"; import semver from "semver";
import levenshtein from "js-levenshtein";
import { addSideEffect } from "@babel/helper-module-imports"; import { addSideEffect } from "@babel/helper-module-imports";
import unreleasedLabels from "../data/unreleased-labels"; import unreleasedLabels from "../data/unreleased-labels";
import { semverMin } from "./targets-parser"; import { semverMin } from "./targets-parser";
import type { Targets } from "./types"; import type { Targets } from "./types";
const versionRegExp = /^(\d+|\d+.\d+)$/;
// Convert version to a semver value. // Convert version to a semver value.
// 2.5 -> 2.5.0; 1 -> 1.0.0; // 2.5 -> 2.5.0; 1 -> 1.0.0;
export const semverify = (version: string | number): string => { export const semverify = (version: string | number): string => {
if (typeof version === "string" && semver.valid(version)) { const isString = typeof version === "string";
if (isString && semver.valid(version)) {
return version; return version;
} }
const split = version.toString().split("."); invariant(
typeof version === "number" || (isString && versionRegExp.test(version)),
`'${version}' is not a valid version`,
);
const split = version.toString().split(".");
while (split.length < 3) { while (split.length < 3) {
split.push("0"); split.push("0");
} }
return split.join("."); return split.join(".");
}; };
export const getValues = (object: Object): Array<any> =>
Object.keys(object).map(key => object[key]);
export const findSuggestion = (options: Array<string>, option: string) => {
let levenshteinValue = Infinity;
return options.reduce((suggestion, validOption) => {
const value = levenshtein(validOption, option);
if (value < levenshteinValue) {
levenshteinValue = value;
return validOption;
}
return suggestion;
}, undefined);
};
export const prettifyVersion = (version: string): string => { export const prettifyVersion = (version: string): string => {
if (typeof version !== "string") { if (typeof version !== "string") {
return version; return version;

View File

@ -36,6 +36,15 @@ describe("normalize-options", () => {
}); });
}); });
describe("Config format validation", () => {
it("should throw if top-level option not found", () => {
const unknownTopLevelOption = () => {
normalizeOptions({ unknown: "option" });
};
expect(unknownTopLevelOption).toThrow();
});
});
describe("RegExp include/exclude", () => { describe("RegExp include/exclude", () => {
it("should not allow invalid plugins in `include` and `exclude`", () => { it("should not allow invalid plugins in `include` and `exclude`", () => {
const normalizeWithNonExistingPlugin = () => { const normalizeWithNonExistingPlugin = () => {

View File

@ -19,6 +19,35 @@ describe("getTargets", () => {
}); });
}); });
describe("validation", () => {
it("throws on invalid target name", () => {
const invalidTargetName = () => {
getTargets({
unknown: "unknown",
});
};
expect(invalidTargetName).toThrow();
});
it("throws on invalid browsers target", () => {
const invalidBrowsersTarget = () => {
getTargets({
browsers: 59,
});
};
expect(invalidBrowsersTarget).toThrow();
});
it("throws on invalid target version", () => {
const invalidTargetVersion = () => {
getTargets({
chrome: "unknown",
});
};
expect(invalidTargetVersion).toThrow();
});
});
describe("browser", () => { describe("browser", () => {
it("merges browser key targets", () => { it("merges browser key targets", () => {
expect( expect(
@ -55,21 +84,6 @@ describe("getTargets", () => {
safari: "tp", safari: "tp",
}); });
}); });
it("ignores invalid", () => {
expect(
getTargets({
browsers: 59,
chrome: "49",
firefox: "55",
ie: "11",
}),
).toEqual({
chrome: "49.0.0",
firefox: "55.0.0",
ie: "11.0.0",
});
});
}); });
describe("esmodules", () => { describe("esmodules", () => {

View File

@ -2,7 +2,7 @@
const utils = require("../lib/utils"); const utils = require("../lib/utils");
const { prettifyTargets, prettifyVersion, semverify } = utils; const { prettifyTargets, prettifyVersion, semverify, findSuggestion } = utils;
describe("utils", () => { describe("utils", () => {
describe("semverify", () => { describe("semverify", () => {
@ -13,6 +13,13 @@ describe("utils", () => {
expect(semverify(1)).toBe("1.0.0"); expect(semverify(1)).toBe("1.0.0");
expect(semverify(1.2)).toBe("1.2.0"); expect(semverify(1.2)).toBe("1.2.0");
}); });
it("throws", () => {
const invalidSemver = () => {
semverify("invalid");
};
expect(invalidSemver).toThrow();
});
}); });
describe("prettifyVersion", () => { describe("prettifyVersion", () => {
@ -43,4 +50,12 @@ describe("utils", () => {
}); });
}); });
}); });
describe("findSuggestion", () => {
it("returns", () => {
const options = ["one", "two", "three"];
expect(findSuggestion(options, "onr")).toEqual("one");
expect(findSuggestion(options, "tree")).toEqual("three");
});
});
}); });