Skip to content

Commit 9e1aa32

Browse files
committed
add css-modules support!
1 parent e74f660 commit 9e1aa32

File tree

8 files changed

+262
-73
lines changed

8 files changed

+262
-73
lines changed

.gitignore

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,3 @@ yarn-error.log*
3131
# turbo
3232
.turbo
3333

34-
# tsup package output
35-
*.cjs
36-
css.js
37-
css.d.ts
38-
index.js
39-
index.d.ts
40-
vite.js
41-
vite.d.ts

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/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
*.d.ts
2+
*.js
3+
*.cjs

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: 85 additions & 53 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';
@@ -102,14 +103,10 @@ export function ecsstatic(options: Options = {}) {
102103

103104
const parsedAst = this.parse(code) as ESTree.Program;
104105

105-
const {
106-
cssImportName,
107-
scssImportName,
108-
statements: ecsstaticImportStatements,
109-
} = findEcsstaticImports(parsedAst);
110-
if (ecsstaticImportStatements.length === 0) return;
106+
const ecsstaticImports = findEcsstaticImports(parsedAst);
107+
if (ecsstaticImports.size === 0) return;
111108

112-
const importNames = [cssImportName, scssImportName].filter(Boolean) as string[];
109+
const importNames = [...ecsstaticImports.keys()];
113110

114111
const cssTemplateLiterals = findCssTaggedTemplateLiterals(parsedAst, importNames);
115112
if (cssTemplateLiterals.length === 0) return;
@@ -119,7 +116,8 @@ export function ecsstatic(options: Options = {}) {
119116

120117
for (const node of cssTemplateLiterals) {
121118
const { start, end, quasi, tag, _originalName } = node;
122-
const isScss = tag.type === 'Identifier' && tag.name === scssImportName;
119+
const isScss = tag.type === 'Identifier' && ecsstaticImports.get(tag.name)?.isScss;
120+
const isModule = tag.type === 'Identifier' && ecsstaticImports.get(tag.name)?.isModule;
123121

124122
// lazy populate inlinedVars until we need it, to delay problems that come with this mess
125123
if (quasi.expressions.length && evaluateExpressions && !inlinedVars) {
@@ -131,27 +129,34 @@ export function ecsstatic(options: Options = {}) {
131129
evaluateExpressions && quasi.expressions.length
132130
? await processTemplateLiteral(rawTemplate, { inlinedVars })
133131
: rawTemplate.slice(1, rawTemplate.length - 2);
134-
const [css, className] = processCss(templateContents, isScss);
132+
133+
// do the scoping!
134+
const [css, modulesOrClass] = await processCss(templateContents, { isScss, isModule });
135+
136+
let returnValue = ''; // what we will replace the tagged template literal with
137+
if (isModule) {
138+
returnValue = JSON.stringify(modulesOrClass);
139+
} else {
140+
returnValue = `"${modulesOrClass}"`;
141+
// add the original variable name in DEV mode
142+
if (_originalName && viteConfigObj.command === 'serve') {
143+
returnValue = `"🎈-${_originalName} ${modulesOrClass}"`;
144+
}
145+
}
135146

136147
// add processed css to a .css file
137148
const extension = isScss ? 'scss' : 'css';
138-
const cssFilename = `${className}.acab.${extension}`.toLowerCase();
149+
const cssFilename = `${hash(templateContents.trim())}.acab.${extension}`.toLowerCase();
139150
magicCode.append(`import "./${cssFilename}";\n`);
140151
const fullCssPath = normalizePath(path.join(path.dirname(id), cssFilename));
141152
cssList.set(fullCssPath, css);
142153

143-
// add the original variable name in DEV mode
144-
let _className = `"${className}"`;
145-
if (_originalName && viteConfigObj.command === 'serve') {
146-
_className = `"🎈-${_originalName} ${className}"`;
147-
}
148-
149-
// replace the tagged template literal with the generated className
150-
magicCode.update(start, end, _className);
154+
// replace the tagged template literal with the generated class names
155+
magicCode.update(start, end, returnValue);
151156
}
152157

153158
// remove ecsstatic imports, we don't need them anymore
154-
ecsstaticImportStatements.forEach(({ start, end }) => magicCode.update(start, end, ''));
159+
for (const { start, end } of ecsstaticImports.values()) magicCode.remove(start, end);
155160

156161
return {
157162
code: magicCode.toString(),
@@ -161,11 +166,8 @@ export function ecsstatic(options: Options = {}) {
161166
};
162167
}
163168

164-
/**
165-
* processes template strings using postcss and
166-
* returns it along with a hashed classname based on the string contents.
167-
*/
168-
function processCss(templateContents: string, isScss = false) {
169+
/** processes css and returns it along with hashed classeses */
170+
async function processCss(templateContents: string, { isScss = false, isModule = false }) {
169171
const isImportOrUse = (line: string) =>
170172
line.trim().startsWith('@import') || line.trim().startsWith('@use');
171173

@@ -180,15 +182,37 @@ function processCss(templateContents: string, isScss = false) {
180182
.join('\n');
181183

182184
const className = `🎈-${hash(templateContents.trim())}`;
183-
const unprocessedCss = `${importsAndUses}\n.${className}{${codeWithoutImportsAndUses}}`;
185+
const unprocessedCss = isModule
186+
? templateContents
187+
: `${importsAndUses}\n.${className}{${codeWithoutImportsAndUses}}`;
184188

185-
const plugins = !isScss
186-
? [postcssNested(), autoprefixer(autoprefixerOptions)]
187-
: [autoprefixer(autoprefixerOptions)];
188-
const options = isScss ? { parser: postcssScss } : {};
189-
const { css } = postcss(plugins).process(unprocessedCss, options);
189+
const { css, modules } = await postprocessCss(unprocessedCss, { isScss, isModule });
190190

191-
return [css, className];
191+
if (isModule) {
192+
return [css, modules] as const;
193+
}
194+
195+
return [css, className] as const;
196+
}
197+
198+
/** runs postcss with autoprefixer and optionally css-modules */
199+
async function postprocessCss(rawCss: string, { isScss = false, isModule = false }) {
200+
let modules: Record<string, string> = {};
201+
202+
const plugins = [
203+
!isScss && postcssNested(),
204+
autoprefixer(autoprefixerOptions),
205+
isModule &&
206+
postcssModules({
207+
generateScopedName: '🎈-[local]-[hash:base64:6]',
208+
getJSON: (_, json) => void (modules = json),
209+
}),
210+
].flatMap((value) => (value ? [value] : []));
211+
212+
const options = isScss ? { parser: postcssScss, from: undefined } : { from: undefined };
213+
const { css } = await postcss(plugins).process(rawCss, options);
214+
215+
return { css, modules };
192216
}
193217

194218
/** resolves all expressions in the template literal and returns a plain string */
@@ -204,28 +228,32 @@ async function processTemplateLiteral(rawTemplate: string, { inlinedVars = '' })
204228

205229
/** parses ast and returns info about all css/scss ecsstatic imports */
206230
function findEcsstaticImports(ast: ESTree.Program) {
207-
let cssImportName: string | undefined;
208-
let scssImportName: string | undefined;
209-
let statements: Array<{ start: number; end: number }> = [];
231+
const statements = new Map<
232+
string,
233+
{ isScss: boolean; isModule: boolean; start: number; end: number }
234+
>();
210235

211236
for (const node of ast.body.filter((node) => node.type === 'ImportDeclaration')) {
212-
if (node.type === 'ImportDeclaration' && node.source.value === '@acab/ecsstatic') {
237+
if (
238+
node.type === 'ImportDeclaration' &&
239+
node.source.value?.toString().startsWith('@acab/ecsstatic')
240+
) {
241+
const isModule = node.source.value?.toString().endsWith('modules');
213242
const { start, end } = node;
214-
if (node.specifiers.some(({ imported }: any) => ['css', 'scss'].includes(imported.name))) {
215-
statements.push({ start, end });
216-
}
217243
node.specifiers.forEach((specifier) => {
218-
if (specifier.type === 'ImportSpecifier' && specifier.imported.name === 'css') {
219-
cssImportName = specifier.local.name;
220-
}
221-
if (specifier.type === 'ImportSpecifier' && specifier.imported.name === 'scss') {
222-
scssImportName = specifier.local.name;
244+
if (
245+
specifier.type === 'ImportSpecifier' &&
246+
['css', 'scss'].includes(specifier.imported.name)
247+
) {
248+
const tagName = specifier.local.name;
249+
const isScss = specifier.imported.name === 'scss';
250+
statements.set(tagName, { isScss, isModule, start, end });
223251
}
224252
});
225253
}
226254
}
227255

228-
return { cssImportName, scssImportName, statements };
256+
return statements;
229257
}
230258

231259
/**
@@ -330,25 +358,29 @@ function findCssTaggedTemplateLiterals(ast: ESTree.Program, tagNames: string[])
330358
function loadDummyEcsstatic() {
331359
const hashStr = hash.toString();
332360
const getHashFromTemplateStr = getHashFromTemplate.toString();
333-
const contents = `${hashStr}\n${getHashFromTemplateStr}\n
361+
const indexContents = `${hashStr}\n${getHashFromTemplateStr}\n
334362
export const css = getHashFromTemplate;
335363
export const scss = getHashFromTemplate;
336364
`;
365+
const modulesContents = `new Proxy({}, {
366+
get() { throw 'please don't do this. css modules are hard to evaluate inside other strings :(' }
367+
})`;
337368

338369
return <esbuild.Plugin>{
339370
name: 'load-dummy-ecsstatic',
340371
setup(build) {
341372
build.onResolve({ filter: /^@acab\/ecsstatic$/ }, (args) => {
342-
return {
343-
namespace: 'ecsstatic',
344-
path: args.path,
345-
};
373+
return { namespace: 'ecsstatic', path: args.path };
346374
});
347375
build.onLoad({ filter: /(.*)/, namespace: 'ecsstatic' }, () => {
348-
return {
349-
contents,
350-
loader: 'js',
351-
};
376+
return { contents: indexContents, loader: 'js' };
377+
});
378+
379+
build.onResolve({ filter: /^@acab\/ecsstatic\/modules$/ }, (args) => {
380+
return { namespace: 'ecsstatic-modules', path: args.path };
381+
});
382+
build.onLoad({ filter: /(.*)/, namespace: 'ecsstatic-modules' }, () => {
383+
return { contents: modulesContents, loader: 'js' };
352384
});
353385
},
354386
};

0 commit comments

Comments
 (0)