diff --git a/Makefile b/Makefile index 052f300..3139fcd 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 ./ diff --git a/README.md b/README.md index 0e1779c..3918097 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/index.d.ts b/index.d.ts index e61d2d0..bb90fd0 100644 --- a/index.d.ts +++ b/index.d.ts @@ -50,3 +50,5 @@ declare interface PostcssModulesPlugin { declare const PostcssModulesPlugin: PostcssModulesPlugin; export = PostcssModulesPlugin; + +declare module "postcss-modules/loader" {} diff --git a/package.json b/package.json index 8bb1471..e03e2d5 100644 --- a/package.json +++ b/package.json @@ -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", @@ -48,7 +56,7 @@ "prettier": "^3.9.4" }, "engines": { - "node": ">=20" + "node": ">=20.6" }, "scripts": { "test": "make test", diff --git a/src/loadConfig.mjs b/src/loadConfig.mjs new file mode 100644 index 0000000..d9e3615 --- /dev/null +++ b/src/loadConfig.mjs @@ -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 {}; +} diff --git a/src/loader.mjs b/src/loader.mjs new file mode 100644 index 0000000..9647fd6 --- /dev/null +++ b/src/loader.mjs @@ -0,0 +1,3 @@ +import { register } from "node:module"; + +register("./loaderHooks.mjs", import.meta.url); diff --git a/src/loaderHooks.mjs b/src/loaderHooks.mjs new file mode 100644 index 0000000..524f3aa --- /dev/null +++ b/src/loaderHooks.mjs @@ -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, + }; +} diff --git a/src/processCSS.js b/src/processCSS.js new file mode 100644 index 0000000..463807d --- /dev/null +++ b/src/processCSS.js @@ -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; +} diff --git a/test/loader.test.js b/test/loader.test.js new file mode 100644 index 0000000..3b3cc34 --- /dev/null +++ b/test/loader.test.js @@ -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/); +});