From 75152902e905bba188580401ec47e565acecce72 Mon Sep 17 00:00:00 2001 From: Mayank Date: Thu, 26 Jan 2023 20:55:37 -0500 Subject: [PATCH] add css-modules support! --- demos/vite-react/src/App.tsx | 22 ++++---- package/modules.ts | 67 +++++++++++++++++++++++ package/package.json | 8 ++- package/tsup.config.ts | 2 +- package/vite.ts | 102 +++++++++++++++++++++++------------ pnpm-lock.yaml | 87 ++++++++++++++++++++++++++++++ 6 files changed, 243 insertions(+), 45 deletions(-) create mode 100644 package/modules.ts diff --git a/demos/vite-react/src/App.tsx b/demos/vite-react/src/App.tsx index 1f9bc11..13b0cbf 100644 --- a/demos/vite-react/src/App.tsx +++ b/demos/vite-react/src/App.tsx @@ -1,5 +1,5 @@ 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'; @@ -7,22 +7,24 @@ export const App = () => { const [count, setCount] = useState(0); return ( -
+

- Edit any .tsx file to test HMR + Edit any .tsx file to test HMR

); }; -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; + } `; diff --git a/package/modules.ts b/package/modules.ts new file mode 100644 index 0000000..79d96d1 --- /dev/null +++ b/package/modules.ts @@ -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 () => ( + *
+ * + *
+ * ); + */ +export function css( + templates: TemplateStringsArray, + ...args: Array +): Record { + 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 () => ( + *
+ * + *
+ * ); + */ +export function scss( + templates: TemplateStringsArray, + ...args: Array +): Record { + throw new Error( + `If you're seeing this error, it is likely your bundler isn't configured correctly.` + ); +} diff --git a/package/package.json b/package/package.json index 07d6b88..58749c5 100644 --- a/package/package.json +++ b/package/package.json @@ -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", @@ -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": { @@ -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" }, diff --git a/package/tsup.config.ts b/package/tsup.config.ts index 743a97f..a54beab 100644 --- a/package/tsup.config.ts +++ b/package/tsup.config.ts @@ -1,7 +1,7 @@ import type { Options } from 'tsup'; export default { - entryPoints: ['index.ts', 'vite.ts'], + entryPoints: ['index.ts', 'vite.ts', 'modules.ts'], clean: false, format: ['cjs', 'esm'], dts: true, diff --git a/package/vite.ts b/package/vite.ts index 2cbbe9b..7d5f492 100644 --- a/package/vite.ts +++ b/package/vite.ts @@ -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'; @@ -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) { @@ -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 @@ -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'); @@ -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 = {}; + + 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 */ @@ -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(); + 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 ( @@ -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 }); } }); } @@ -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 { 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' }; }); }, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e66782..de3e91d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,6 +139,7 @@ importers: 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 tsup: ^6.5.0 @@ -150,6 +151,7 @@ importers: esbuild-plugin-noexternal: 0.1.4 magic-string: 0.27.0 postcss: 8.4.21 + postcss-modules: 6.0.0_postcss@8.4.21 postcss-nested: 6.0.0_postcss@8.4.21 postcss-scss: 4.0.6_postcss@8.4.21 devDependencies: @@ -2406,6 +2408,12 @@ packages: /function-bind/1.1.1: resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + /generic-names/4.0.0: + resolution: {integrity: sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A==} + dependencies: + loader-utils: 3.2.1 + dev: false + /gensync/1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -2611,6 +2619,15 @@ packages: engines: {node: '>=12.20.0'} dev: false + /icss-utils/5.1.0_postcss@8.4.21: + resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + dependencies: + postcss: 8.4.21 + dev: false + /ieee754/1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} dev: false @@ -2822,6 +2839,11 @@ packages: strip-bom: 3.0.0 dev: false + /loader-utils/3.2.1: + resolution: {integrity: sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==} + engines: {node: '>= 12.13.0'} + dev: false + /locate-path/3.0.0: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} @@ -2844,6 +2866,10 @@ packages: p-locate: 5.0.0 dev: false + /lodash.camelcase/4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + dev: false + /lodash.sortby/4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} dev: true @@ -3555,6 +3581,63 @@ packages: yaml: 1.10.2 dev: true + /postcss-modules-extract-imports/3.0.0_postcss@8.4.21: + resolution: {integrity: sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + dependencies: + postcss: 8.4.21 + dev: false + + /postcss-modules-local-by-default/4.0.0_postcss@8.4.21: + resolution: {integrity: sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + dependencies: + icss-utils: 5.1.0_postcss@8.4.21 + postcss: 8.4.21 + postcss-selector-parser: 6.0.10 + postcss-value-parser: 4.2.0 + dev: false + + /postcss-modules-scope/3.0.0_postcss@8.4.21: + resolution: {integrity: sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + dependencies: + postcss: 8.4.21 + postcss-selector-parser: 6.0.10 + dev: false + + /postcss-modules-values/4.0.0_postcss@8.4.21: + resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + dependencies: + icss-utils: 5.1.0_postcss@8.4.21 + postcss: 8.4.21 + dev: false + + /postcss-modules/6.0.0_postcss@8.4.21: + resolution: {integrity: sha512-7DGfnlyi/ju82BRzTIjWS5C4Tafmzl3R79YP/PASiocj+aa6yYphHhhKUOEoXQToId5rgyFgJ88+ccOUydjBXQ==} + peerDependencies: + postcss: ^8.0.0 + dependencies: + generic-names: 4.0.0 + icss-utils: 5.1.0_postcss@8.4.21 + lodash.camelcase: 4.3.0 + postcss: 8.4.21 + postcss-modules-extract-imports: 3.0.0_postcss@8.4.21 + postcss-modules-local-by-default: 4.0.0_postcss@8.4.21 + postcss-modules-scope: 3.0.0_postcss@8.4.21 + postcss-modules-values: 4.0.0_postcss@8.4.21 + string-hash: 1.1.3 + dev: false + /postcss-nested/6.0.0_postcss@8.4.21: resolution: {integrity: sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==} engines: {node: '>=12.0'} @@ -3999,6 +4082,10 @@ packages: engines: {node: '>=10.0.0'} dev: false + /string-hash/1.1.3: + resolution: {integrity: sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==} + dev: false + /string-width/4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'}