diff --git a/README.md b/README.md index e91530a..d4e685e 100644 --- a/README.md +++ b/README.md @@ -376,6 +376,42 @@ In order for `quietDeps` to correctly identify external dependencies the `url` o > The `url` option creates problems when importing source SASS files from 3rd party modules in which case the best workaround is to avoid `quietDeps` and [mute the logger](https://sass-lang.com/documentation/js-api/interfaces/StringOptionsWithImporter#logger) if that's a big issue. +### namedExports + +Type: `boolean` `function`
+Default: `false` + +Use named exports alongside default export. + +You can supply a function to control how exported named is generated: + +```js +namedExports(name) { + // Maybe you simply want to convert dash to underscore + return name.replace(/-/g, '_') +} +``` + +If you set it to `true`, the following will happen when importing specific classNames: + +- dashed class names will be transformed by replacing all the dashes to `$` sign wrapped underlines, eg. `--` => `$__$` +- js protected names used as your style class names, will be transformed by wrapping the names between `$` signs, eg. `switch` => `$switch$` + +All transformed names will be logged in your terminal like: + +```bash +Exported "new" as "$new$" in test/fixtures/named-exports/style.css +``` + +The original will not be removed, it's still available on `default` export: + +```js +import style, { class$_$name, class$__$name, $switch$ } from './style.css' +console.log(style['class-name'] === class$_$name) // true +console.log(style['class--name'] === class$__$name) // true +console.log(style['switch'] === $switch$) // true +``` + ### pnpm There's a working example of using `pnpm` with `@material` design diff --git a/package.json b/package.json index f557ec1..eff1a8a 100755 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ }, "dependencies": { "resolve": "^1.22.8", + "safe-identifier": "^0.4.2", "sass": "^1.71.1" }, "devDependencies": { @@ -52,10 +53,10 @@ "postcss": "^8.4.35", "postcss-modules": "^6.0.0", "postcss-url": "^10.1.3", + "sass-embedded": "^1.71.1", "source-map": "^0.7.4", "ts-node": "^10.9.2", - "typescript": "^5.3.3", - "sass-embedded": "^1.71.1" + "typescript": "^5.3.3" }, "peerDependencies": { "esbuild": ">=0.20.1", diff --git a/src/index.ts b/src/index.ts index f0b38ce..14f2ae6 100755 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import {StringOptions} from 'sass' import {sassPlugin} from './plugin' export type Type = 'css' | 'local-css' | 'style' | 'css-text' | 'lit-css' | ((cssText: string, nonce?: string) => string) +export type NamedExport = boolean | ((name: string) => string); export type SassPluginOptions = StringOptions<'sync'|'async'> & { @@ -81,6 +82,11 @@ export type SassPluginOptions = StringOptions<'sync'|'async'> & { * To enable the sass-embedded compiler */ embedded?: boolean + + /** + * Use named exports alongside default export. + */ + namedExports?: NamedExport; } export default sassPlugin diff --git a/src/plugin.ts b/src/plugin.ts index 5f1b57e..daf85ba 100755 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,9 +1,9 @@ -import {OnLoadResult, Plugin} from 'esbuild' -import {dirname} from 'path' -import {SassPluginOptions} from './index' -import {getContext, makeModule, modulesPaths, parseNonce, posixRelative, DEFAULT_FILTER} from './utils' -import {useCache} from './cache' -import {createRenderer} from './render' +import { OnLoadResult, Plugin } from 'esbuild' +import { dirname } from 'path' +import { SassPluginOptions } from './index' +import { getContext, makeModule, modulesPaths, parseNonce, posixRelative, DEFAULT_FILTER, ensureClassName } from './utils' +import { useCache } from './cache' +import { createRenderer } from './render' /** * @@ -29,7 +29,7 @@ export function sassPlugin(options: SassPluginOptions = {}): Plugin { return { name: 'sass-plugin', - async setup({initialOptions, onResolve, onLoad, resolve, onStart, onDispose}) { + async setup({ initialOptions, onResolve, onLoad, resolve, onStart, onDispose }) { options.loadPaths = Array.from(new Set([ ...options.loadPaths || modulesPaths(initialOptions.absWorkingDir), @@ -42,8 +42,8 @@ export function sassPlugin(options: SassPluginOptions = {}): Plugin { } = getContext(initialOptions) if (options.cssImports) { - onResolve({filter: /^~.*\.css$/}, ({path, importer, resolveDir}) => { - return resolve(path.slice(1), {importer, resolveDir, kind: 'import-rule'}) + onResolve({ filter: /^~.*\.css$/ }, ({ path, importer, resolveDir }) => { + return resolve(path.slice(1), { importer, resolveDir, kind: 'import-rule' }) }) } @@ -57,13 +57,13 @@ export function sassPlugin(options: SassPluginOptions = {}): Plugin { if (transform) { const namespace = 'esbuild-sass-plugin' - onResolve({filter: /^css-chunk:/}, ({path, resolveDir}) => ({ + onResolve({ filter: /^css-chunk:/ }, ({ path, resolveDir }) => ({ path, namespace, - pluginData: {resolveDir} + pluginData: { resolveDir } })) - onLoad({filter: /./, namespace}, ({path, pluginData: {resolveDir}}) => ({ + onLoad({ filter: /./, namespace }, ({ path, pluginData: { resolveDir } }) => ({ contents: cssChunks[path], resolveDir, loader: 'css' @@ -72,9 +72,9 @@ export function sassPlugin(options: SassPluginOptions = {}): Plugin { const renderSass = await createRenderer(options, options.sourceMap ?? sourcemap, onDispose) - onLoad({filter: options.filter ?? DEFAULT_FILTER}, useCache(options, fsStatCache, async path => { + onLoad({ filter: options.filter ?? DEFAULT_FILTER }, useCache(options, fsStatCache, async path => { try { - let {cssText, watchFiles, warnings} = await renderSass(path) + let { cssText, watchFiles, warnings } = await renderSass(path) if (!warnings) { warnings = [] } @@ -94,7 +94,7 @@ export function sassPlugin(options: SassPluginOptions = {}): Plugin { watchDirs: out.watchDirs || [] } } - let {contents, pluginData} = out + let { contents, pluginData } = out if (type === 'css') { let name = posixRelative(path) cssChunks[name] = contents @@ -103,11 +103,27 @@ export function sassPlugin(options: SassPluginOptions = {}): Plugin { contents = makeModule(String(contents), 'style', nonce) } else { return { - errors: [{text: `unsupported type '${type}' for postCSS modules`}] + errors: [{ text: `unsupported type '${type}' for postCSS modules` }] } } + + let exportConstants = ""; + if (options.namedExports && pluginData.exports) { + const json = JSON.parse(pluginData.exports); + const getClassName = + typeof options.namedExports === "function" + ? options.namedExports + : ensureClassName; + Object.keys(json).forEach((name) => { + const newName = getClassName(name); + exportConstants += `export const ${newName} = ${JSON.stringify( + json[name] + )};\n`; + }); + } + return { - contents: `${contents}export default ${pluginData.exports};`, + contents: `${contents}${exportConstants}export default ${pluginData.exports};`, loader: 'js', resolveDir, watchFiles: [...watchFiles, ...(out.watchFiles || [])], @@ -134,7 +150,7 @@ export function sassPlugin(options: SassPluginOptions = {}): Plugin { } catch (err: any) { return { - errors: [{text: err.message}], + errors: [{ text: err.message }], watchFiles: watched[path] ?? [path] } } diff --git a/src/utils.ts b/src/utils.ts index 1cccdc3..e5e0743 100755 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,11 +1,12 @@ -import {SassPluginOptions, Type} from './index' -import {AcceptedPlugin, Postcss} from 'postcss' +import { SassPluginOptions, Type } from './index' +import { AcceptedPlugin, Postcss } from 'postcss' import PostcssModulesPlugin from 'postcss-modules' -import {BuildOptions, OnLoadResult} from 'esbuild' -import {Syntax} from 'sass' -import {parse, relative, resolve} from 'path' -import {existsSync} from 'fs' -import {SyncOpts} from 'resolve' +import { BuildOptions, OnLoadResult } from 'esbuild' +import { Syntax } from 'sass' +import { parse, relative, resolve } from 'path' +import { existsSync } from 'fs' +import { SyncOpts } from 'resolve' +import { identifier } from 'safe-identifier' const cwd = process.cwd() @@ -17,7 +18,7 @@ export const posixRelative = require('path').sep === '/' export function modulesPaths(absWorkingDir?: string): string[] { let path = absWorkingDir || process.cwd() - let {root} = parse(path) + let { root } = parse(path) let found: string[] = [] while (path !== root) { const filename = resolve(path, 'node_modules') @@ -77,11 +78,11 @@ function requireTool(module: string, basedir?: string) { } catch (ignored) { } if (basedir) try { - return require(require.resolve(module, {paths: [basedir]})) + return require(require.resolve(module, { paths: [basedir] })) } catch (ignored) { } try { - return require(require.resolve(module, {paths: [process.cwd()]})) + return require(require.resolve(module, { paths: [process.cwd()] })) } catch (e) { try { return require(module) // extra attempt at finding a co-located tool @@ -116,7 +117,7 @@ document.head export {css}; ` -export function makeModule(contents: string, type: Type, nonce?: string):string { +export function makeModule(contents: string, type: Type, nonce?: string): string { switch (type) { case 'style': return styleModule(contents, nonce) @@ -157,7 +158,7 @@ export function postcssModules(options: PostcssModulesParams, plugins: AcceptedP let cssModule - const {css} = await postcss([ + const { css } = await postcss([ postcssModulesPlugin({ ...(options as Parameters[0]), getJSON(cssFilename: string, json: { [name: string]: string }, outputFileName?: string): void { @@ -166,11 +167,11 @@ export function postcssModules(options: PostcssModulesParams, plugins: AcceptedP } }), ...plugins - ]).process(source, {from: path, map: false}) + ]).process(source, { from: path, map: false }) return { contents: css, - pluginData: {exports: cssModule}, + pluginData: { exports: cssModule }, loader: 'js' } } @@ -187,7 +188,7 @@ export function createResolver(options: SassPluginOptions = {}, loadPaths: strin let cached = cache[pkgfile] if (!cached) { const pkg = JSON.parse(readFileSync(pkgfile) as string) - cached = cache[pkgfile] = {main: pkg[prefer] || pkg.main} + cached = cache[pkgfile] = { main: pkg[prefer] || pkg.main } } return cached } @@ -226,3 +227,10 @@ export function createResolver(options: SassPluginOptions = {}, loadPaths: strin } } } + +const escapeClassNameDashes = (name: string) => + name.replace(/-+/g, (match) => `$${match.replace(/-/g, "_")}$`); +export const ensureClassName = (name: string) => { + const escaped = escapeClassNameDashes(name); + return identifier(escaped); +}; \ No newline at end of file diff --git a/test/e2e.test.ts b/test/e2e.test.ts index bdbaa28..dd16503 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -1,7 +1,7 @@ import * as esbuild from 'esbuild' -import {postcssModules, sassPlugin} from '../src' -import {readFileSync, statSync} from 'fs' -import {consumeSourceMap, readCssFile, readJsonFile, readTextFile, useFixture} from './test-toolkit' +import { postcssModules, sassPlugin } from '../src' +import { readFileSync, statSync } from 'fs' +import { consumeSourceMap, readCssFile, readJsonFile, readTextFile, useFixture } from './test-toolkit' describe('e2e tests', function () { @@ -66,7 +66,7 @@ describe('e2e tests', function () { sourceMap.sources = sourceMap.sources.map(relativeToNodeModules) - const {sources} = JSON.parse(readFileSync('../node_modules/bootstrap/dist/css/bootstrap.css.map', 'utf8')) + const { sources } = JSON.parse(readFileSync('../node_modules/bootstrap/dist/css/bootstrap.css.map', 'utf8')) expect(sourceMap.sources.slice(0, -1).map(entry => entry.replace('../../node_modules/bootstrap/', ''))) .to.include.members(sources.slice(1).map(entry => entry.replace('../../', ''))) @@ -96,7 +96,7 @@ describe('e2e tests', function () { }) expect( - consumer.originalPositionFor({line: 10286, column: 0}) + consumer.originalPositionFor({ line: 10286, column: 0 }) ).to.eql({ source: `../src/entrypoint.scss`, line: 3, @@ -114,7 +114,7 @@ describe('e2e tests', function () { entryPoints: ['./src/index.tsx'], outdir: './out', bundle: true, - define: {'process.env.NODE_ENV': '"development"'}, + define: { 'process.env.NODE_ENV': '"development"' }, plugins: [ sassPlugin() ] @@ -211,12 +211,12 @@ describe('e2e tests', function () { it('post-css', async function () { const options = useFixture('post-css') - const resolveOptions = {paths: [options.absWorkingDir!]} + const resolveOptions = { paths: [options.absWorkingDir!] } const postcss = require(require.resolve('postcss', resolveOptions)) const autoprefixer = require(require.resolve('autoprefixer', resolveOptions)) const postcssPresetEnv = require(require.resolve('postcss-preset-env', resolveOptions)) - const postCSS = postcss([autoprefixer, postcssPresetEnv({stage: 0})]) + const postCSS = postcss([autoprefixer, postcssPresetEnv({ stage: 0 })]) await esbuild.build({ ...options, @@ -228,7 +228,7 @@ describe('e2e tests', function () { }, plugins: [sassPlugin({ async transform(source) { - const {css} = await postCSS.process(source, {from: undefined}) + const { css } = await postCSS.process(source, { from: undefined }) return css } })] @@ -261,7 +261,7 @@ describe('e2e tests', function () { }) }) ] - }) + }); const bundle = readTextFile('./out/index.js') @@ -280,6 +280,36 @@ describe('e2e tests', function () { `) }) + it('named exports', async function () { + const options = useFixture('named-exports') + + await esbuild.build({ + ...options, + entryPoints: ['./src/index.js'], + outdir: './out', + bundle: true, + format: 'esm', + plugins: [ + sassPlugin({ + transform: postcssModules({ + localsConvention: 'camelCaseOnly' + }), + namedExports: (name) => { + return `${name.replace(/-/g, "_")}`; + }, + }) + ] + }); + + const bundle = readTextFile('./out/index.js') + + expect(bundle).to.containIgnoreSpaces('class="${message} ${message2}') + + expect(bundle).to.containIgnoreSpaces(`var message = "_message_1vmzm_1"`) + + expect(bundle).to.containIgnoreSpaces(`var message2 = "_message_bxgcs_1";`) + }) + it('css modules & lit-element together', async function () { const options = useFixture('multiple') @@ -419,7 +449,7 @@ describe('e2e tests', function () { plugins: [sassPlugin({ type: 'lit-css', async transform(css, resolveDir) { - const {outputFiles: [out]} = await esbuild.build({ + const { outputFiles: [out] } = await esbuild.build({ stdin: { contents: css, resolveDir, @@ -461,7 +491,7 @@ describe('e2e tests', function () { outdir: './out', bundle: true, plugins: [ - sassPlugin({type: 'css-text'}), + sassPlugin({ type: 'css-text' }), { name: 'counter', setup(build) { @@ -478,7 +508,7 @@ describe('e2e tests', function () { expect(readTextFile('./out/index.js')).to.match(/crimson/) - let {mtimeMs} = statSync('./out/index.js') + let { mtimeMs } = statSync('./out/index.js') await new Promise((resolve, reject) => { const timeout = setTimeout(reject, 10000) setTimeout(function tryAgain() { @@ -504,7 +534,7 @@ describe('e2e tests', function () { const env = readTextFile('./env.scss') - const context = {blue: 'blue'} + const context = { blue: 'blue' } await esbuild.build({ ...options, diff --git a/test/fixture/named-exports/build.js b/test/fixture/named-exports/build.js new file mode 100644 index 0000000..f46aca6 --- /dev/null +++ b/test/fixture/named-exports/build.js @@ -0,0 +1,23 @@ +const esbuild = require('esbuild') +const {sassPlugin, postcssModules} = require('../../../lib') +const {cleanFixture, logSuccess, logFailure} = require('../utils') + +cleanFixture(__dirname) + +esbuild.build({ + entryPoints: ['./src/index.js'], + outdir: './out', + bundle: true, + format: 'esm', + plugins: [ + sassPlugin({ + transform: postcssModules({ + generateScopedName: '[hash:base64:8]--[local]', + localsConvention: 'camelCaseOnly' + }), + namedExports: (name) => { + return `${name.replace(/-/g, "_")}`; + }, + }) + ] +}).then(logSuccess, logFailure) \ No newline at end of file diff --git a/test/fixture/named-exports/index.html b/test/fixture/named-exports/index.html new file mode 100644 index 0000000..e3ed70a --- /dev/null +++ b/test/fixture/named-exports/index.html @@ -0,0 +1,11 @@ + + + + + Bootstrap Example + + + + + + \ No newline at end of file diff --git a/test/fixture/named-exports/package.json b/test/fixture/named-exports/package.json new file mode 100644 index 0000000..847492a --- /dev/null +++ b/test/fixture/named-exports/package.json @@ -0,0 +1,14 @@ +{ + "name": "css-modules-fixture", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "autoprefixer": "^10.4.12", + "postcss": "^8.4.18", + "postcss-modules": "^5.0.0" + }, + "scripts": { + "build": "node ./build", + "serve": "node ../serve css-modules" + } +} diff --git a/test/fixture/named-exports/src/common.module.scss b/test/fixture/named-exports/src/common.module.scss new file mode 100644 index 0000000..2825bde --- /dev/null +++ b/test/fixture/named-exports/src/common.module.scss @@ -0,0 +1,3 @@ +.message { + font-family: Roboto, sans-serif; +} \ No newline at end of file diff --git a/test/fixture/named-exports/src/example.module.scss b/test/fixture/named-exports/src/example.module.scss new file mode 100644 index 0000000..826dd30 --- /dev/null +++ b/test/fixture/named-exports/src/example.module.scss @@ -0,0 +1,5 @@ +.message { + color: white; + background-color: red; + font-size: 24px; +} \ No newline at end of file diff --git a/test/fixture/named-exports/src/index.js b/test/fixture/named-exports/src/index.js new file mode 100644 index 0000000..ec5e3a8 --- /dev/null +++ b/test/fixture/named-exports/src/index.js @@ -0,0 +1,7 @@ +import { message as stylesMessage } from "./example.module.scss"; +import { message as commonMessage } from "./common.module.scss"; + +document.body.insertAdjacentHTML( + "afterbegin", + `
Hello World
`, +); diff --git a/yarn.lock b/yarn.lock index db1b772..c80c8ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1524,6 +1524,11 @@ safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2: resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +safe-identifier@^0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/safe-identifier/-/safe-identifier-0.4.2.tgz#cf6bfca31c2897c588092d1750d30ef501d59fcb" + integrity sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w== + safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz"