Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support .cts as configuration file #15283

Merged
merged 13 commits into from Feb 18, 2023
47 changes: 40 additions & 7 deletions babel.config.js
Expand Up @@ -464,17 +464,30 @@ function pluginPolyfillsOldNode({ template, types: t }) {
},
};
}

/**
* @param {import("@babel/core")} pluginAPI
* @returns {import("@babel/core").PluginObj}
*/
function pluginToggleBooleanFlag({ types: t }, { name, value }) {
function check(test) {
let keepConsequent = value;

if (test.isUnaryExpression({ operator: "!" })) {
test = test.get("argument");
keepConsequent = !keepConsequent;
}
return {
test,
keepConsequent,
};
}

return {
visitor: {
"IfStatement|ConditionalExpression"(path) {
let test = path.get("test");
let keepConsequent = value;

if (test.isUnaryExpression({ operator: "!" })) {
test = test.get("argument");
keepConsequent = !keepConsequent;
}
// eslint-disable-next-line prefer-const
let { test, keepConsequent } = check(path.get("test"));

// yarn-plugin-conditions injects bool(process.env.BABEL_8_BREAKING)
// tests, to properly cast the env variable to a boolean.
Expand All @@ -494,6 +507,26 @@ function pluginToggleBooleanFlag({ types: t }, { name, value }) {
: path.node.alternate || t.emptyStatement()
);
},
LogicalExpression(path) {
const { test, keepConsequent } = check(path.get("left"));

if (!test.matchesPattern(name)) return;

switch (path.node.operator) {
case "&&":
path.replaceWith(
keepConsequent ? path.node.right : t.booleanLiteral(false)
);
break;
case "||":
path.replaceWith(
keepConsequent ? t.booleanLiteral(true) : path.node.right
);
break;
default:
throw path.buildCodeFrameError("This check could not be stripped.");
}
},
MemberExpression(path) {
if (path.matchesPattern(name)) {
throw path.buildCodeFrameError("This check could not be stripped.");
Expand Down
3 changes: 2 additions & 1 deletion packages/babel-core/package.json
Expand Up @@ -74,7 +74,8 @@
"@types/gensync": "^1.0.0",
"@types/resolve": "^1.3.2",
"@types/semver": "^5.4.0",
"rimraf": "^3.0.0"
"rimraf": "^3.0.0",
"ts-node": "^10.9.1"
},
"conditions": {
"BABEL_8_BREAKING": [
Expand Down
20 changes: 14 additions & 6 deletions packages/babel-core/src/config/files/configuration.ts
Expand Up @@ -9,7 +9,7 @@ import type { CacheConfigurator } from "../caching";
import { makeConfigAPI } from "../helpers/config-api";
import type { ConfigAPI } from "../helpers/config-api";
import { makeStaticFileCache } from "./utils";
import loadCjsOrMjsDefault from "./module-types";
import loadCodeDefault from "./module-types";
import pathPatternToRegex from "../pattern-to-regex";
import type { FilePackageData, RelativeConfig, ConfigFile } from "./types";
import type { CallerMetadata } from "../validation/options";
Expand All @@ -28,20 +28,22 @@ export const ROOT_CONFIG_FILENAMES = [
"babel.config.cjs",
"babel.config.mjs",
"babel.config.json",
"babel.config.cts",
];
const RELATIVE_CONFIG_FILENAMES = [
".babelrc",
".babelrc.js",
".babelrc.cjs",
".babelrc.mjs",
".babelrc.json",
".babelrc.cts",
];

const BABELIGNORE_FILENAME = ".babelignore";

const LOADING_CONFIGS = new Set();

const readConfigJS = makeStrongCache(function* readConfigJS(
const readConfigCode = makeStrongCache(function* readConfigCode(
filepath: string,
cache: CacheConfigurator<{
envName: string;
Expand Down Expand Up @@ -70,7 +72,7 @@ const readConfigJS = makeStrongCache(function* readConfigJS(
let options: unknown;
try {
LOADING_CONFIGS.add(filepath);
options = yield* loadCjsOrMjsDefault(
options = yield* loadCodeDefault(
filepath,
"You appear to be using a native ECMAScript module configuration " +
"file, which is only supported when running Babel asynchronously.",
Expand Down Expand Up @@ -313,9 +315,15 @@ function readConfig(
caller: CallerMetadata | undefined,
): Handler<ConfigFile | null> {
const ext = path.extname(filepath);
return ext === ".js" || ext === ".cjs" || ext === ".mjs"
? readConfigJS(filepath, { envName, caller })
: readConfigJSON5(filepath);
switch (ext) {
case ".js":
case ".cjs":
case ".mjs":
case ".cts":
return readConfigCode(filepath, { envName, caller });
default:
return readConfigJSON5(filepath);
}
}

export function* resolveShowConfigPath(
Expand Down
120 changes: 100 additions & 20 deletions packages/babel-core/src/config/files/module-types.ts
Expand Up @@ -8,6 +8,9 @@ import semver from "semver";
import { endHiddenCallStack } from "../../errors/rewrite-stack-trace";
import ConfigError from "../../errors/config-error";

import type { InputOptions } from "..";
import { transformFileSync } from "../../transform-file";

const require = createRequire(import.meta.url);

let import_: ((specifier: string | URL) => any) | undefined;
Expand All @@ -23,38 +26,87 @@ export const supportsESM = semver.satisfies(
"^12.17 || >=13.2",
);

export default function* loadCjsOrMjsDefault(
export default function* loadCodeDefault(
filepath: string,
asyncError: string,
// TODO(Babel 8): Remove this
fallbackToTranspiledModule: boolean = false,
): Handler<unknown> {
switch (guessJSModuleType(filepath)) {
case "cjs":
switch (path.extname(filepath)) {
case ".cjs":
return loadCjsDefault(filepath, fallbackToTranspiledModule);
case "unknown":
case ".mjs":
break;
case ".cts":
return loadCtsDefault(filepath);
default:
try {
return loadCjsDefault(filepath, fallbackToTranspiledModule);
} catch (e) {
if (e.code !== "ERR_REQUIRE_ESM") throw e;
}
// fall through
case "mjs":
if (yield* isAsync()) {
return yield* waitFor(loadMjsDefault(filepath));
}
throw new ConfigError(asyncError, filepath);
}
if (yield* isAsync()) {
return yield* waitFor(loadMjsDefault(filepath));
}
throw new ConfigError(asyncError, filepath);
}

function guessJSModuleType(filename: string): "cjs" | "mjs" | "unknown" {
switch (path.extname(filename)) {
case ".cjs":
return "cjs";
case ".mjs":
return "mjs";
default:
return "unknown";
function loadCtsDefault(filepath: string) {
const ext = ".cts";
const hasTsSupport = !!(
require.extensions[".ts"] ||
require.extensions[".cts"] ||
require.extensions[".mts"]
Comment on lines +58 to +60
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the plan to eventually support .ts and .mts? Can we throw an error when we find babel.config.ts and babel.config.mts, instead of silently ignoring them?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I want to support this, but it may be difficult, and the current nodejs support for this is not very complete.

);

let handler: NodeJS.RequireExtensions[""];

if (!hasTsSupport) {
const opts: InputOptions = {
babelrc: false,
configFile: false,
sourceType: "script",
sourceMaps: "inline",
presets: [
[
getTSPreset(filepath),
{
disallowAmbiguousJSXLike: true,
allExtensions: true,
onlyRemoveTypeImports: true,
optimizeConstEnums: true,
...(!process.env.BABEL_8_BREAKING && {
allowDeclareFields: true,
}),
},
],
],
};

handler = function (m, filename) {
// If we want to support `.ts`, `.d.ts` must be handled specially.
if (handler && filename.endsWith(ext)) {
// @ts-expect-error Undocumented API
return m._compile(
transformFileSync(filename, {
...opts,
filename,
}).code,
filename,
);
}
return require.extensions[".js"](m, filename);
};
require.extensions[ext] = handler;
}
try {
return endHiddenCallStack(require)(filepath);
} finally {
if (!hasTsSupport) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user config could register a new handler for TS extensions. We should also check if require.extensions[ext] is still === to the function we defined a few lines above, before deleting it.

if (require.extensions[ext] === handler) delete require.extensions[ext];
handler = undefined;
}
}
}

Expand All @@ -69,8 +121,7 @@ function loadCjsDefault(filepath: string, fallbackToTranspiledModule: boolean) {
async function loadMjsDefault(filepath: string) {
if (!import_) {
throw new ConfigError(
"Internal error: Native ECMAScript modules aren't supported" +
" by this platform.\n",
"Internal error: Native ECMAScript modules aren't supported by this platform.\n",
filepath,
);
}
Expand All @@ -80,3 +131,32 @@ async function loadMjsDefault(filepath: string) {
const module = await endHiddenCallStack(import_)(pathToFileURL(filepath));
return module.default;
}

function getTSPreset(filepath: string) {
try {
// eslint-disable-next-line import/no-extraneous-dependencies
return require("@babel/preset-typescript");
} catch (error) {
if (error.code !== "MODULE_NOT_FOUND") throw error;

let message =
"You appear to be using a .cts file as Babel configuration, but the `@babel/preset-typescript` package was not found: please install it!";

if (process.versions.pnp) {
// Using Yarn PnP, which doesn't allow requiring packages that are not
// explicitly specified as dependencies.
// TODO(Babel 8): Explicitly add `@babel/preset-typescript` as an
// optional peer dependency of `@babel/core`.
message += `
If you are using Yarn Plug'n'Play, you may also need to add the following configuration to your .yarnrc.yml file:

packageExtensions:
\t"@babel/core@*":
\t\tpeerDependencies:
\t\t\t"@babel/preset-typescript": "*"
`;
}

throw new ConfigError(message, filepath);
}
}
4 changes: 2 additions & 2 deletions packages/babel-core/src/config/files/plugins.ts
Expand Up @@ -6,7 +6,7 @@ import buildDebug from "debug";
import path from "path";
import gensync, { type Handler } from "gensync";
import { isAsync } from "../../gensync-utils/async";
import loadCjsOrMjsDefault, { supportsESM } from "./module-types";
import loadCodeDefault, { supportsESM } from "./module-types";
import { fileURLToPath, pathToFileURL } from "url";

import importMetaResolve from "./import-meta-resolve";
Expand Down Expand Up @@ -217,7 +217,7 @@ function* requireModule(type: string, name: string): Handler<unknown> {
if (!process.env.BABEL_8_BREAKING) {
LOADING_MODULES.add(name);
}
return yield* loadCjsOrMjsDefault(
return yield* loadCodeDefault(
name,
`You appear to be using a native ECMAScript module ${type}, ` +
"which is only supported when running Babel asynchronously.",
Expand Down
4 changes: 2 additions & 2 deletions packages/babel-core/src/errors/config-error.ts
@@ -1,9 +1,9 @@
import { injcectVirtualStackFrame, expectedError } from "./rewrite-stack-trace";
import { injectVirtualStackFrame, expectedError } from "./rewrite-stack-trace";

export default class ConfigError extends Error {
constructor(message: string, filename?: string) {
super(message);
expectedError(this);
if (filename) injcectVirtualStackFrame(this, filename);
if (filename) injectVirtualStackFrame(this, filename);
}
}
22 changes: 11 additions & 11 deletions packages/babel-core/src/errors/rewrite-stack-trace.ts
@@ -1,5 +1,5 @@
/**
* This file uses the iternal V8 Stack Trace API (https://v8.dev/docs/stack-trace-api)
* This file uses the internal V8 Stack Trace API (https://v8.dev/docs/stack-trace-api)
* to provide utilities to rewrite the stack trace.
* When this API is not present, all the functions in this file become noops.
*
Expand Down Expand Up @@ -33,10 +33,10 @@
* - If e() throws an error, then its shown call stack will be "e, f"
*
* Additionally, an error can inject additional "virtual" stack frames using the
* injcectVirtualStackFrame(error, filename) function: those are injected as a
* injectVirtualStackFrame(error, filename) function: those are injected as a
* replacement of the hidden frames.
* In the example above, if we called injcectVirtualStackFrame(err, "h") and
* injcectVirtualStackFrame(err, "i") on the expected error thrown by c(), its
* In the example above, if we called injectVirtualStackFrame(err, "h") and
* injectVirtualStackFrame(err, "i") on the expected error thrown by c(), its
* shown call stack would have been "h, i, e, f".
* This can be useful, for example, to report config validation errors as if they
* were directly thrown in the config file.
Expand All @@ -46,8 +46,8 @@ const ErrorToString = Function.call.bind(Error.prototype.toString);

const SUPPORTED = !!Error.captureStackTrace;

const START_HIDNG = "startHiding - secret - don't use this - v1";
const STOP_HIDNG = "stopHiding - secret - don't use this - v1";
const START_HIDING = "startHiding - secret - don't use this - v1";
const STOP_HIDING = "stopHiding - secret - don't use this - v1";

type CallSite = Parameters<typeof Error.prepareStackTrace>[1][number];

Expand All @@ -70,7 +70,7 @@ function CallSite(filename: string): CallSite {
} as CallSite);
}

export function injcectVirtualStackFrame(error: Error, filename: string) {
export function injectVirtualStackFrame(error: Error, filename: string) {
if (!SUPPORTED) return;

let frames = virtualFrames.get(error);
Expand All @@ -97,7 +97,7 @@ export function beginHiddenCallStack<A extends unknown[], R>(
return fn(...args);
},
"name",
{ value: STOP_HIDNG },
{ value: STOP_HIDING },
);
}

Expand All @@ -111,7 +111,7 @@ export function endHiddenCallStack<A extends unknown[], R>(
return fn(...args);
},
"name",
{ value: START_HIDNG },
{ value: START_HIDING },
);
}

Expand Down Expand Up @@ -144,9 +144,9 @@ function setupPrepareStackTrace() {
: "unknown";
for (let i = 0; i < trace.length; i++) {
const name = trace[i].getFunctionName();
if (name === START_HIDNG) {
if (name === START_HIDING) {
status = "hiding";
} else if (name === STOP_HIDNG) {
} else if (name === STOP_HIDING) {
if (status === "hiding") {
status = "showing";
if (virtualFrames.has(err)) {
Expand Down