diff --git a/.changeset/config-esm-in-commonjs.md b/.changeset/config-esm-in-commonjs.md new file mode 100644 index 0000000..f167d98 --- /dev/null +++ b/.changeset/config-esm-in-commonjs.md @@ -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. diff --git a/packages/cssforge/src/loader.ts b/packages/cssforge/src/loader.ts index a5ec265..ffd3b1b 100644 --- a/packages/cssforge/src/loader.ts +++ b/packages/cssforge/src/loader.ts @@ -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"; @@ -40,6 +41,49 @@ let loadQueue: Promise = 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(); + +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"; @@ -81,6 +125,29 @@ const loadConfigFile = async (absolutePath: string): Promise => { 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 { @@ -93,9 +160,13 @@ const loadConfigFile = async (absolutePath: string): Promise => { 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; diff --git a/packages/cssforge/tests/load-config.test.ts b/packages/cssforge/tests/load-config.test.ts index 373688f..688d980 100644 --- a/packages/cssforge/tests/load-config.test.ts +++ b/packages/cssforge/tests/load-config.test.ts @@ -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, "")).sort(), [ + "/cssforge.config.ts", + "/nested.ts", + "/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);