Skip to content

Commit ada2bb2

Browse files
committed
Introduce rollup-plugin-preprocess-css-modules package
1 parent 7f91b0c commit ada2bb2

File tree

11 files changed

+4053
-302
lines changed

11 files changed

+4053
-302
lines changed

package.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
{
22
"private": true,
33
"workspaces": [
4+
"packages/ember-local-class",
5+
"packages/glimmer-local-class-transform",
6+
"packages/rollup-plugin-preprocess-css-modules",
47
"packages/ember-css-modules",
58
"test-packages/embroider-app",
69
"test-packages/octane-addon",
710
"test-packages/octane-addon-with-module-name",
811
"test-packages/plugin-addon",
912
"test-packages/sass-app"
10-
]
13+
],
14+
"scripts": {
15+
"build": "yarn workspace glimmer-local-class-transform prepare && yarn workspace rollup-plugin-preprocess-css-modules prepare && yarn workspace addon-v2 build"
16+
},
17+
"volta": {
18+
"node": "22.15.0",
19+
"yarn": "1.22.22"
20+
}
1121
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"plugins": ["prettier"],
3+
"extends": ["eslint:recommended", "plugin:prettier/recommended"],
4+
"env": {
5+
"node": true
6+
},
7+
"parserOptions": {
8+
"ecmaVersion": "latest",
9+
"sourceType": "module"
10+
}
11+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist/
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"singleQuote": true,
3+
"printWidth": 100
4+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# `rollup-plugin-preprocess-css-modules`
2+
3+
TODO
4+
5+
```js
6+
// my-component.js
7+
import styles from './my-component.module.css';
8+
export default `
9+
<h1 class="${styles.title}">Hello, world!</h1>
10+
`;
11+
```
12+
13+
```css
14+
/* my-component.module.css */
15+
.title {
16+
color: darkblue;
17+
}
18+
```
19+
20+
===>
21+
22+
```js
23+
// my-component.js
24+
import styles from './my-component.module.css.js';
25+
export default `
26+
<h1 class="${styles.title}">Hello, world!</h1>
27+
`;
28+
```
29+
30+
```js
31+
// my-component.module.css.js
32+
import './my-component.css';
33+
34+
export default {
35+
title: '_title_abc123_',
36+
};
37+
```
38+
39+
```css
40+
/* my-component.css */
41+
._title_abc123_ {
42+
color: darkblue;
43+
}
44+
```
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"name": "rollup-plugin-preprocess-css-modules",
3+
"version": "1.0.0-alpha.0",
4+
"description": "A Rollup plugin to preprocess CSS Modules into standard CSS for library distribution",
5+
"license": "MIT",
6+
"author": "Dan Freeman",
7+
"homepage": "https://github.com/salsify/ember-css-modules/tree/master/packages/rollup-plugin-preprocess-css-modules",
8+
"repository": {
9+
"type": "git",
10+
"url": "git+ssh://[email protected]/salsify/ember-css-modules.git",
11+
"directory": "packages/rollup-plugin-preprocess-css-modules"
12+
},
13+
"type": "module",
14+
"exports": {
15+
".": "./dist/rollup-plugin.js"
16+
},
17+
"scripts": {
18+
"prepare": "tsc --project tsconfig.build.json",
19+
"test": "vitest run"
20+
},
21+
"files": [
22+
"README.md",
23+
"dist/**/!(*.test.*)"
24+
],
25+
"dependencies": {
26+
"minimatch": "^10.0.3",
27+
"postcss": "^8.5.6",
28+
"postcss-modules": "^6.0.1"
29+
},
30+
"devDependencies": {
31+
"@types/common-tags": "^1.8.4",
32+
"common-tags": "^1.8.2",
33+
"eslint": "^8.22.0",
34+
"fixturify-project": "^7.1.3",
35+
"prettier": "^3.2.5",
36+
"rollup": "^4.43.0",
37+
"typescript": "^5.4.5",
38+
"vitest": "^1.6.0"
39+
},
40+
"peerDependencies": {
41+
"rollup": "^4.0.0"
42+
},
43+
"volta": {
44+
"extends": "../../package.json"
45+
}
46+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { readdir, readFile } from 'node:fs/promises';
2+
import { OutputOptions, rollup } from 'rollup';
3+
import { Project } from 'fixturify-project';
4+
import { describe, test, expect } from 'vitest';
5+
import { stripIndent } from 'common-tags';
6+
import { Addon } from '@embroider/addon-dev/rollup';
7+
import { preprocessCSSModules } from './rollup-plugin.js';
8+
9+
describe('rollup-plugin-preprocess-css-modules', () => {
10+
type Tree = { [name: string]: string | Tree };
11+
async function readTree(path: string): Promise<Tree> {
12+
let result: Tree = {};
13+
for (let entry of await readdir(path, { withFileTypes: true })) {
14+
let fullPath = `${path}/${entry.name}`;
15+
if (entry.isDirectory()) {
16+
result[entry.name] = await readTree(fullPath);
17+
} else if (entry.isFile()) {
18+
result[entry.name] = await readFile(fullPath, { encoding: 'utf8' });
19+
}
20+
}
21+
return result;
22+
}
23+
24+
test('generally works', async () => {
25+
let project = new Project({
26+
files: {
27+
src: {
28+
'index.js': stripIndent`
29+
import styles from './styles.module.css';
30+
31+
export const fooClass = styles.foo;
32+
`,
33+
'constants.module.css': stripIndent`
34+
@value fooColor: green;
35+
`,
36+
'styles.module.css': stripIndent`
37+
@value fooColor from './constants.module.css';
38+
39+
.foo {
40+
color: fooColor;
41+
}
42+
`,
43+
'unused.module.css': stripIndent`
44+
.unused {
45+
color: red;
46+
}
47+
`,
48+
},
49+
},
50+
});
51+
52+
let addon = new Addon({
53+
srcDir: `${project.baseDir}/src`,
54+
destDir: `${project.baseDir}/dist`,
55+
});
56+
57+
await project.write();
58+
59+
let bundle = await rollup({
60+
plugins: [
61+
addon.publicEntrypoints(['index.js']),
62+
preprocessCSSModules({
63+
generateScopedName: (name) => `_${name}_abc123_`,
64+
}),
65+
addon.keepAssets(['**/*.css']),
66+
],
67+
});
68+
69+
await bundle.write(addon.output() as OutputOptions);
70+
71+
expect(new Set(bundle.watchFiles)).toEqual(
72+
new Set([
73+
`${project.baseDir}/src/index.js`,
74+
`${project.baseDir}/src/constants.module.css`,
75+
`${project.baseDir}/src/styles.module.css`,
76+
]),
77+
);
78+
79+
expect(await readTree(`${project.baseDir}/dist`)).toEqual({
80+
'index.js':
81+
stripIndent`
82+
import "./styles.css"
83+
;
84+
85+
var styles = {"foo":"_foo_abc123_"};
86+
87+
const fooClass = styles.foo;
88+
89+
export { fooClass };
90+
//# sourceMappingURL=index.js.map
91+
` + '\n',
92+
'styles.css': stripIndent`
93+
._foo_abc123_ {
94+
color: green;
95+
}
96+
`,
97+
'index.js.map': `{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":""}`,
98+
});
99+
});
100+
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { Plugin, PluginContext, SourceMapInput } from 'rollup';
2+
import { Minimatch } from 'minimatch';
3+
import postcss from 'postcss';
4+
import modules from 'postcss-modules';
5+
6+
type SeenRecord = { css: string; js: string; json: Record<string, string>; map: SourceMapInput };
7+
8+
export type PreprocessCSSModulesConfig = {
9+
include?: string;
10+
generateScopedName?: (name: string, filename: string, css: string) => string;
11+
getOutputFilename?: (filename: string) => string;
12+
};
13+
14+
export function preprocessCSSModules({
15+
include = '**/*.module.css',
16+
getOutputFilename = (filename: string) => filename.replace(/\.module\.css$/, '.css'),
17+
generateScopedName,
18+
}: PreprocessCSSModulesConfig = {}): Plugin<unknown> {
19+
let seen = new Map<string, SeenRecord>();
20+
let includeMatcher = new Minimatch(include, {});
21+
22+
return {
23+
name: 'rollup-plugin-preprocess-css-modules',
24+
25+
async resolveId(source, importer, options) {
26+
let { cssModuleSource } = options.attributes;
27+
if (cssModuleSource) {
28+
this.addWatchFile(cssModuleSource);
29+
return { id: source, meta: { cssModuleSource } };
30+
} else {
31+
let file = await this.resolve(source, importer, { ...options, skipSelf: true });
32+
if (file && includeMatcher.match(file.id)) {
33+
return { ...file, id: `${file.id}.js`, meta: { ...file.meta, cssModule: true } };
34+
}
35+
}
36+
},
37+
38+
async load(id) {
39+
let meta = this.getModuleInfo(id)?.meta;
40+
if (meta?.cssModuleSource) {
41+
return seen.get(meta.cssModuleSource)?.css;
42+
} else if (meta?.cssModule) {
43+
let result = await processModule(this, id);
44+
return result.js;
45+
}
46+
},
47+
};
48+
49+
async function processModule(ctx: PluginContext, id: string): Promise<SeenRecord> {
50+
let inputPath = id.replace(/\.js$/, '');
51+
let outputPath = getOutputFilename(inputPath);
52+
let rawContent = await ctx.fs.readFile(inputPath, { encoding: 'utf8' });
53+
let js: string | undefined, json: Record<string, string> | undefined;
54+
let processor = postcss([
55+
modules({
56+
generateScopedName,
57+
Loader: class {
58+
async fetch(path: string, fromModule: string) {
59+
let resolved = await ctx.resolve(path, fromModule, { skipSelf: false });
60+
if (resolved) await ctx.load(resolved);
61+
return (
62+
seen.get(resolved?.id.replace(/\.js$/, '') ?? '')?.json ??
63+
ctx.error('Internal error: CSS module mapping not found')
64+
);
65+
}
66+
},
67+
getJSON: (inputFilename, mapping) => {
68+
if (inputFilename === inputPath) {
69+
json = mapping;
70+
js = [
71+
`import ${JSON.stringify(outputPath)} with { cssModuleSource: ${JSON.stringify(inputPath)} };`,
72+
`export default ${JSON.stringify(json)};`,
73+
].join('\n');
74+
}
75+
},
76+
}),
77+
]);
78+
79+
let { css, map } = await processor.process(rawContent, { from: inputPath, to: outputPath });
80+
if (!js || !json) {
81+
throw new Error(`Failed to process CSS module: ${inputPath}`);
82+
}
83+
84+
let result = { js, json, css, map: map?.toJSON() as SourceMapInput };
85+
seen.set(inputPath, result);
86+
return result;
87+
}
88+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"exclude": ["**/*.test.ts"]
4+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"include": ["src"],
3+
"compilerOptions": {
4+
"strict": true,
5+
"module": "nodenext",
6+
"target": "esnext",
7+
"outDir": "dist",
8+
"declaration": true,
9+
"skipLibCheck": true
10+
}
11+
}

0 commit comments

Comments
 (0)