From e50d525a3549c642e332f462afe0883662187dac Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Thu, 24 Sep 2026 20:40:41 +0700 Subject: [PATCH 1/2] fix(core): load the config as ESM in CommonJS projects A project that declares `"type": "commonjs"` makes Node read a `.ts` config as CommonJS with module syntax detection off, so `export default` failed and the CLI and the bundler plugin could not load the config at all. The loader now evaluates project TypeScript in the config graph as ESM, and its message includes the failure it caught, which bundlers showed only as a config path. --- .changeset/config-esm-in-commonjs.md | 10 +++++++ packages/cssforge/src/loader.ts | 32 +++++++++++++++++++-- packages/cssforge/tests/load-config.test.ts | 24 ++++++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 .changeset/config-esm-in-commonjs.md 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..6084889 100644 --- a/packages/cssforge/src/loader.ts +++ b/packages/cssforge/src/loader.ts @@ -8,6 +8,7 @@ * @module */ +import { readFileSync } from "node:fs"; import { registerHooks } from "node:module"; import { resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -40,6 +41,10 @@ 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") || url.pathname.endsWith(".mts")); + /** Describes a rejected default export for the error message. */ const describeValue = (value: unknown): string => { if (value === null) return "null"; @@ -81,6 +86,23 @@ const loadConfigFile = async (absolutePath: string): Promise => { return { ...resolved, url: withCacheKey(resolved.url, cacheKey) }; }, + load(url, context, nextLoad) { + // A config and the token modules it imports are written as ESM, so they + // must not depend on the consumer's package.json `type` field. Node reads + // a `.ts` file in a CommonJS package as CommonJS and rejects + // `export default`, which is the documented config shape. + const parsed = new URL(url); + + if (!isProjectTypeScriptUrl(parsed)) { + return nextLoad(url, context); + } + + return { + format: "module-typescript", + source: readFileSync(fileURLToPath(parsed), "utf8"), + shortCircuit: true, + }; + }, }); try { @@ -93,9 +115,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); From 38f13e5e1f3e44f199bfdb7da67e1397d87e4073 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Thu, 24 Sep 2026 20:46:04 +0700 Subject: [PATCH 2/2] fix(core): scope the ESM override to CommonJS packages Forcing the ESM format for every project TypeScript file also hit the package's own sources when they run from source, which broke the JSR smoke test under Deno. The override now applies only when the nearest package.json declares CommonJS and the file is written as ESM, so a Deno run, a `"type": "module"` project, and a CommonJS-authored config all keep their own format. --- packages/cssforge/src/loader.ts | 69 +++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/packages/cssforge/src/loader.ts b/packages/cssforge/src/loader.ts index 6084889..ffd3b1b 100644 --- a/packages/cssforge/src/loader.ts +++ b/packages/cssforge/src/loader.ts @@ -8,9 +8,9 @@ * @module */ -import { readFileSync } from "node:fs"; +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"; @@ -43,7 +43,46 @@ const isLocalFileUrl = (url: URL) => /** Whether a URL is a TypeScript module in the project, config or token file. */ const isProjectTypeScriptUrl = (url: URL) => - isLocalFileUrl(url) && (url.pathname.endsWith(".ts") || url.pathname.endsWith(".mts")); + 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 => { @@ -87,21 +126,27 @@ const loadConfigFile = async (absolutePath: string): Promise => { return { ...resolved, url: withCacheKey(resolved.url, cacheKey) }; }, load(url, context, nextLoad) { - // A config and the token modules it imports are written as ESM, so they - // must not depend on the consumer's package.json `type` field. Node reads - // a `.ts` file in a CommonJS package as CommonJS and rejects - // `export default`, which is the documented config shape. const parsed = new URL(url); if (!isProjectTypeScriptUrl(parsed)) { return nextLoad(url, context); } - return { - format: "module-typescript", - source: readFileSync(fileURLToPath(parsed), "utf8"), - shortCircuit: true, - }; + // 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 }; }, });