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"