Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/oxfmt/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"build-napi-test": "pnpm run build-napi --profile coverage",
"build-napi-release": "pnpm run build-napi --release --features allocator",
"build-js": "node scripts/build.js",
"test": "vitest --dir test run",
"test": "vitest run",
"conformance": "node conformance/run.ts",
"download-fixtures": "node conformance/download-fixtures.js",
"generate-config-types": "node scripts/generate-config-types.ts"
Expand Down
2 changes: 1 addition & 1 deletion apps/oxfmt/src-js/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
formatEmbeddedDoc,
sortTailwindClasses,
} from "./cli/worker-proxy";
import { loadJsConfig } from "./cli/js_config";
import { loadJsConfig } from "./cli/js_config/index";

// napi-JS `oxfmt` CLI entry point
// See also `run_cli()` function in `./src/main_napi.rs`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { basename as pathBasename } from "node:path";
import { pathToFileURL } from "node:url";
import { getUnsupportedTypeScriptModuleLoadHintForError } from "./node_version";
import { getUnsupportedTypeScriptModuleLoadHint } from "./node_version";

const isObject = (v: unknown) => typeof v === "object" && v !== null && !Array.isArray(v);

Expand All @@ -24,13 +24,11 @@ export async function loadJsConfig(path: string): Promise<object | null> {
const fileUrl = pathToFileURL(path);
fileUrl.searchParams.set("cache", Date.now().toString());

let config: unknown;
try {
({ default: config } = await import(fileUrl.href));
} catch (err) {
const unsupportedNodeHint = getUnsupportedTypeScriptModuleLoadHintForError(err, path);
throw unsupportedNodeHint ?? err;
}
const { default: config } = await import(fileUrl.href).catch((err) => {
const hint = getUnsupportedTypeScriptModuleLoadHint(err, path);
if (hint && err instanceof Error) err.message += `\n\n${hint}`;
throw err;
});

if (config === undefined) throw new Error("Configuration file has no default export.");

Expand Down
76 changes: 76 additions & 0 deletions apps/oxfmt/src-js/cli/js_config/node_version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { extname as pathExtname } from "node:path";
import { fileURLToPath } from "node:url";

const NODE_TYPESCRIPT_SUPPORT_RANGE = "^20.19.0 || >=22.12.0";
const TS_MODULE_EXTENSIONS = new Set([".ts", ".mts", ".cts"]);

export function getUnsupportedTypeScriptModuleLoadHint(
err: unknown,
specifier: string,
nodeVersion: string = process.version,
): string | null {
if (!isTypeScriptModuleSpecifier(specifier) || !isUnknownFileExtensionError(err)) return null;

return `TypeScript config files require Node.js ${NODE_TYPESCRIPT_SUPPORT_RANGE}.\nDetected Node.js ${nodeVersion}.\nPlease upgrade Node.js or use a JSON config file instead.`;
}

// ---

function isTypeScriptModuleSpecifier(specifier: string): boolean {
const ext = pathExtname(normalizeModuleSpecifierPath(specifier)).toLowerCase();
return TS_MODULE_EXTENSIONS.has(ext);
}

function normalizeModuleSpecifierPath(specifier: string): string {
if (!specifier.startsWith("file:")) return specifier;

try {
return fileURLToPath(specifier);
} catch {
return specifier;
}
}

function isUnknownFileExtensionError(err: unknown): boolean {
if ((err as { code?: unknown })?.code === "ERR_UNKNOWN_FILE_EXTENSION") return true;

const message = (err as { message?: unknown })?.message;
return typeof message === "string" && /unknown(?: or unsupported)? file extension/i.test(message);
}

// ---

if (import.meta.vitest) {
const { it, expect } = import.meta.vitest;

it("detects supported TypeScript config specifiers", () => {
expect(isTypeScriptModuleSpecifier("/tmp/oxfmt.config.ts")).toBe(true);
expect(isTypeScriptModuleSpecifier("/tmp/oxfmt.config.mts")).toBe(true);
expect(isTypeScriptModuleSpecifier("/tmp/oxfmt.config.cts")).toBe(true);
expect(isTypeScriptModuleSpecifier("file:///tmp/oxfmt.config.ts")).toBe(true);
expect(isTypeScriptModuleSpecifier("/tmp/oxfmt.config.js")).toBe(false);
});

it("returns a node version hint for unsupported TypeScript module loading", () => {
const err = new TypeError(
'Unknown file extension ".ts" for /tmp/oxfmt.config.ts',
) as TypeError & {
code?: string;
};
err.code = "ERR_UNKNOWN_FILE_EXTENSION";

expect(getUnsupportedTypeScriptModuleLoadHint(err, "/tmp/oxfmt.config.ts", "v22.11.0")).toBe(
`TypeScript config files require Node.js ${NODE_TYPESCRIPT_SUPPORT_RANGE}.\nDetected Node.js v22.11.0.\nPlease upgrade Node.js or use a JSON config file instead.`,
);
});

it("does not add the hint for non-TypeScript specifiers or unrelated errors", () => {
const err = new Error("Cannot find package");
expect(getUnsupportedTypeScriptModuleLoadHint(err, "/tmp/oxfmt.config.ts")).toBeNull();

const unknownExtension = new TypeError('Unknown file extension ".ts"');
expect(
getUnsupportedTypeScriptModuleLoadHint(unknownExtension, "/tmp/oxfmt.config.js"),
).toBeNull();
});
}
49 changes: 0 additions & 49 deletions apps/oxfmt/src-js/cli/node_version.ts

This file was deleted.

41 changes: 0 additions & 41 deletions apps/oxfmt/test/cli/js_config/node_version.test.ts

This file was deleted.

3 changes: 2 additions & 1 deletion apps/oxfmt/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"noEmit": true,
"target": "ESNext",
"strict": true,
"skipLibCheck": true
"skipLibCheck": true,
"types": ["vitest/importMeta"]
},
"include": ["scripts", "conformance/*.ts", "src-js", "test/**/*.ts"],
"exclude": ["test/**/fixtures"]
Expand Down
1 change: 1 addition & 0 deletions apps/oxfmt/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default defineConfig({
outDir: "dist",
shims: false,
fixedExtension: false,
define: { "import.meta.vitest": "undefined" },
deps: {
// Optional peer plugins that `prettier-plugin-tailwindcss` tries to dynamic import.
// They are not installed and not needed for us,
Expand Down
5 changes: 3 additions & 2 deletions apps/oxfmt/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { configDefaults, defineConfig } from "vitest/config";
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
exclude: [...configDefaults.exclude],
include: ["./test/**/*.test.ts"],
includeSource: ["./src-js/**/*.ts"],
snapshotFormat: {
escapeString: false,
printBasicPrototype: false,
Expand Down
Loading