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
9 changes: 6 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
BUILD_DIR = ./build
EXEC = npm exec --

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

# Reinstall whenever package-lock.json is newer than the marker.
node_modules/.installed: package-lock.json package.json
Expand All @@ -15,11 +15,14 @@ clean:
lint: node_modules/.installed
$(EXEC) eslint src test

test: lint
compile: node_modules/.installed
$(EXEC) swc src -d $(BUILD_DIR) --strip-leading-paths --ignore 'src/*.mjs'
cp src/*.mjs $(BUILD_DIR)/

test: lint compile
$(EXEC) jest

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

publish: build
npm publish ./
Expand Down
95 changes: 95 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,101 @@ postcss([

`getJSON` may also return a `Promise`.

## Using with JavaScript imports

### With a bundler

`import styles from "./x.css"` is already supported by every major bundler — they invoke postcss-modules (or its building blocks) under the hood:

- **webpack** — [`css-loader`](https://github.com/webpack-contrib/css-loader) with `{ modules: true }`
- **Vite** — built-in, files named `*.module.css`
- **esbuild** — [`esbuild-css-modules-plugin`](https://github.com/indooorsman/esbuild-css-modules-plugin)
- **Rollup** — [`rollup-plugin-postcss`](https://github.com/egoist/rollup-plugin-postcss) with `modules: true`

### Without a bundler (SSR, Node scripts, Jest/Vitest in node mode)

`postcss-modules` ships a Node [module-customization hook](https://nodejs.org/api/module.html#customization-hooks) at the `postcss-modules/loader` subpath. Register it with `--import` and any `import` of a `.css` file is intercepted, run through the plugin, and exposed as a default-exported token map.

```sh
node --import postcss-modules/loader app.mjs
```

```js
// app.mjs
import styles from "./button.css";

const el = document.createElement("div");
el.className = styles.primary; // "_button__primary_xkpkl_5"
```

Composing across files (`composes: foo from "./other.css"`) works without extra configuration — the same `FileSystemLoader` the PostCSS plugin uses resolves siblings.

#### Configuring the loader

The hook accepts the same options object as the PostCSS plugin
(`generateScopedName`, `localsConvention`, `scopeBehaviour`, `globalModulePaths`, `exportGlobals`, `hashPrefix`, `Loader`, `resolve`, `root`). Options are discovered, in priority order:

1. `process.env.POSTCSS_MODULES_CONFIG` — absolute or cwd-relative path to a config file.
2. `postcss-modules.config.{js,cjs,mjs}` in `process.cwd()`.
3. A `"postcss-modules"` key in the nearest `package.json`.
4. Empty defaults.

Example config file:

```js
// postcss-modules.config.cjs
module.exports = {
generateScopedName: "[name]__[local]___[hash:base64:5]",
localsConvention: "camelCaseOnly",
};
```

Or inline in `package.json`:

```json
{
"postcss-modules": {
"generateScopedName": "[name]__[local]___[hash:base64:5]"
}
}
```

Or via an environment variable, useful for per-environment overrides:

```sh
POSTCSS_MODULES_CONFIG=./config/css-modules.cjs node --import postcss-modules/loader app.mjs
```

#### Jest / Vitest

Both runners accept the same `--import` flag through their Node options.

```sh
NODE_OPTIONS="--import postcss-modules/loader" jest
NODE_OPTIONS="--import postcss-modules/loader" vitest
```

Or wire it into a setup file. The loader runs once per worker, so spawned worker pools (Jest's default) pick it up automatically.

#### TypeScript

Add an ambient declaration so the compiler accepts default-importing a `.css` file:

```ts
// css-modules.d.ts
declare module "*.css" {
const tokens: { readonly [key: string]: string };
export default tokens;
}
```

#### Limitations

- **ESM only.** `require("./x.css")` is not supported; the underlying transform is async and cannot be expressed through `require.extensions`. Use a `.mjs` entrypoint, or `"type": "module"` in `package.json`.
- **Tokens only, no CSS output.** The loader returns the class-name map but does not emit the transformed CSS. SSR setups that need the stylesheet should use a bundler or call `postcss([require("postcss-modules")(...)]).process(...)` directly to capture both.
- **No HMR, no watch mode** — by design. Use a bundler for those.
- **Node ≥ 20.6.** `module.register` stabilized there.

### Generating scoped names

By default, the plugin assumes that all the classes are local. You can change
Expand Down
2 changes: 2 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,5 @@ declare interface PostcssModulesPlugin {
declare const PostcssModulesPlugin: PostcssModulesPlugin;

export = PostcssModulesPlugin;

declare module "postcss-modules/loader" {}
10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@
"description": "PostCSS plugin to use CSS Modules everywhere",
"main": "build/index.js",
"types": "index.d.ts",
"exports": {
".": {
"types": "./index.d.ts",
"default": "./build/index.js"
},
"./loader": "./build/loader.mjs",
"./package.json": "./package.json"
},
"keywords": [
"postcss",
"css",
Expand Down Expand Up @@ -48,7 +56,7 @@
"prettier": "^3.9.4"
},
"engines": {
"node": ">=20"
"node": ">=20.6"
},
"scripts": {
"test": "make test",
Expand Down
43 changes: 43 additions & 0 deletions src/loadConfig.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { existsSync } from "fs";
import { readFile } from "fs/promises";
import path from "path";
import { pathToFileURL } from "url";

const CONFIG_NAMES = [
"postcss-modules.config.js",
"postcss-modules.config.cjs",
"postcss-modules.config.mjs",
];

async function importConfig(filePath) {
const url = pathToFileURL(filePath).href;
const mod = await import(url);
return mod.default || mod;
}

export async function loadConfig(cwd = process.cwd()) {
const explicit = process.env.POSTCSS_MODULES_CONFIG;
if (explicit) {
const resolved = path.isAbsolute(explicit) ? explicit : path.resolve(cwd, explicit);
return importConfig(resolved);
}

for (const name of CONFIG_NAMES) {
const candidate = path.resolve(cwd, name);
if (existsSync(candidate)) {
return importConfig(candidate);
}
}

const pkgPath = path.resolve(cwd, "package.json");
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
if (pkg["postcss-modules"]) return pkg["postcss-modules"];
} catch {
// ignore unreadable / malformed package.json
}
}

return {};
}
3 changes: 3 additions & 0 deletions src/loader.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { register } from "node:module";

register("./loaderHooks.mjs", import.meta.url);
25 changes: 25 additions & 0 deletions src/loaderHooks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { processCSS } from "./processCSS.js";
import { loadConfig } from "./loadConfig.mjs";

let configPromise;
function getConfig() {
if (!configPromise) configPromise = loadConfig();
return configPromise;
}

export async function load(url, context, nextLoad) {
if (!url.startsWith("file:") || !url.endsWith(".css")) {
return nextLoad(url, context);
}
const filename = fileURLToPath(url);
const source = await readFile(filename, "utf8");
const opts = await getConfig();
const tokens = await processCSS(source, filename, opts);
return {
format: "module",
source: `export default ${JSON.stringify(tokens)};\n`,
shortCircuit: true,
};
}
18 changes: 18 additions & 0 deletions src/processCSS.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import postcss from "postcss";
import { readFile, writeFile } from "fs";
import { setFileSystem } from "./fs";
import { makePlugin } from "./pluginFactory";

setFileSystem({ readFile, writeFile });

export async function processCSS(source, filename, opts = {}) {
let tokens = {};
const plugin = makePlugin({
...opts,
getJSON: (_cssFile, json) => {
tokens = json;
},
});
await postcss([plugin]).process(source, { from: filename });
return tokens;
}
102 changes: 102 additions & 0 deletions test/loader.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";

const repoRoot = path.resolve(__dirname, "..");
const loaderPath = path.resolve(repoRoot, "build/loader.mjs");
const loaderUrl = pathToFileURL(loaderPath).href;
const fixturesIn = path.resolve(__dirname, "./fixtures/in");

const toImportSpecifier = (filePath) => pathToFileURL(filePath).href;

beforeAll(() => {
if (!existsSync(loaderPath)) {
throw new Error(
`Loader build artifact missing at ${loaderPath}. Run \`make compile\` first.`,
);
}
});

function runLoaderScript(script, options = {}) {
return execFileSync(
process.execPath,
["--import", loaderUrl, "--input-type=module", "-e", script],
{
cwd: options.cwd || repoRoot,
encoding: "utf8",
env: { ...process.env, ...(options.env || {}) },
},
).trim();
}

it("returns the token map for a CSS file via JS import", () => {
const cssPath = path.join(fixturesIn, "classes.css");
const out = runLoaderScript(
`import s from ${JSON.stringify(toImportSpecifier(cssPath))}; process.stdout.write(JSON.stringify(s));`,
);
const tokens = JSON.parse(out);
expect(Object.keys(tokens).sort()).toEqual(["article", "title"]);
expect(tokens.title).toMatch(/title/);
});

it("composes tokens across imported CSS files", () => {
const cssPath = path.join(fixturesIn, "composes.css");
const out = runLoaderScript(
`import s from ${JSON.stringify(toImportSpecifier(cssPath))}; process.stdout.write(JSON.stringify(s));`,
);
const tokens = JSON.parse(out);
expect(tokens).toHaveProperty("title");
expect(tokens).toHaveProperty("figure");
expect(tokens.title.split(/\s+/).length).toBeGreaterThanOrEqual(2);
expect(tokens.figure.split(/\s+/).length).toBeGreaterThanOrEqual(2);
});

it("applies options from postcss-modules.config.cjs in cwd", () => {
const dir = mkdtempSync(path.join(tmpdir(), "pcm-loader-"));
try {
writeFileSync(
path.join(dir, "postcss-modules.config.cjs"),
'module.exports = { generateScopedName: (name) => "_test_" + name };\n',
);
const cssPath = path.join(dir, "x.css");
writeFileSync(cssPath, ".foo { color: red; }\n.bar { color: blue; }\n");
const out = runLoaderScript(
`import s from ${JSON.stringify(toImportSpecifier(cssPath))}; process.stdout.write(JSON.stringify(s));`,
{ cwd: dir },
);
expect(JSON.parse(out)).toEqual({ foo: "_test_foo", bar: "_test_bar" });
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("honors POSTCSS_MODULES_CONFIG env var", () => {
const dir = mkdtempSync(path.join(tmpdir(), "pcm-loader-env-"));
try {
const configPath = path.join(dir, "my-config.cjs");
writeFileSync(
configPath,
'module.exports = { generateScopedName: (name) => "_env_" + name };\n',
);
const cssPath = path.join(dir, "x.css");
writeFileSync(cssPath, ".a {}\n.b {}\n");
const out = runLoaderScript(
`import s from ${JSON.stringify(toImportSpecifier(cssPath))}; process.stdout.write(JSON.stringify(s));`,
{ cwd: dir, env: { POSTCSS_MODULES_CONFIG: configPath } },
);
expect(JSON.parse(out)).toEqual({ a: "_env_a", b: "_env_b" });
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("surfaces missing-file errors with the source path", () => {
const cssPath = path.join(fixturesIn, "does-not-exist.css");
expect(() => {
runLoaderScript(
`import s from ${JSON.stringify(toImportSpecifier(cssPath))}; process.stdout.write(JSON.stringify(s));`,
);
}).toThrow(/does-not-exist\.css/);
});
Loading