Skip to content

Commit 7515290

Browse files
committed
add css-modules support!
1 parent 0a133c0 commit 7515290

File tree

6 files changed

+243
-45
lines changed

6 files changed

+243
-45
lines changed

demos/vite-react/src/App.tsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,30 @@
11
import { useState } from 'react';
2-
import { css } from '@acab/ecsstatic';
2+
import { css } from '@acab/ecsstatic/modules';
33
import { Logo } from './Logo.js';
44
import { Button } from './Button.js';
55

66
export const App = () => {
77
const [count, setCount] = useState(0);
88

99
return (
10-
<div className={wrapper}>
10+
<div className={styles.wrapper}>
1111
<Logo />
1212
<Button onClick={() => setCount((c) => c + 1)}>count is {count}</Button>
1313
<p>
14-
Edit any <code className={code}>.tsx</code> file to test HMR
14+
Edit any <code className={styles.code}>.tsx</code> file to test HMR
1515
</p>
1616
</div>
1717
);
1818
};
1919

20-
const wrapper = css`
21-
display: grid;
22-
place-items: center;
23-
`;
20+
const styles = css`
21+
.wrapper {
22+
display: grid;
23+
place-items: center;
24+
}
2425
25-
const code = css`
26-
font-size: 0.9em;
27-
font-family: ui-monospace, monospace;
26+
.code {
27+
font-size: 0.9em;
28+
font-family: ui-monospace, monospace;
29+
}
2830
`;

package/modules.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Returns an object containing CSS-modules-like scoped class names for the
3+
* CSS inside the template string.
4+
*
5+
* @example
6+
* import { css } from '@acab/ecsstatic/modules';
7+
*
8+
* const styles = css`
9+
* .wrapper {
10+
* display: grid;
11+
* place-items: center;
12+
* }
13+
* .button {
14+
* font: inherit;
15+
* color: hotpink;
16+
* }
17+
* `;
18+
*
19+
* export () => (
20+
* <div class={styles.wrapper}>
21+
* <button class={styles.button}>hi</button>
22+
* </div>
23+
* );
24+
*/
25+
export function css(
26+
templates: TemplateStringsArray,
27+
...args: Array<string | number>
28+
): Record<string, string> {
29+
throw new Error(
30+
`If you're seeing this error, it is likely your bundler isn't configured correctly.`
31+
);
32+
}
33+
34+
/**
35+
* Returns an object containing CSS-modules-like scoped class names for the
36+
* SCSS inside the template string.
37+
*
38+
* @example
39+
* import { scss } from '@acab/ecsstatic/modules';
40+
*
41+
* const styles = scss`
42+
* $accent: hotpink;
43+
*
44+
* .wrapper {
45+
* display: grid;
46+
* place-items: center;
47+
* }
48+
* .button {
49+
* font: inherit;
50+
* color: $accent;
51+
* }
52+
* `;
53+
*
54+
* export () => (
55+
* <div class={styles.wrapper}>
56+
* <button class={styles.button}>hi</button>
57+
* </div>
58+
* );
59+
*/
60+
export function scss(
61+
templates: TemplateStringsArray,
62+
...args: Array<string | number>
63+
): Record<string, string> {
64+
throw new Error(
65+
`If you're seeing this error, it is likely your bundler isn't configured correctly.`
66+
);
67+
}

package/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@acab/ecsstatic",
33
"description": "The predefinite CSS-in-JS library for Vite.",
4-
"version": "0.2.0",
4+
"version": "0.3.0-dev.1",
55
"license": "MIT",
66
"repository": {
77
"type": "git",
@@ -33,6 +33,11 @@
3333
"types": "./vite.d.ts",
3434
"import": "./vite.js",
3535
"require": "./vite.cjs"
36+
},
37+
"./modules": {
38+
"types": "./modules.d.ts",
39+
"import": "./modules.js",
40+
"require": "./modules.cjs"
3641
}
3742
},
3843
"dependencies": {
@@ -42,6 +47,7 @@
4247
"esbuild-plugin-noexternal": "^0.1.4",
4348
"magic-string": "^0.27.0",
4449
"postcss": "^8.4.19",
50+
"postcss-modules": "^6.0.0",
4551
"postcss-nested": "^6.0.0",
4652
"postcss-scss": "^4.0.6"
4753
},

package/tsup.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Options } from 'tsup';
22

33
export default <Options>{
4-
entryPoints: ['index.ts', 'vite.ts'],
4+
entryPoints: ['index.ts', 'vite.ts', 'modules.ts'],
55
clean: false,
66
format: ['cjs', 'esm'],
77
dts: true,

package/vite.ts

Lines changed: 69 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import externalizeAllPackagesExcept from 'esbuild-plugin-noexternal';
33
import MagicString from 'magic-string';
44
import path from 'path';
55
import postcss from 'postcss';
6+
import postcssModules from 'postcss-modules';
67
import postcssNested from 'postcss-nested';
78
import postcssScss from 'postcss-scss';
89
import { ancestor as walk } from 'acorn-walk';
@@ -107,6 +108,7 @@ export function ecsstatic(options: Options = {}) {
107108
for (const node of cssTemplateLiterals) {
108109
const { start, end, quasi, tag, _originalName } = node;
109110
const isScss = tag.type === 'Identifier' && ecsstaticImports.get(tag.name)?.isScss;
111+
const isModule = tag.type === 'Identifier' && ecsstaticImports.get(tag.name)?.isModule;
110112

111113
// lazy populate inlinedVars until we need it, to delay problems that come with this mess
112114
if (quasi.expressions.length && !inlinedVars) {
@@ -117,23 +119,30 @@ export function ecsstatic(options: Options = {}) {
117119
const templateContents = quasi.expressions.length
118120
? await processTemplateLiteral(rawTemplate, { inlinedVars })
119121
: rawTemplate.slice(1, rawTemplate.length - 2);
120-
const [css, className] = processCss(templateContents, isScss);
122+
123+
// do the scoping!
124+
const [css, modulesOrClass] = await processCss(templateContents, { isScss, isModule });
125+
126+
let returnValue = ''; // what we will replace the tagged template literal with
127+
if (isModule) {
128+
returnValue = JSON.stringify(modulesOrClass);
129+
} else {
130+
returnValue = `"${modulesOrClass}"`;
131+
// add the original variable name in DEV mode
132+
if (_originalName && viteConfigObj.command === 'serve') {
133+
returnValue = `"🎈-${_originalName} ${modulesOrClass}"`;
134+
}
135+
}
121136

122137
// add processed css to a .css file
123138
const extension = isScss ? 'scss' : 'css';
124-
const cssFilename = `${className}.acab.${extension}`.toLowerCase();
139+
const cssFilename = `${hash(templateContents.trim())}.acab.${extension}`.toLowerCase();
125140
magicCode.append(`import "./${cssFilename}";\n`);
126141
const fullCssPath = normalizePath(path.join(path.dirname(id), cssFilename));
127142
cssList.set(fullCssPath, css);
128143

129-
// add the original variable name in DEV mode
130-
let _className = `"${className}"`;
131-
if (_originalName && viteConfigObj.command === 'serve') {
132-
_className = `"🎈-${_originalName} ${className}"`;
133-
}
134-
135-
// replace the tagged template literal with the generated className
136-
magicCode.update(start, end, _className);
144+
// replace the tagged template literal with the generated class names
145+
magicCode.update(start, end, returnValue);
137146
}
138147

139148
// remove ecsstatic imports, we don't need them anymore
@@ -147,11 +156,8 @@ export function ecsstatic(options: Options = {}) {
147156
};
148157
}
149158

150-
/**
151-
* processes template strings using postcss and
152-
* returns it along with a hashed classname based on the string contents.
153-
*/
154-
function processCss(templateContents: string, isScss = false) {
159+
/** processes css and returns it along with hashed classeses */
160+
async function processCss(templateContents: string, { isScss = false, isModule = false }) {
155161
const isImportOrUse = (line: string) =>
156162
line.trim().startsWith('@import') || line.trim().startsWith('@use');
157163

@@ -166,15 +172,37 @@ function processCss(templateContents: string, isScss = false) {
166172
.join('\n');
167173

168174
const className = `🎈-${hash(templateContents.trim())}`;
169-
const unprocessedCss = `${importsAndUses}\n.${className}{${codeWithoutImportsAndUses}}`;
175+
const unprocessedCss = isModule
176+
? templateContents
177+
: `${importsAndUses}\n.${className}{${codeWithoutImportsAndUses}}`;
170178

171-
const plugins = !isScss
172-
? [postcssNested(), autoprefixer(autoprefixerOptions)]
173-
: [autoprefixer(autoprefixerOptions)];
174-
const options = isScss ? { parser: postcssScss } : {};
175-
const { css } = postcss(plugins).process(unprocessedCss, options);
179+
const { css, modules } = await postprocessCss(unprocessedCss, { isScss, isModule });
180+
181+
if (isModule) {
182+
return [css, modules] as const;
183+
}
176184

177-
return [css, className];
185+
return [css, className] as const;
186+
}
187+
188+
/** runs postcss with autoprefixer and optionally css-modules */
189+
async function postprocessCss(rawCss: string, { isScss = false, isModule = false }) {
190+
let modules: Record<string, string> = {};
191+
192+
const plugins = [
193+
!isScss && postcssNested(),
194+
autoprefixer(autoprefixerOptions),
195+
isModule &&
196+
postcssModules({
197+
generateScopedName: '🎈-[local]-[hash:base64:6]',
198+
getJSON: (_, json) => void (modules = json),
199+
}),
200+
].flatMap((value) => (value ? [value] : []));
201+
202+
const options = isScss ? { parser: postcssScss, from: undefined } : { from: undefined };
203+
const { css } = await postcss(plugins).process(rawCss, options);
204+
205+
return { css, modules };
178206
}
179207

180208
/** resolves all expressions in the template literal and returns a plain string */
@@ -190,13 +218,17 @@ async function processTemplateLiteral(rawTemplate: string, { inlinedVars = '' })
190218

191219
/** parses ast and returns info about all css/scss ecsstatic imports */
192220
function findEcsstaticImports(ast: ESTree.Program) {
193-
const statements = new Map<string, { isScss: boolean; start: number; end: number }>();
221+
const statements = new Map<
222+
string,
223+
{ isScss: boolean; isModule: boolean; start: number; end: number }
224+
>();
194225

195226
for (const node of ast.body.filter((node) => node.type === 'ImportDeclaration')) {
196227
if (
197228
node.type === 'ImportDeclaration' &&
198229
node.source.value?.toString().startsWith('@acab/ecsstatic')
199230
) {
231+
const isModule = node.source.value?.toString().endsWith('modules');
200232
const { start, end } = node;
201233
node.specifiers.forEach((specifier) => {
202234
if (
@@ -205,7 +237,7 @@ function findEcsstaticImports(ast: ESTree.Program) {
205237
) {
206238
const tagName = specifier.local.name;
207239
const isScss = specifier.imported.name === 'scss';
208-
statements.set(tagName, { isScss, start, end });
240+
statements.set(tagName, { isScss, isModule, start, end });
209241
}
210242
});
211243
}
@@ -316,25 +348,29 @@ function findCssTaggedTemplateLiterals(ast: ESTree.Program, tagNames: string[])
316348
function loadDummyEcsstatic() {
317349
const hashStr = hash.toString();
318350
const getHashFromTemplateStr = getHashFromTemplate.toString();
319-
const contents = `${hashStr}\n${getHashFromTemplateStr}\n
351+
const indexContents = `${hashStr}\n${getHashFromTemplateStr}\n
320352
export const css = getHashFromTemplate;
321353
export const scss = getHashFromTemplate;
322354
`;
355+
const modulesContents = `new Proxy({}, {
356+
get() { throw 'please don't do this. css modules are hard to evaluate inside other strings :(' }
357+
})`;
323358

324359
return <esbuild.Plugin>{
325360
name: 'load-dummy-ecsstatic',
326361
setup(build) {
327362
build.onResolve({ filter: /^@acab\/ecsstatic$/ }, (args) => {
328-
return {
329-
namespace: 'ecsstatic',
330-
path: args.path,
331-
};
363+
return { namespace: 'ecsstatic', path: args.path };
332364
});
333365
build.onLoad({ filter: /(.*)/, namespace: 'ecsstatic' }, () => {
334-
return {
335-
contents,
336-
loader: 'js',
337-
};
366+
return { contents: indexContents, loader: 'js' };
367+
});
368+
369+
build.onResolve({ filter: /^@acab\/ecsstatic\/modules$/ }, (args) => {
370+
return { namespace: 'ecsstatic-modules', path: args.path };
371+
});
372+
build.onLoad({ filter: /(.*)/, namespace: 'ecsstatic-modules' }, () => {
373+
return { contents: modulesContents, loader: 'js' };
338374
});
339375
},
340376
};

0 commit comments

Comments
 (0)