diff --git a/lib/third-party-libs.js.flow b/lib/third-party-libs.js.flow index 1b6ef25940..26d86cd225 100644 --- a/lib/third-party-libs.js.flow +++ b/lib/third-party-libs.js.flow @@ -166,3 +166,9 @@ declare module "convert-source-map" { generateMapFileComment(path: string, options?: ?{ multiline: boolean }): string, }; } + +declare module "js-levenshtein" { + declare module.exports: { + (string, string): number, + }; +} diff --git a/packages/babel-preset-env/package.json b/packages/babel-preset-env/package.json index 442f4fe2da..17505d8ea4 100644 --- a/packages/babel-preset-env/package.json +++ b/packages/babel-preset-env/package.json @@ -49,6 +49,7 @@ "@babel/plugin-transform-unicode-regex": "7.0.0-beta.49", "browserslist": "^3.0.0", "invariant": "^2.2.2", + "js-levenshtein": "^1.1.3", "semver": "^5.3.0" }, "peerDependencies": { diff --git a/packages/babel-preset-env/src/normalize-options.js b/packages/babel-preset-env/src/normalize-options.js index b8f0e697fa..838625ecc2 100644 --- a/packages/babel-preset-env/src/normalize-options.js +++ b/packages/babel-preset-env/src/normalize-options.js @@ -5,9 +5,23 @@ import browserslist from "browserslist"; import builtInsList from "../data/built-ins.json"; import { defaultWebIncludes } from "./default-includes"; import moduleTransformations from "./module-transformations"; +import { getValues, findSuggestion } from "./utils"; import pluginsList from "../data/plugins.json"; +import { TopLevelOptions, ModulesOption, UseBuiltInsOption } from "./options"; 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([ ...Object.keys(pluginsList), ...Object.keys(moduleTransformations).map(m => moduleTransformations[m]), @@ -108,17 +122,17 @@ export const validateIgnoreBrowserslistConfig = ( ignoreBrowserslistConfig: boolean, ) => validateBoolOption( - "ignoreBrowserslistConfig", + TopLevelOptions.ignoreBrowserslistConfig, ignoreBrowserslistConfig, false, ); export const validateModulesOption = ( - modulesOpt: ModuleOption = "commonjs", + modulesOpt: ModuleOption = ModulesOption.commonjs, ) => { invariant( - modulesOpt === false || - Object.keys(moduleTransformations).indexOf(modulesOpt) > -1, + ModulesOption[modulesOpt] || + ModulesOption[modulesOpt] === ModulesOption.false, `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'.`, ); @@ -140,7 +154,8 @@ export const validateUseBuiltInsOption = ( builtInsOpt: BuiltInsOption = false, ): BuiltInsOption => { invariant( - builtInsOpt === "usage" || builtInsOpt === false || builtInsOpt === "entry", + UseBuiltInsOption[builtInsOpt] || + UseBuiltInsOption[builtInsOpt] === UseBuiltInsOption.false, `Invalid Option: The 'useBuiltIns' option must be either 'false' (default) to indicate no polyfill, '"entry"' to indicate replacing the entry polyfill, or @@ -151,32 +166,40 @@ export const validateUseBuiltInsOption = ( }; export default function normalizeOptions(opts: Options) { - const include = expandIncludesAndExcludes(opts.include, "include"); - const exclude = expandIncludesAndExcludes(opts.exclude, "exclude"); + validateTopLevelOptions(opts); + + const include = expandIncludesAndExcludes( + opts.include, + TopLevelOptions.include, + ); + const exclude = expandIncludesAndExcludes( + opts.exclude, + TopLevelOptions.exclude, + ); checkDuplicateIncludeExcludes(include, exclude); return { configPath: validateConfigPathOption(opts.configPath), - debug: opts.debug, + debug: validateBoolOption(TopLevelOptions.debug, opts.debug, false), include, exclude, forceAllTransforms: validateBoolOption( - "forceAllTransforms", + TopLevelOptions.forceAllTransforms, opts.forceAllTransforms, false, ), ignoreBrowserslistConfig: validateIgnoreBrowserslistConfig( opts.ignoreBrowserslistConfig, ), - loose: validateBoolOption("loose", opts.loose, false), + loose: validateBoolOption(TopLevelOptions.loose, opts.loose, false), modules: validateModulesOption(opts.modules), shippedProposals: validateBoolOption( - "shippedProposals", + TopLevelOptions.shippedProposals, opts.shippedProposals, false, ), - spec: validateBoolOption("loose", opts.spec, false), + spec: validateBoolOption(TopLevelOptions.spec, opts.spec, false), targets: { ...opts.targets, }, diff --git a/packages/babel-preset-env/src/options.js b/packages/babel-preset-env/src/options.js new file mode 100644 index 0000000000..b201bac971 --- /dev/null +++ b/packages/babel-preset-env/src/options.js @@ -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", +}; diff --git a/packages/babel-preset-env/src/targets-parser.js b/packages/babel-preset-env/src/targets-parser.js index 0f3aef0364..0a97f9f07b 100644 --- a/packages/babel-preset-env/src/targets-parser.js +++ b/packages/babel-preset-env/src/targets-parser.js @@ -1,12 +1,32 @@ // @flow import browserslist from "browserslist"; +import invariant from "invariant"; import semver from "semver"; -import { semverify, isUnreleasedVersion, getLowestUnreleased } from "./utils"; +import { + semverify, + isUnreleasedVersion, + getLowestUnreleased, + getValues, + findSuggestion, +} from "./utils"; import { objectToBrowserslist } from "./normalize-options"; import browserModulesData from "../data/built-in-modules.json"; +import { TargetNames } from "./options"; 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 = { android: "android", chrome: "chrome", @@ -21,13 +41,21 @@ const browserNameMap = { const isBrowsersQueryValid = (browsers: string | Array): boolean => 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 => { return first && semver.lt(first, second) ? first : second; }; const mergeBrowsers = (fromQuery: Targets, fromTarget: Targets) => { return Object.keys(fromTarget).reduce((queryObj, targKey) => { - if (targKey !== "browsers") { + if (targKey !== TargetNames.browsers) { queryObj[targKey] = fromTarget[targKey]; } return queryObj; @@ -85,11 +113,21 @@ const outputDecimalWarning = (decimalTargets: Array): void => { 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 = { __default: (target, value) => { const version = isUnreleasedVersion(value, target) ? value.toLowerCase() - : semverify(value); + : semverifyTarget(target, value); return [target, version]; }, @@ -98,8 +136,7 @@ const targetParserMap = { const parsed = value === true || value === "current" ? process.versions.node - : semverify(value); - + : semverifyTarget(target, value); return [target, parsed]; }, }; @@ -108,9 +145,12 @@ type ParsedResult = { targets: Targets, decimalWarnings: Array, }; + const getTargets = (targets: Object = {}, options: Object = {}): Targets => { const targetOpts: Targets = {}; + validateTargetNames(targets); + // `esmodules` as a target indicates the specific set of browsers supporting ES Modules. // These values OVERRIDE the `browsers` field. if (targets.esmodules) { @@ -119,23 +159,24 @@ const getTargets = (targets: Object = {}, options: Object = {}): Targets => { .map(browser => `${browser} ${supportsESModules[browser]}`) .join(", "); } - // Parse browsers target via browserslist; - const queryIsValid = isBrowsersQueryValid(targets.browsers); - const browsersquery = queryIsValid ? targets.browsers : null; - if (queryIsValid || !options.ignoreBrowserslistConfig) { + + // Parse browsers target via browserslist + const browsersquery = validateBrowsers(targets.browsers); + if (!options.ignoreBrowserslistConfig) { browserslist.defaults = objectToBrowserslist(targets); const browsers = browserslist(browsersquery, { path: options.configPath }); const queryBrowsers = getLowestVersions(browsers); targets = mergeBrowsers(queryBrowsers, targets); } + // Parse remaining targets const parsed = Object.keys(targets) - .filter(value => value !== "esmodules") + .filter(value => value !== TargetNames.esmodules) .sort() .reduce( (results: ParsedResult, target: string): ParsedResult => { - if (target !== "browsers") { + if (target !== TargetNames.browsers) { const value = targets[target]; // Warn when specifying minor/patch as a decimal diff --git a/packages/babel-preset-env/src/types.js b/packages/babel-preset-env/src/types.js index fd33e277fa..fe7428d204 100644 --- a/packages/babel-preset-env/src/types.js +++ b/packages/babel-preset-env/src/types.js @@ -1,15 +1,17 @@ //@flow +import { TargetNames, ModulesOption, UseBuiltInsOption } from "./options"; + // Targets -export type Target = string; +export type Target = $Keys; export type Targets = { - [target: string]: Target, + [target: Target]: string, }; // Options // Use explicit modules to prevent typo errors. -export type ModuleOption = false | "amd" | "commonjs" | "systemjs" | "umd"; -export type BuiltInsOption = false | "entry" | "usage"; +export type ModuleOption = $Values; +export type BuiltInsOption = $Values; export type Options = { configPath: string, diff --git a/packages/babel-preset-env/src/utils.js b/packages/babel-preset-env/src/utils.js index 7f08ad109c..ae5380d17f 100644 --- a/packages/babel-preset-env/src/utils.js +++ b/packages/babel-preset-env/src/utils.js @@ -1,26 +1,51 @@ // @flow + +import invariant from "invariant"; import semver from "semver"; +import levenshtein from "js-levenshtein"; import { addSideEffect } from "@babel/helper-module-imports"; import unreleasedLabels from "../data/unreleased-labels"; import { semverMin } from "./targets-parser"; import type { Targets } from "./types"; +const versionRegExp = /^(\d+|\d+.\d+)$/; + // Convert version to a semver value. // 2.5 -> 2.5.0; 1 -> 1.0.0; 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; } - 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) { split.push("0"); } - return split.join("."); }; +export const getValues = (object: Object): Array => + Object.keys(object).map(key => object[key]); + +export const findSuggestion = (options: Array, 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 => { if (typeof version !== "string") { return version; diff --git a/packages/babel-preset-env/test/normalize-options.spec.js b/packages/babel-preset-env/test/normalize-options.spec.js index 7a3a6db36d..abe163e0cf 100644 --- a/packages/babel-preset-env/test/normalize-options.spec.js +++ b/packages/babel-preset-env/test/normalize-options.spec.js @@ -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", () => { it("should not allow invalid plugins in `include` and `exclude`", () => { const normalizeWithNonExistingPlugin = () => { diff --git a/packages/babel-preset-env/test/targets-parser.spec.js b/packages/babel-preset-env/test/targets-parser.spec.js index 21bbb286bc..2a0d4bb074 100644 --- a/packages/babel-preset-env/test/targets-parser.spec.js +++ b/packages/babel-preset-env/test/targets-parser.spec.js @@ -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", () => { it("merges browser key targets", () => { expect( @@ -55,21 +84,6 @@ describe("getTargets", () => { 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", () => { diff --git a/packages/babel-preset-env/test/utils.spec.js b/packages/babel-preset-env/test/utils.spec.js index f9c329fef3..2dce5f76c5 100644 --- a/packages/babel-preset-env/test/utils.spec.js +++ b/packages/babel-preset-env/test/utils.spec.js @@ -2,7 +2,7 @@ const utils = require("../lib/utils"); -const { prettifyTargets, prettifyVersion, semverify } = utils; +const { prettifyTargets, prettifyVersion, semverify, findSuggestion } = utils; describe("utils", () => { describe("semverify", () => { @@ -13,6 +13,13 @@ describe("utils", () => { expect(semverify(1)).toBe("1.0.0"); expect(semverify(1.2)).toBe("1.2.0"); }); + + it("throws", () => { + const invalidSemver = () => { + semverify("invalid"); + }; + expect(invalidSemver).toThrow(); + }); }); 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"); + }); + }); });