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
10 changes: 10 additions & 0 deletions .changeset/config-esm-in-commonjs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@hebilicious/cssforge": patch
---

Load `cssforge.config.ts` as ESM even when the project declares `"type": "commonjs"`. Node read the config
as CommonJS there, switched module syntax detection off, and rejected `export default`, so neither the CLI
nor the bundler plugin could load a config in those projects.

The loader's message now also carries the failure it caught, which bundlers previously hid behind the
config path.
79 changes: 75 additions & 4 deletions packages/cssforge/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
* @module
*/

import { existsSync, readFileSync } from "node:fs";
import { registerHooks } from "node:module";
import { resolve } from "node:path";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import type { CSSForgeConfig } from "./config.ts";

Expand Down Expand Up @@ -40,6 +41,49 @@ let loadQueue: Promise<unknown> = Promise.resolve();
const isLocalFileUrl = (url: URL) =>
url.protocol === "file:" && !url.pathname.includes("/node_modules/");

/** Whether a URL is a TypeScript module in the project, config or token file. */
const isProjectTypeScriptUrl = (url: URL) =>
isLocalFileUrl(url) && url.pathname.endsWith(".ts");

/** The `type` field of the nearest package.json, cached per directory. */
const packageTypeCache = new Map<string, string | undefined>();

const packageTypeOf = (path: string): string | undefined => {
const cached = packageTypeCache.get(path);
if (cached !== undefined) return cached;

let directory = dirname(path);

while (true) {
const manifestPath = join(directory, "package.json");
if (existsSync(manifestPath)) {
try {
const parsed: unknown = JSON.parse(readFileSync(manifestPath, "utf8"));
const type =
typeof parsed === "object" && parsed !== null && "type" in parsed
? (parsed as { type?: unknown }).type
: undefined;
const value = typeof type === "string" ? type : undefined;
packageTypeCache.set(path, value);
return value;
} catch {
packageTypeCache.set(path, undefined);
return undefined;
}
}

const parent = dirname(directory);
if (parent === directory) {
packageTypeCache.set(path, undefined);
return undefined;
}
directory = parent;
}
};

/** Whether a module's source is written as ESM rather than CommonJS. */
const usesEsmSyntax = (source: string) => /^\s*(?:export|import)\b/m.test(source);

/** Describes a rejected default export for the error message. */
const describeValue = (value: unknown): string => {
if (value === null) return "null";
Expand Down Expand Up @@ -81,6 +125,29 @@ const loadConfigFile = async (absolutePath: string): Promise<LoadedConfig> => {

return { ...resolved, url: withCacheKey(resolved.url, cacheKey) };
},
load(url, context, nextLoad) {
const parsed = new URL(url);

if (!isProjectTypeScriptUrl(parsed)) {
return nextLoad(url, context);
}

// A package that declares CommonJS turns module syntax detection off, so
// a config or token module written as ESM fails to parse. Force the ESM
// TypeScript format for those files, and leave CommonJS-authored ones to
// Node so a working setup keeps working.
const path = fileURLToPath(parsed);
if (packageTypeOf(path) !== "commonjs") {
return nextLoad(url, context);
}

const source = readFileSync(path, "utf8");
if (!usesEsmSyntax(source)) {
return nextLoad(url, context);
}

return { format: "module-typescript", source, shortCircuit: true };
},
});

try {
Expand All @@ -93,9 +160,13 @@ const loadConfigFile = async (absolutePath: string): Promise<LoadedConfig> => {
default?: unknown;
};
} catch (error) {
throw new Error(`Could not load the CSS Forge config at ${absolutePath}.`, {
cause: error,
});
// Bundlers show only this message, so the failure behind it has to be
// readable without expanding `cause`.
const detail = error instanceof Error ? ` ${error.message}` : "";
throw new Error(
`Could not load the CSS Forge config at ${absolutePath}.${detail}`,
{ cause: error },
);
}

const config = imported.default;
Expand Down
24 changes: 24 additions & 0 deletions packages/cssforge/tests/load-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,30 @@ Deno.test("loadConfig - returns the default export and every local file the conf
});
});

Deno.test("loadConfig - loads a TypeScript config in a CommonJS project", async () => {
// An explicit `"type": "commonjs"` makes Node read `.ts` as CommonJS and turns
// off module syntax detection, so `export default` would be a syntax error.
await inConfigDir(
{
"package.json": `{ "name": "cjs-project", "version": "0.0.0", "type": "commonjs" }\n`,
...configFiles,
},
async (dir) => {
const { first } = runScenario(dir);

assertEquals(first.config.spacing?.custom?.size?.value, {
2: "0.5rem",
4: "1rem",
});
assertEquals(first.dependencies.map((path) => path.replace(dir, "<dir>")).sort(), [
"<dir>/cssforge.config.ts",
"<dir>/nested.ts",
"<dir>/tokens.ts",
]);
},
);
});

Deno.test("loadConfig - re-evaluates a token module the config imports on the next load", async () => {
await inConfigDir(configFiles, async (dir) => {
const { first, second } = runScenario(dir);
Expand Down
Loading