Skip to content

add css-modules support 🎈 #4

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
22 changes: 12 additions & 10 deletions demos/vite-react/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,30 @@
import { useState } from 'react';
import { css } from '@acab/ecsstatic';
import { css } from '@acab/ecsstatic/modules';
import { Logo } from './Logo.js';
import { Button } from './Button.js';

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

return (
<div className={wrapper}>
<div className={styles.wrapper}>
<Logo />
<Button onClick={() => setCount((c) => c + 1)}>count is {count}</Button>
<p>
Edit any <code className={code}>.tsx</code> file to test HMR
Edit any <code className={styles.code}>.tsx</code> file to test HMR
</p>
</div>
);
};

const wrapper = css`
display: grid;
place-items: center;
`;
const styles = css`
.wrapper {
display: grid;
place-items: center;
}

const code = css`
font-size: 0.9em;
font-family: ui-monospace, monospace;
.code {
font-size: 0.9em;
font-family: ui-monospace, monospace;
}
`;
67 changes: 67 additions & 0 deletions package/modules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Returns an object containing CSS-modules-like scoped class names for the
* CSS inside the template string.
*
* @example
* import { css } from '@acab/ecsstatic/modules';
*
* const styles = css`
* .wrapper {
* display: grid;
* place-items: center;
* }
* .button {
* font: inherit;
* color: hotpink;
* }
* `;
*
* export () => (
* <div class={styles.wrapper}>
* <button class={styles.button}>hi</button>
* </div>
* );
*/
export function css(
templates: TemplateStringsArray,
...args: Array<string | number>
): Record<string, string> {
throw new Error(
`If you're seeing this error, it is likely your bundler isn't configured correctly.`
);
}

/**
* Returns an object containing CSS-modules-like scoped class names for the
* SCSS inside the template string.
*
* @example
* import { scss } from '@acab/ecsstatic/modules';
*
* const styles = scss`
* $accent: hotpink;
*
* .wrapper {
* display: grid;
* place-items: center;
* }
* .button {
* font: inherit;
* color: $accent;
* }
* `;
*
* export () => (
* <div class={styles.wrapper}>
* <button class={styles.button}>hi</button>
* </div>
* );
*/
export function scss(
templates: TemplateStringsArray,
...args: Array<string | number>
): Record<string, string> {
throw new Error(
`If you're seeing this error, it is likely your bundler isn't configured correctly.`
);
}
8 changes: 7 additions & 1 deletion package/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@acab/ecsstatic",
"description": "The predefinite CSS-in-JS library for Vite.",
"version": "0.2.0",
"version": "0.3.0-dev.1",
"license": "MIT",
"repository": {
"type": "git",
Expand Down Expand Up @@ -33,6 +33,11 @@
"types": "./vite.d.ts",
"import": "./vite.js",
"require": "./vite.cjs"
},
"./modules": {
"types": "./modules.d.ts",
"import": "./modules.js",
"require": "./modules.cjs"
}
},
"dependencies": {
Expand All @@ -42,6 +47,7 @@
"esbuild-plugin-noexternal": "^0.1.4",
"magic-string": "^0.27.0",
"postcss": "^8.4.19",
"postcss-modules": "^6.0.0",
"postcss-nested": "^6.0.0",
"postcss-scss": "^4.0.6"
},
Expand Down
2 changes: 1 addition & 1 deletion package/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Options } from 'tsup';

export default <Options>{
entryPoints: ['index.ts', 'vite.ts'],
entryPoints: ['index.ts', 'vite.ts', 'modules.ts'],
clean: false,
format: ['cjs', 'esm'],
dts: true,
Expand Down
102 changes: 69 additions & 33 deletions package/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import externalizeAllPackagesExcept from 'esbuild-plugin-noexternal';
import MagicString from 'magic-string';
import path from 'path';
import postcss from 'postcss';
import postcssModules from 'postcss-modules';
import postcssNested from 'postcss-nested';
import postcssScss from 'postcss-scss';
import { ancestor as walk } from 'acorn-walk';
Expand Down Expand Up @@ -107,6 +108,7 @@ export function ecsstatic(options: Options = {}) {
for (const node of cssTemplateLiterals) {
const { start, end, quasi, tag, _originalName } = node;
const isScss = tag.type === 'Identifier' && ecsstaticImports.get(tag.name)?.isScss;
const isModule = tag.type === 'Identifier' && ecsstaticImports.get(tag.name)?.isModule;

// lazy populate inlinedVars until we need it, to delay problems that come with this mess
if (quasi.expressions.length && !inlinedVars) {
Expand All @@ -117,23 +119,30 @@ export function ecsstatic(options: Options = {}) {
const templateContents = quasi.expressions.length
? await processTemplateLiteral(rawTemplate, { inlinedVars })
: rawTemplate.slice(1, rawTemplate.length - 2);
const [css, className] = processCss(templateContents, isScss);

// do the scoping!
const [css, modulesOrClass] = await processCss(templateContents, { isScss, isModule });

let returnValue = ''; // what we will replace the tagged template literal with
if (isModule) {
returnValue = JSON.stringify(modulesOrClass);
} else {
returnValue = `"${modulesOrClass}"`;
// add the original variable name in DEV mode
if (_originalName && viteConfigObj.command === 'serve') {
returnValue = `"🎈-${_originalName} ${modulesOrClass}"`;
}
}

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

// add the original variable name in DEV mode
let _className = `"${className}"`;
if (_originalName && viteConfigObj.command === 'serve') {
_className = `"🎈-${_originalName} ${className}"`;
}

// replace the tagged template literal with the generated className
magicCode.update(start, end, _className);
// replace the tagged template literal with the generated class names
magicCode.update(start, end, returnValue);
}

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

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

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

const className = `🎈-${hash(templateContents.trim())}`;
const unprocessedCss = `${importsAndUses}\n.${className}{${codeWithoutImportsAndUses}}`;
const unprocessedCss = isModule
? templateContents
: `${importsAndUses}\n.${className}{${codeWithoutImportsAndUses}}`;

const plugins = !isScss
? [postcssNested(), autoprefixer(autoprefixerOptions)]
: [autoprefixer(autoprefixerOptions)];
const options = isScss ? { parser: postcssScss } : {};
const { css } = postcss(plugins).process(unprocessedCss, options);
const { css, modules } = await postprocessCss(unprocessedCss, { isScss, isModule });

if (isModule) {
return [css, modules] as const;
}

return [css, className];
return [css, className] as const;
}

/** runs postcss with autoprefixer and optionally css-modules */
async function postprocessCss(rawCss: string, { isScss = false, isModule = false }) {
let modules: Record<string, string> = {};

const plugins = [
!isScss && postcssNested(),
autoprefixer(autoprefixerOptions),
isModule &&
postcssModules({
generateScopedName: '🎈-[local]-[hash:base64:6]',
getJSON: (_, json) => void (modules = json),
}),
].flatMap((value) => (value ? [value] : []));

const options = isScss ? { parser: postcssScss, from: undefined } : { from: undefined };
const { css } = await postcss(plugins).process(rawCss, options);

return { css, modules };
}

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

/** parses ast and returns info about all css/scss ecsstatic imports */
function findEcsstaticImports(ast: ESTree.Program) {
const statements = new Map<string, { isScss: boolean; start: number; end: number }>();
const statements = new Map<
string,
{ isScss: boolean; isModule: boolean; start: number; end: number }
>();

for (const node of ast.body.filter((node) => node.type === 'ImportDeclaration')) {
if (
node.type === 'ImportDeclaration' &&
node.source.value?.toString().startsWith('@acab/ecsstatic')
) {
const isModule = node.source.value?.toString().endsWith('modules');
const { start, end } = node;
node.specifiers.forEach((specifier) => {
if (
Expand All @@ -205,7 +237,7 @@ function findEcsstaticImports(ast: ESTree.Program) {
) {
const tagName = specifier.local.name;
const isScss = specifier.imported.name === 'scss';
statements.set(tagName, { isScss, start, end });
statements.set(tagName, { isScss, isModule, start, end });
}
});
}
Expand Down Expand Up @@ -316,25 +348,29 @@ function findCssTaggedTemplateLiterals(ast: ESTree.Program, tagNames: string[])
function loadDummyEcsstatic() {
const hashStr = hash.toString();
const getHashFromTemplateStr = getHashFromTemplate.toString();
const contents = `${hashStr}\n${getHashFromTemplateStr}\n
const indexContents = `${hashStr}\n${getHashFromTemplateStr}\n
export const css = getHashFromTemplate;
export const scss = getHashFromTemplate;
`;
const modulesContents = `new Proxy({}, {
get() { throw 'please don't do this. css modules are hard to evaluate inside other strings :(' }
})`;

return <esbuild.Plugin>{
name: 'load-dummy-ecsstatic',
setup(build) {
build.onResolve({ filter: /^@acab\/ecsstatic$/ }, (args) => {
return {
namespace: 'ecsstatic',
path: args.path,
};
return { namespace: 'ecsstatic', path: args.path };
});
build.onLoad({ filter: /(.*)/, namespace: 'ecsstatic' }, () => {
return {
contents,
loader: 'js',
};
return { contents: indexContents, loader: 'js' };
});

build.onResolve({ filter: /^@acab\/ecsstatic\/modules$/ }, (args) => {
return { namespace: 'ecsstatic-modules', path: args.path };
});
build.onLoad({ filter: /(.*)/, namespace: 'ecsstatic-modules' }, () => {
return { contents: modulesContents, loader: 'js' };
});
},
};
Expand Down
Loading