Skip to content

Commit af0a3c5

Browse files
committed
Add Node ESM loader hook for import styles from './x.css'
Ships an opt-in `postcss-modules/loader` entrypoint that registers a Node module-customization hook. With `node --import postcss-modules/loader`, JavaScript files can `import styles from './foo.css'` and receive the class-name token map directly, mirroring the workflow `css-loader` and similar bundler integrations provide. The hook reads the CSS file, runs it through the existing plugin with a synthetic `getJSON` callback that captures tokens, and returns a synthetic ESM module whose default export is the token map. Options are resolved from `postcss-modules.config.{js,cjs,mjs}` in cwd, the `POSTCSS_MODULES_CONFIG` env var, or a `"postcss-modules"` key in `package.json`, and accept the same shape as the PostCSS plugin. Targeted at non-bundler use cases (SSR, Node scripts, Jest/Vitest in node mode); bundler users keep using `css-loader`, Vite's built-in support, etc. Requires Node >= 20.6 where `module.register` stabilized. Closes #80.
1 parent 47428c2 commit af0a3c5

9 files changed

Lines changed: 233 additions & 4 deletions

File tree

Makefile

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ NODE_IMAGE = node:22
33
DOCKER = docker run --rm -v "$(CURDIR)":/app -w /app $(NODE_IMAGE)
44
EXEC = $(DOCKER) npm exec --
55

6-
.PHONY: clean lint test build publish pack release-patch release-minor release-major
6+
.PHONY: clean lint compile test build publish pack release-patch release-minor release-major
77

88
# Reinstall whenever package-lock.json is newer than the marker.
99
node_modules/.installed: package-lock.json package.json
@@ -17,11 +17,14 @@ clean:
1717
lint: node_modules/.installed
1818
$(EXEC) eslint src test
1919

20-
test: lint
20+
compile: node_modules/.installed
21+
$(EXEC) swc src -d $(BUILD_DIR) --strip-leading-paths --ignore 'src/*.mjs'
22+
$(DOCKER) sh -c 'cp src/*.mjs $(BUILD_DIR)/'
23+
24+
test: lint compile
2125
$(EXEC) jest
2226

2327
build: clean test
24-
$(EXEC) swc src -d $(BUILD_DIR) --strip-leading-paths
2528

2629
publish: build
2730
npm publish ./

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,35 @@ postcss([
9292

9393
`getJSON` may also return a `Promise`.
9494

95+
## Using with JavaScript imports
96+
97+
### With a bundler
98+
99+
`import styles from "./x.css"` is already supported by every major bundler — they invoke postcss-modules (or its building blocks) under the hood:
100+
101+
- **webpack**[`css-loader`](https://github.com/webpack-contrib/css-loader) with `{ modules: true }`
102+
- **Vite** — built-in, files named `*.module.css`
103+
- **esbuild**[`esbuild-css-modules-plugin`](https://github.com/indooorsman/esbuild-css-modules-plugin)
104+
- **Rollup**[`rollup-plugin-postcss`](https://github.com/egoist/rollup-plugin-postcss) with `modules: true`
105+
106+
### Without a bundler (SSR, Node scripts, Jest/Vitest in node mode)
107+
108+
For Node directly, install the loader hook:
109+
110+
```sh
111+
node --import postcss-modules/loader app.mjs
112+
```
113+
114+
```js
115+
// app.mjs
116+
import styles from "./button.css";
117+
console.log(styles.primary); // "_button__primary_xkpkl_5"
118+
```
119+
120+
Options live in `postcss-modules.config.{js,cjs,mjs}` in your cwd, or set `POSTCSS_MODULES_CONFIG=/path/to/cfg.cjs`. The config object accepts the same options as the PostCSS plugin (`generateScopedName`, `localsConvention`, `scopeBehaviour`, `globalModulePaths`, `Loader`, `resolve`, …).
121+
122+
Requires Node ≥ 20.6 (the version that stabilized `module.register`). The loader returns only the token map; the transformed CSS is not emitted. For HMR, watch mode, or CSS extraction, use a bundler instead.
123+
95124
### Generating scoped names
96125

97126
By default, the plugin assumes that all the classes are local. You can change

index.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,5 @@ declare interface PostcssModulesPlugin {
5050
declare const PostcssModulesPlugin: PostcssModulesPlugin;
5151

5252
export = PostcssModulesPlugin;
53+
54+
declare module "postcss-modules/loader" {}

package.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44
"description": "PostCSS plugin to use CSS Modules everywhere",
55
"main": "build/index.js",
66
"types": "index.d.ts",
7+
"exports": {
8+
".": {
9+
"types": "./index.d.ts",
10+
"default": "./build/index.js"
11+
},
12+
"./loader": "./build/loader.mjs",
13+
"./package.json": "./package.json"
14+
},
715
"keywords": [
816
"postcss",
917
"css",
@@ -48,7 +56,7 @@
4856
"prettier": "^3.9.4"
4957
},
5058
"engines": {
51-
"node": ">=20"
59+
"node": ">=20.6"
5260
},
5361
"scripts": {
5462
"test": "make test",

src/loadConfig.mjs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { existsSync } from "fs";
2+
import { readFile } from "fs/promises";
3+
import path from "path";
4+
import { pathToFileURL } from "url";
5+
6+
const CONFIG_NAMES = [
7+
"postcss-modules.config.js",
8+
"postcss-modules.config.cjs",
9+
"postcss-modules.config.mjs",
10+
];
11+
12+
async function importConfig(filePath) {
13+
const url = pathToFileURL(filePath).href;
14+
const mod = await import(url);
15+
return mod.default || mod;
16+
}
17+
18+
export async function loadConfig(cwd = process.cwd()) {
19+
const explicit = process.env.POSTCSS_MODULES_CONFIG;
20+
if (explicit) {
21+
const resolved = path.isAbsolute(explicit) ? explicit : path.resolve(cwd, explicit);
22+
return importConfig(resolved);
23+
}
24+
25+
for (const name of CONFIG_NAMES) {
26+
const candidate = path.resolve(cwd, name);
27+
if (existsSync(candidate)) {
28+
return importConfig(candidate);
29+
}
30+
}
31+
32+
const pkgPath = path.resolve(cwd, "package.json");
33+
if (existsSync(pkgPath)) {
34+
try {
35+
const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
36+
if (pkg["postcss-modules"]) return pkg["postcss-modules"];
37+
} catch {
38+
// ignore unreadable / malformed package.json
39+
}
40+
}
41+
42+
return {};
43+
}

src/loader.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { register } from "node:module";
2+
3+
register("./loaderHooks.mjs", import.meta.url);

src/loaderHooks.mjs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { readFile } from "node:fs/promises";
2+
import { fileURLToPath } from "node:url";
3+
import { processCSS } from "./processCSS.js";
4+
import { loadConfig } from "./loadConfig.mjs";
5+
6+
let configPromise;
7+
function getConfig() {
8+
if (!configPromise) configPromise = loadConfig();
9+
return configPromise;
10+
}
11+
12+
export async function load(url, context, nextLoad) {
13+
if (!url.startsWith("file:") || !url.endsWith(".css")) {
14+
return nextLoad(url, context);
15+
}
16+
const filename = fileURLToPath(url);
17+
const source = await readFile(filename, "utf8");
18+
const opts = await getConfig();
19+
const tokens = await processCSS(source, filename, opts);
20+
return {
21+
format: "module",
22+
source: `export default ${JSON.stringify(tokens)};\n`,
23+
shortCircuit: true,
24+
};
25+
}

src/processCSS.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import postcss from "postcss";
2+
import { readFile, writeFile } from "fs";
3+
import { setFileSystem } from "./fs";
4+
import { makePlugin } from "./pluginFactory";
5+
6+
setFileSystem({ readFile, writeFile });
7+
8+
export async function processCSS(source, filename, opts = {}) {
9+
let tokens = {};
10+
const plugin = makePlugin({
11+
...opts,
12+
getJSON: (_cssFile, json) => {
13+
tokens = json;
14+
},
15+
});
16+
await postcss([plugin]).process(source, { from: filename });
17+
return tokens;
18+
}

test/loader.test.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { execFileSync } from "node:child_process";
2+
import { existsSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import path from "node:path";
5+
6+
const repoRoot = path.resolve(__dirname, "..");
7+
const loaderPath = path.resolve(repoRoot, "build/loader.mjs");
8+
const fixturesIn = path.resolve(__dirname, "./fixtures/in");
9+
10+
beforeAll(() => {
11+
if (!existsSync(loaderPath)) {
12+
throw new Error(
13+
`Loader build artifact missing at ${loaderPath}. Run \`make compile\` first.`,
14+
);
15+
}
16+
});
17+
18+
function runLoaderScript(script, options = {}) {
19+
return execFileSync(
20+
process.execPath,
21+
["--import", loaderPath, "--input-type=module", "-e", script],
22+
{
23+
cwd: options.cwd || repoRoot,
24+
encoding: "utf8",
25+
env: { ...process.env, ...(options.env || {}) },
26+
},
27+
).trim();
28+
}
29+
30+
it("returns the token map for a CSS file via JS import", () => {
31+
const cssPath = path.join(fixturesIn, "classes.css");
32+
const out = runLoaderScript(
33+
`import s from ${JSON.stringify(cssPath)}; process.stdout.write(JSON.stringify(s));`,
34+
);
35+
const tokens = JSON.parse(out);
36+
expect(Object.keys(tokens).sort()).toEqual(["article", "title"]);
37+
expect(tokens.title).toMatch(/title/);
38+
});
39+
40+
it("composes tokens across imported CSS files", () => {
41+
const cssPath = path.join(fixturesIn, "composes.css");
42+
const out = runLoaderScript(
43+
`import s from ${JSON.stringify(cssPath)}; process.stdout.write(JSON.stringify(s));`,
44+
);
45+
const tokens = JSON.parse(out);
46+
expect(tokens).toHaveProperty("title");
47+
expect(tokens).toHaveProperty("figure");
48+
expect(tokens.title.split(/\s+/).length).toBeGreaterThanOrEqual(2);
49+
expect(tokens.figure.split(/\s+/).length).toBeGreaterThanOrEqual(2);
50+
});
51+
52+
it("applies options from postcss-modules.config.cjs in cwd", () => {
53+
const dir = mkdtempSync(path.join(tmpdir(), "pcm-loader-"));
54+
try {
55+
writeFileSync(
56+
path.join(dir, "postcss-modules.config.cjs"),
57+
'module.exports = { generateScopedName: (name) => "_test_" + name };\n',
58+
);
59+
const cssPath = path.join(dir, "x.css");
60+
writeFileSync(cssPath, ".foo { color: red; }\n.bar { color: blue; }\n");
61+
const out = runLoaderScript(
62+
`import s from ${JSON.stringify(cssPath)}; process.stdout.write(JSON.stringify(s));`,
63+
{ cwd: dir },
64+
);
65+
expect(JSON.parse(out)).toEqual({ foo: "_test_foo", bar: "_test_bar" });
66+
} finally {
67+
rmSync(dir, { recursive: true, force: true });
68+
}
69+
});
70+
71+
it("honors POSTCSS_MODULES_CONFIG env var", () => {
72+
const dir = mkdtempSync(path.join(tmpdir(), "pcm-loader-env-"));
73+
try {
74+
const configPath = path.join(dir, "my-config.cjs");
75+
writeFileSync(
76+
configPath,
77+
'module.exports = { generateScopedName: (name) => "_env_" + name };\n',
78+
);
79+
const cssPath = path.join(dir, "x.css");
80+
writeFileSync(cssPath, ".a {}\n.b {}\n");
81+
const out = runLoaderScript(
82+
`import s from ${JSON.stringify(cssPath)}; process.stdout.write(JSON.stringify(s));`,
83+
{ cwd: dir, env: { POSTCSS_MODULES_CONFIG: configPath } },
84+
);
85+
expect(JSON.parse(out)).toEqual({ a: "_env_a", b: "_env_b" });
86+
} finally {
87+
rmSync(dir, { recursive: true, force: true });
88+
}
89+
});
90+
91+
it("surfaces missing-file errors with the source path", () => {
92+
const cssPath = path.join(fixturesIn, "does-not-exist.css");
93+
expect(() => {
94+
runLoaderScript(
95+
`import s from ${JSON.stringify(cssPath)}; process.stdout.write(JSON.stringify(s));`,
96+
);
97+
}).toThrow(/does-not-exist\.css/);
98+
});

0 commit comments

Comments
 (0)