263 lines
7.4 KiB
JavaScript

// @flow
import browserslist from "browserslist";
import { findSuggestion } from "@babel/helper-validator-option";
import browserModulesData from "@babel/compat-data/native-modules";
import {
semverify,
semverMin,
isUnreleasedVersion,
getLowestUnreleased,
getHighestUnreleased,
} from "./utils";
import { OptionValidator } from "@babel/helper-validator-option";
import { browserNameMap } from "./targets";
import { TargetNames } from "./options";
import type { Targets, InputTargets, Browsers, TargetsTuple } from "./types";
export type { Targets, InputTargets };
export { prettifyTargets } from "./pretty";
export { getInclusionReasons } from "./debug";
export { default as filterItems, isRequired } from "./filter-items";
export { unreleasedLabels } from "./targets";
export { TargetNames };
const ESM_SUPPORT = browserModulesData["es6.module"];
const v = new OptionValidator(PACKAGE_JSON.name);
function validateTargetNames(targets: Targets): TargetsTuple {
const validTargets = Object.keys(TargetNames);
for (const target of Object.keys(targets)) {
if (!(target in TargetNames)) {
throw new Error(
v.formatMessage(`'${target}' is not a valid target
- Did you mean '${findSuggestion(target, validTargets)}'?`),
);
}
}
return (targets: any);
}
export function isBrowsersQueryValid(browsers: mixed): boolean %checks {
return (
typeof browsers === "string" ||
(Array.isArray(browsers) && browsers.every(b => typeof b === "string"))
);
}
function validateBrowsers(browsers: Browsers | void) {
v.invariant(
browsers === undefined || isBrowsersQueryValid(browsers),
`'${String(browsers)}' is not a valid browserslist query`,
);
return browsers;
}
function getLowestVersions(browsers: Array<string>): Targets {
return browsers.reduce((all: Object, browser: string): Object => {
const [browserName, browserVersion] = browser.split(" ");
const normalizedBrowserName = browserNameMap[browserName];
if (!normalizedBrowserName) {
return all;
}
try {
// Browser version can return as "10.0-10.2"
const splitVersion = browserVersion.split("-")[0].toLowerCase();
const isSplitUnreleased = isUnreleasedVersion(splitVersion, browserName);
if (!all[normalizedBrowserName]) {
all[normalizedBrowserName] = isSplitUnreleased
? splitVersion
: semverify(splitVersion);
return all;
}
const version = all[normalizedBrowserName];
const isUnreleased = isUnreleasedVersion(version, browserName);
if (isUnreleased && isSplitUnreleased) {
all[normalizedBrowserName] = getLowestUnreleased(
version,
splitVersion,
browserName,
);
} else if (isUnreleased) {
all[normalizedBrowserName] = semverify(splitVersion);
} else if (!isUnreleased && !isSplitUnreleased) {
const parsedBrowserVersion = semverify(splitVersion);
all[normalizedBrowserName] = semverMin(version, parsedBrowserVersion);
}
} catch (e) {}
return all;
}, {});
}
function outputDecimalWarning(
decimalTargets: Array<{| target: string, value: string |}>,
): void {
if (!decimalTargets.length) {
return;
}
console.warn("Warning, the following targets are using a decimal version:\n");
decimalTargets.forEach(({ target, value }) =>
console.warn(` ${target}: ${value}`),
);
console.warn(`
We recommend using a string for minor/patch versions to avoid numbers like 6.10
getting parsed as 6.1, which can lead to unexpected behavior.
`);
}
function semverifyTarget(target, value) {
try {
return semverify(value);
} catch (error) {
throw new Error(
v.formatMessage(
`'${value}' is not a valid value for 'targets.${target}'.`,
),
);
}
}
const targetParserMap = {
__default(target, value) {
const version = isUnreleasedVersion(value, target)
? value.toLowerCase()
: semverifyTarget(target, value);
return [target, version];
},
// Parse `node: true` and `node: "current"` to version
node(target, value) {
const parsed =
value === true || value === "current"
? process.versions.node
: semverifyTarget(target, value);
return [target, parsed];
},
};
function generateTargets(inputTargets: InputTargets): Targets {
const input = { ...inputTargets };
delete input.esmodules;
delete input.browsers;
return ((input: any): Targets);
}
function resolveTargets(queries: Browsers): Targets {
const resolved = browserslist(queries, { mobileToDesktop: true });
return getLowestVersions(resolved);
}
type GetTargetsOption = {
// This is not the path of the config file, but the path where start searching it from
configPath?: string,
// The path of the config file
configFile?: string,
// The env to pass to browserslist
browserslistEnv?: string,
// true to disable config loading
ignoreBrowserslistConfig?: boolean,
};
export default function getTargets(
inputTargets: InputTargets = {},
options: GetTargetsOption = {},
): Targets {
let { browsers, esmodules } = inputTargets;
validateBrowsers(browsers);
const input = generateTargets(inputTargets);
let targets: TargetsTuple = validateTargetNames(input);
const shouldParseBrowsers = !!browsers;
const hasTargets = shouldParseBrowsers || Object.keys(targets).length > 0;
const shouldSearchForConfig =
!options.ignoreBrowserslistConfig && !hasTargets;
if (!browsers && shouldSearchForConfig) {
browsers =
browserslist.loadConfig({
config: options.configFile,
path: options.configPath,
env: options.browserslistEnv,
}) ??
// If no targets are passed, we need to overwrite browserslist's defaults
// so that we enable all transforms (acting like the now deprecated
// preset-latest).
[];
}
// `esmodules` as a target indicates the specific set of browsers supporting ES Modules.
// These values OVERRIDE the `browsers` field.
if (esmodules && (esmodules !== "intersect" || !browsers)) {
browsers = Object.keys(ESM_SUPPORT)
.map(browser => `${browser} >= ${ESM_SUPPORT[browser]}`)
.join(", ");
esmodules = false;
}
if (browsers) {
const queryBrowsers = resolveTargets(browsers);
if (esmodules === "intersect") {
for (const browser of Object.keys(queryBrowsers)) {
const version = queryBrowsers[browser];
if (ESM_SUPPORT[browser]) {
queryBrowsers[browser] = getHighestUnreleased(
version,
semverify(ESM_SUPPORT[browser]),
browser,
);
} else {
delete queryBrowsers[browser];
}
}
}
targets = Object.assign(queryBrowsers, targets);
}
// Parse remaining targets
const result: Targets = {};
const decimalWarnings = [];
for (const target of Object.keys(targets).sort()) {
const value = targets[target];
// Warn when specifying minor/patch as a decimal
if (typeof value === "number" && value % 1 !== 0) {
decimalWarnings.push({ target, value });
}
// Check if we have a target parser?
// $FlowIgnore - Flow doesn't like that some targetParserMap[target] might be missing
const parser = targetParserMap[target] ?? targetParserMap.__default;
const [parsedTarget, parsedValue] = parser(target, value);
if (parsedValue) {
// Merge (lowest wins)
result[parsedTarget] = parsedValue;
}
}
outputDecimalWarning(decimalWarnings);
return result;
}