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/loader-node-format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@hebilicious/cssforge": patch
---

Read project TypeScript in the config graph as ESM whenever Node would read it as CommonJS. The loader
asks Node for the format it chose instead of repeating the package.json lookup and guessing from the
source text.

A CommonJS-authored `.ts` module inside a CommonJS package now loads as ESM, which matches the documented
`export default` config shape.
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ standard library or writing code from first principles.
branch and worktree. Read-only inspection may remain in the current checkout.
- Run `git status` before editing and review the complete diff afterward. Preserve unrelated
changes and commit only when explicitly requested.
- Open a pull request and stop there. Never merge a pull request that a human has not reviewed,
including release pull requests, and never merge one because the work looks finished or the
checks are green. Merging needs an explicit instruction from the user for that specific pull
request, and opening a pull request is not that instruction. Report the pull request URL and wait.
- Define repository operations in `moon.yml` or `.moon/tasks/**/*.yml`, not `package.json`
scripts. Run existing operations through their Moon targets; use pnpm directly only for
dependency management.
Expand Down
67 changes: 14 additions & 53 deletions packages/cssforge/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
* @module
*/

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

Expand Down Expand Up @@ -45,45 +45,6 @@ const isLocalFileUrl = (url: URL) =>
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 @@ -132,21 +93,21 @@ const loadConfigFile = async (absolutePath: string): Promise<LoadedConfig> => {
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 loaded = nextLoad(url, context);

const source = readFileSync(path, "utf8");
if (!usesEsmSyntax(source)) {
return nextLoad(url, context);
// Node answers the module-format question itself, so the loader does not
// repeat its package.json lookup. A package that declares CommonJS makes
// Node read the config as CommonJS, which rejects the documented
// `export default` shape; everything else already loads as ESM.
if (loaded.format !== "commonjs-typescript") {
return loaded;
}

return { format: "module-typescript", source, shortCircuit: true };
return {
format: "module-typescript",
source: loaded.source ?? readFileSync(fileURLToPath(parsed), "utf8"),
shortCircuit: true,
};
},
});

Expand Down
46 changes: 26 additions & 20 deletions packages/cssforge/tests/load-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,28 +111,34 @@ 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 () => {
Deno.test("loadConfig - loads a TypeScript config whatever the package type declares", 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",
]);
},
);
const manifests = [
["commonjs", `{ "name": "cjs", "version": "0.0.0", "type": "commonjs" }`],
["module", `{ "name": "esm", "version": "0.0.0", "type": "module" }`],
["absent", `{ "name": "typeless", "version": "0.0.0" }`],
] as const;

for (const [label, manifest] of manifests) {
await inConfigDir(
{ "package.json": `${manifest}\n`, ...configFiles },
async (dir) => {
const { first } = runScenario(dir);

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

Deno.test("loadConfig - re-evaluates a token module the config imports on the next load", async () => {
Expand Down
Loading