Skip to content
This repository was archived by the owner on Oct 11, 2026. It is now read-only.
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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,24 @@ import { css } from "./main.module.css";
console.log(css); // Logs the generated css file
```

#### JavaScript export names

When importing a CSS module from JavaScript or TypeScript, class-map keys must
be valid JavaScript binding names. For example, `.foo-bar` and `.default` cannot
be exported as `export const foo-bar` and `export const default`. The name `css`
is reserved for the generated stylesheet. Unsupported keys produce a build error
identifying the key and its stylesheet.

Use `modules: { localsConvention: "dashesOnly" }` to export `.foo-bar` as
`fooBar`. `"camelCaseOnly"` also removes original dashed keys; `"dashes"` and
`"camelCase"` retain them and still produce an error. For reserved names, rename
the class or supply a `localsConvention` function that returns a valid name,
such as `(name) => name + "Class"` for `.default` or `.css`.

These restrictions apply to JavaScript named exports. CSS entry points and their
JSON class maps keep arbitrary keys. Setting `modules: false` exports only the
processed stylesheet as `css`. There is no default class-map export.

### Preprocessors

This plugin supports using Sass, Less, and Stylus with PostCSS. If you want to
Expand Down
249 changes: 249 additions & 0 deletions module-export-names.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import { assertEquals, assertRejects, assertStringIncludes } from "@std/assert";
import { delay } from "@std/async/delay";
import * as path from "@std/path";
import { describe, it } from "@std/testing/bdd";
import esbuild from "esbuild";

import { postCSSPlugin, type PostCSSPluginOptions } from "./postcss.ts";

async function withStylesheet(
css: string,
options: PostCSSPluginOptions,
verify: (
result: esbuild.BuildResult,
directory: string,
) => Promise<void> | void,
stylesheetEntry = false,
) {
const directory = await Deno.makeTempDir({ prefix: "postcss-export-names-" });
const stylesheet = path.join(directory, "styles.module.css");
try {
await Deno.writeTextFile(stylesheet, css);
const result = await esbuild.build({
absWorkingDir: directory,
...(stylesheetEntry ? { entryPoints: [stylesheet] } : {
stdin: {
contents:
'import * as styles from "./styles.module.css"; export { styles };',
resolveDir: directory,
sourcefile: "entry.js",
},
}),
plugins: [postCSSPlugin(options)],
bundle: true,
format: "esm",
outfile: path.join(directory, stylesheetEntry ? "out.css" : "out.js"),
write: false,
logLevel: "silent",
});
await verify(result, directory);
} finally {
await esbuild.stop();
await delay(1);
await Deno.remove(directory, { recursive: true });
}
}

async function importStyles(result: esbuild.BuildResult, directory: string) {
const output = path.join(directory, "result.mjs");
await Deno.writeTextFile(output, result.outputFiles![0].text);
const module = await import(path.toFileUrl(output).href);
return module.styles as Record<string, string>;
}

describe("CSS module JavaScript export names", () => {
for (
const key of [
"foo-bar",
"default",
"class",
"await",
"yield",
"let",
"enum",
"implements",
"interface",
"package",
"private",
"protected",
"public",
"static",
"eval",
"arguments",
"123name",
"",
]
) {
it(`reports the transformed invalid binding ${JSON.stringify(key)}`, async () => {
const error = await assertRejects(
() =>
withStylesheet(".original { color: red; }", {
modules: { localsConvention: () => key, getJSON() {} },
}, () => {}),
Error,
`CSS module export ${
JSON.stringify(key)
} is not a valid JavaScript binding name`,
) as esbuild.BuildFailure;
assertEquals(error.errors.length, 1);
assertEquals(error.errors[0].pluginName, "postcss");
assertStringIncludes(error.errors[0].location!.file, "styles.module.css");
assertStringIncludes(error.errors[0].text, "modules.localsConvention");
});
}

it("reports every unsupported original class key in the stylesheet", async () => {
const error = await assertRejects(
() =>
withStylesheet(".foo-bar { color: red; } .default { color: blue; }", {
modules: { getJSON() {} },
}, () => {}),
Error,
'CSS module export "foo-bar" is not a valid JavaScript binding name',
) as esbuild.BuildFailure;
assertEquals(error.errors.length, 2);
assertStringIncludes(
error.errors.map((entry) => entry.text).join("\n"),
'CSS module export "default"',
);
assertEquals(error.errors.map((entry) => entry.pluginName), [
"postcss",
"postcss",
]);
});

it("preserves valid identifier joiners supplied by a custom convention", async () => {
const key = "a\u200Cb\u200Dc";
await withStylesheet(".title { color: red; }", {
modules: {
localsConvention: () => key,
generateScopedName: "scoped_[local]",
getJSON() {},
},
}, async (result, directory) => {
const styles = await importStyles(result, directory);
assertEquals(styles[key], "scoped_title");
assertEquals(Object.keys(styles).sort(), [key, "css"]);
assertStringIncludes(styles.css, ".scoped_title");
});
});
it("reports a class that conflicts with the stylesheet payload", async () => {
const error = await assertRejects(
() =>
withStylesheet(".css { color: red; }", {
modules: { getJSON() {} },
}, () => {}),
Error,
'CSS module export "css" conflicts with the stylesheet export',
) as esbuild.BuildFailure;
assertEquals(error.errors.length, 1);
assertEquals(error.errors[0].pluginName, "postcss");
assertStringIncludes(error.errors[0].text, "modules.localsConvention");
});

it("preserves valid Unicode and contextual names and their class values", async () => {
const names = [
"title",
"café",
"δ",
"á",
"async",
"as",
"from",
"of",
"get",
"set",
];
const classes: Record<string, string> = {};
await withStylesheet(
names.map((name) => `.${name} { color: red; }`).join("\n"),
{
modules: {
generateScopedName: (name) => `scoped_${name}`,
getJSON(_filename, json) {
Object.assign(classes, json);
},
},
},
async (result, directory) => {
const styles = await importStyles(result, directory);
const { css, ...exports } = styles;
assertEquals(exports, classes);
assertEquals(Object.keys(exports).sort(), names.sort());
for (const name of names) {
assertEquals(exports[name], `scoped_${name}`);
assertStringIncludes(css, `.scoped_${name}`);
}
},
);
});

it("uses dashesOnly to export a dashed class under a supported name", async () => {
await withStylesheet(".foo-bar { color: red; }", {
modules: {
localsConvention: "dashesOnly",
generateScopedName: "scoped_[local]",
getJSON() {},
},
}, async (result, directory) => {
const styles = await importStyles(result, directory);
assertEquals(styles.fooBar, "scoped_foo-bar");
assertEquals(Object.keys(styles).sort(), ["css", "fooBar"]);
assertStringIncludes(styles.css, ".scoped_foo-bar");
});
});

it("lets a custom convention remap a reserved class name", async () => {
await withStylesheet(".default { color: red; } .css { color: blue; }", {
modules: {
localsConvention: (name) => `${name}Class`,
generateScopedName: "scoped_[local]",
getJSON() {},
},
}, async (result, directory) => {
const styles = await importStyles(result, directory);
assertEquals(styles.defaultClass, "scoped_default");
assertEquals(styles.cssClass, "scoped_css");
assertStringIncludes(styles.css, ".scoped_default");
});
});

it("keeps arbitrary class-map keys for stylesheet entry points", async () => {
await withStylesheet(
".foo-bar { color: red; } .default { color: blue; } .css { color: green; }",
{},
async (result, directory) => {
const classes = JSON.parse(
await Deno.readTextFile(
path.join(directory, "styles.module.css.json"),
),
);
assertEquals(Object.keys(classes).sort(), [
"css",
"default",
"foo-bar",
]);
assertStringIncludes(result.outputFiles![0].text, classes["foo-bar"]);
assertStringIncludes(result.outputFiles![0].text, classes.default);
assertStringIncludes(result.outputFiles![0].text, classes.css);
},
true,
);
});

it("keeps modules disabled and exports only the stylesheet", async () => {
await withStylesheet(
".foo-bar { color: red; } .default { color: blue; } .css { color: green; }",
{
modules: false,
},
async (result, directory) => {
const styles = await importStyles(result, directory);
assertEquals(Object.keys(styles), ["css"]);
assertStringIncludes(styles.css, ".foo-bar");
assertStringIncludes(styles.css, ".default");
assertStringIncludes(styles.css, ".css");
},
);
});
});
77 changes: 75 additions & 2 deletions postcss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,58 @@ export interface PostCSSModulesOptions {
) => string | null | Promise<string | null>;
}

const moduleBindingName = /^[$_\p{ID_Start}][$_\u200C\u200D\p{ID_Continue}]*$/u;
const reservedModuleBindings = new Set([
"arguments",
"await",
"break",
"case",
"catch",
"class",
"const",
"continue",
"debugger",
"default",
"delete",
"do",
"else",
"enum",
"eval",
"export",
"extends",
"false",
"finally",
"for",
"function",
"if",
"implements",
"import",
"in",
"instanceof",
"interface",
"let",
"new",
"null",
"package",
"private",
"protected",
"public",
"return",
"static",
"super",
"switch",
"this",
"throw",
"true",
"try",
"typeof",
"var",
"void",
"while",
"with",
"yield",
]);

function getFilesRecursive(directory: string): string[] {
return [...Deno.readDirSync(directory)].reduce<string[]>((files, file) => {
const name = path.join(directory, file.name);
Expand Down Expand Up @@ -241,8 +293,11 @@ export interface PostCSSPluginOptions {
* A stylesheet imported with a JavaScript `import` statement becomes a module
* exporting the processed stylesheet as `css`; a CSS module also exports each
* class name as a named export (`import { title, css } from
* "./main.module.css"`). There is no default export. A stylesheet reached any
* other way, such as an entry point or a CSS `@import`, is emitted as CSS.
* "./main.module.css"`). Export keys must be valid JavaScript binding names,
* and `css` is reserved for the stylesheet. Use `dashesOnly` or a custom
* `localsConvention` to remap unsupported names. There is no default export.
* A stylesheet reached any other way, such as an entry point or a CSS
* `@import`, is emitted as CSS.
*
* @example Build a stylesheet
* ```ts
Expand Down Expand Up @@ -363,6 +418,24 @@ export const postCSSPlugin = (
const absolutePath = pluginData.absolutePath as string;
const mod = modulesMap.get(absolutePath) ?? {};
const css = pluginData.css;
if (pluginData.kind === "import-statement") {
const errors = Object.keys(mod).flatMap((key) => {
const reason = key === "css"
? "conflicts with the stylesheet export"
: !moduleBindingName.test(key) || reservedModuleBindings.has(key)
? "is not a valid JavaScript binding name"
: undefined;
return reason
? [{
text: `CSS module export ${
JSON.stringify(key)
} ${reason}. Rename the class or use modules.localsConvention to produce a supported name.`,
location: { file: absolutePath },
}]
: [];
});
if (errors.length) return { errors };
}
return {
resolveDir: pluginData.resolveDir,
loader: pluginData.kind === "import-statement" ? "js" : "css",
Expand Down
Loading