Skip to content

Commit

Permalink
Merge pull request #129 from glromeo/css-chunks
Browse files Browse the repository at this point in the history
Css chunks
  • Loading branch information
glromeo authored Mar 20, 2023
2 parents 3db4358 + ef2fbc2 commit 58bc668
Show file tree
Hide file tree
Showing 177 changed files with 63,431 additions and 216 deletions.
120 changes: 88 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,94 @@ await esbuild.build({
plugins: [sassPlugin()]
})
```
There are two main options that control the plugin: `filter` which has the same meaning of filter in esbuild
[onLoad](https://esbuild.github.io/plugins/#on-load) and `type` that's what specifies how the css should be
rendered and imported.

The example above uses the default type `css` and will use esbuild CSS loader so your transpiled Sass
### Options

You can pass a series of **options** to the plugin that are a superset of Sass
[compile string options](https://sass-lang.com/documentation/js-api/interfaces/StringOptionsWithImporter). \
The following are the options specific to the plugin with their defaults whether provided:

| Option | Type | Default |
|---------------|---------------------------------------------------------|-----------------------------------------|
| filter | regular expression (in Go syntax) | <code>/\.(s[ac]ss&vert;css)$/</code> |
| exclude | regular expression (in Js syntax) | |
| external | regular expression (in Go syntax) | |
| type | `"css"`<br/>`"style"`<br/>`"lit-css"`<br/>`"css-text"` | `"css"` |
| cache | boolean or Map | `true` (there is one Map per namespace) |
| transform | function | |
| [loadPaths](https://sass-lang.com/documentation/js-api/interfaces/Options#loadPaths) | string[] | [] |
| precompile | function | |
| importMapper | function | |
| cssImports | boolean | false |
| nonce | string | |
| prefer | string | preferred package.json field |
| quietDeps | boolean | false |

Two main options control the plugin: `filter` which has the same meaning of filter in [esbuild](https://esbuild.github.io/plugins/#on-load)
allowing to select the URLs handled by a plugin instance and then `type` that's what specifies how the css should be rendered and imported.

### `filter`
The default filter is quite simple but also quite permissive. When specifying a custom regex bear in mind that this
is in [Go syntax](https://pkg.go.dev/regexp/syntax)

> e.g. If you have URLs in your imports and you want the plugin to ignore them you can't just a filter expression like:
`/^(?!https?:).*\.(s[ac]ss|css)$/` because in Go the regex engine doesn't support lookarounds.

You can try to list multiple plugin instances in order so that the most specific RegEx come first:
```javascript
await esbuild.build({
...
plugins: [
sassPlugin({
filter: /\.module\.scss$/,
transform: postcssModules()
}),
sassPlugin({
filter: /\.scss$/
}),
],
...
})
```

But for cases in which this won't work there's the `exclude` RegEx.

### `exclude`

This is a [Javascript RegEx](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Cheatsheet),
the URLs matching it will be ignored by the plugin and passed on to the next plugins configured.

This means that these URLs have to be handled by some other plugin, if you want them to be ignored and left unresolved
you can use a [solution like this](https://esbuild.github.io/plugins/#on-resolve) which is quite verbose so to make
life easier for the users of this plugin it is available as `external` option.

e.g. This way:
```javascript
await esbuild.build({
...
plugins: [
sassPlugin({
external: /^https?:.*\.css$/
}),
],
...
})
```
...all http `css` URLs are marked as external.


### `type`

The example in [Usage](#usage) uses the default type `css` and will use esbuild CSS loader so your transpiled Sass
will be in `index.css` alongside your bundle.

In all other cases `esbuild` won't process the CSS content which instead will be handled by the plugin.
> if you want `url()` resolution or other processing you have to use `postcss` like in [this example](https://github.com/glromeo/esbuild-sass-plugin/issues/92#issuecomment-1219209442)
**NOTE:** Since version `2.7.0` the `css` type works also with postcss, CSS modules and more in general
with any transformation function by keeping an internal cache of CSS chunks (virtual CSS files)
importing them in the module wrapping the contents

#### `type: "style"`
In this mode the stylesheet will be in the javascript bundle
and will be dynamically added to the page when the bundle is loaded.
Expand Down Expand Up @@ -102,34 +180,12 @@ export default class HelloWorld extends LitElement {
Look in `test/fixtures` folder for more usage examples.
### Options
The **options** passed to the plugin are a superset of Sass
[compile string options](https://sass-lang.com/documentation/js-api/interfaces/StringOptionsWithImporter).
| Option | Type | Default |
|------------------------------------------------------|---------------------------------------|-----------------------------------------|
| [filter](https://esbuild.github.io/plugins/#on-load) | regular expression | <code>/\.(s[ac]ss&vert;css)$/</code> |
| cache | boolean or Map | `true` (there is one Map per namespace) |
| type | `"css"`<br/>`"style"`<br/>`"lit-css"` <br/> `"css-text"` | `"css"` |
| transform | function | undefined |
| [loadPaths](https://sass-lang.com/documentation/js-api/interfaces/Options#loadPaths) | string[] | [] |
| precompile | function | undefined |
| importMapper | function | undefined |
| cssImports | boolean | false |
| nonce | string | undefined |
| prefer | string | preferred package.json field |
| quietDeps | boolean | false |
### What happened to `exclude` ?
the option has been removed in favour of using `filter`. The default filter is quite simple but also quite permissive.
If you have URLs in your imports and you want the plugin to ignore them you can just change the filter to something like:
```javascript
sassPlugin({
filter: /^(?!https?:).*\.(s[ac]ss|css)$/
...
})
```
### `cache`
The cache is enabled by default and can be turned off with `cache: false`.
Each plugin instance creates and maintain its own cache (as a Map) and this cache lives for the duration of the build.
If you want to pass a Map to preserve the cache amongst subsequent builds bear in mind that sharing the very same cache
between different instances might work just fine or it might lead to issues if the contents are incompatible.
> If you are not sure of what to do just keep a separate Map for each plugin instance.
### `cssImports`
when this is set to `true` the plugin rewrites the node-modules relative URLs startig with the `~` prefix so that
Expand Down
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "esbuild-sass-plugin",
"version": "2.6.0",
"version": "2.7.0",
"description": "esbuild plugin for sass/scss files supporting both css loader and css result import (lit-element)",
"main": "lib/index.js",
"keywords": [
Expand Down Expand Up @@ -39,6 +39,10 @@
"ts-node/register"
]
},
"dependencies": {
"sass": "^1.59.3",
"resolve": "^1.22.1"
},
"devDependencies": {
"@types/node": "^18.14.1",
"@types/resolve": "^1.20.2",
Expand All @@ -49,13 +53,9 @@
"source-map": "^0.7.4",
"ts-node": "^10.9.1",
"typescript": "^4.9.5",
"esbuild": "^0.17.10",
"resolve": "^1.22.1",
"sass": "^1.58.3"
"esbuild": "^0.17.12"
},
"peerDependencies": {
"esbuild": "^0.17.10",
"resolve": "^1.22.1",
"sass": "^1.58.3"
"esbuild": "^0.17.12"
}
}
34 changes: 20 additions & 14 deletions src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import {CachedResult, SassPluginOptions} from './index'
import {OnLoadArgs, OnLoadResult} from 'esbuild'
import {promises as fsp, Stats} from 'fs'

type OnLoadCallback = (args: OnLoadArgs) => (OnLoadResult | Promise<OnLoadResult>)
type PluginLoadCallback = (path: string) => (OnLoadResult | Promise<OnLoadResult>)
type OnLoadCallback = (args: OnLoadArgs) => (OnLoadResult | null | undefined | Promise<OnLoadResult | null | undefined>)
type PluginLoadCallback = (path: string) => (OnLoadResult | null | undefined | Promise<OnLoadResult | null | undefined>)

function collectStats(watchFiles): Promise<Stats[]> {
function collectStats(watchFiles:string[]): Promise<Stats[]>|[] {
return Promise.all(watchFiles.map(filename => fsp.stat(filename)))
}

Expand All @@ -30,22 +30,28 @@ export function useCache(options: SassPluginOptions = {}, loadCallback: PluginLo
try {
let cached = cache.get(path)
if (cached) {
let watchFiles = cached.result.watchFiles!
let stats = await collectStats(watchFiles)
for (const {mtimeMs} of stats) {
if (mtimeMs > cached.mtimeMs) {
cached.result = await loadCallback(watchFiles[0])
cached.mtimeMs = maxMtimeMs(stats)
break
let watchFiles = cached.result.watchFiles
if (watchFiles) {
let stats = await collectStats(watchFiles)
for (const {mtimeMs} of stats) {
if (mtimeMs > cached.mtimeMs) {
cached.result = (await loadCallback(watchFiles[0]))!
cached.mtimeMs = maxMtimeMs(stats)
break
}
}
}
} else {
let result = await loadCallback(path)
cached = {
mtimeMs: maxMtimeMs(await collectStats(result.watchFiles)),
result
if (result) {
cached = {
mtimeMs: maxMtimeMs(await collectStats(result.watchFiles ?? [])),
result
}
cache.set(path, cached)
} else {
return null;
}
cache.set(path, cached)
}
if (cached.result.errors) {
cache.delete(path)
Expand Down
14 changes: 12 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,20 @@ export type Type = 'css' | 'style' | 'css-text' | 'lit-css'
export type SassPluginOptions = StringOptions<'sync'> & {

/**
*
* Careful: this RegExp has to respect Go limitations!!!
*/
filter?: RegExp

/**
* This allows to further filter out to work around the limitations of filter
*/
exclude?: RegExp

/**
* The paths matching this (Go) regexp are marked as external (e.g. exclude: /^http:/)
*/
external?: RegExp

/**
* Function to transform import path. Not just paths by @import
* directive, but also paths imported by ts code.
Expand Down Expand Up @@ -55,7 +65,7 @@ export type SassPluginOptions = StringOptions<'sync'> & {
*
* @default undefined
*/
transform?: (css: string, resolveDir: string, filePath: string) => string | OnLoadResult | Promise<string | OnLoadResult>
transform?: (this: SassPluginOptions, css: string, resolveDir: string, filePath: string) => string | OnLoadResult | Promise<string | OnLoadResult>

/**
*
Expand Down
63 changes: 51 additions & 12 deletions src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {OnLoadResult, Plugin} from 'esbuild'
import {dirname} from 'path'
import {dirname, relative} from 'path'
import {SassPluginOptions} from './index'
import {getContext, makeModule, modulesPaths, parseNonce} from './utils'
import {useCache} from './cache'
Expand All @@ -22,12 +22,10 @@ export function sassPlugin(options: SassPluginOptions = {}): Plugin {
}

const type = options.type ?? 'css'

if (options['picomatch'] || options['exclude'] || typeof type !== 'string') {
console.log('The type array, exclude and picomatch options are no longer supported, please refer to the README for alternatives.')
}

const nonce = parseNonce(options.nonce)
const shouldExclude = options.exclude ? options.exclude.test.bind(options.exclude) : () => false

const cwd = process.cwd();

return {
name: 'sass-plugin',
Expand All @@ -43,17 +41,46 @@ export function sassPlugin(options: SassPluginOptions = {}): Plugin {
watched
} = getContext(initialOptions)

const renderSync = createRenderer(options, options.sourceMap ?? sourcemap)
const transform = options.transform ? options.transform.bind(options) : null
if (options.external) {
onResolve({filter: options.external}, args => {
return {path: args.path, external: true}
})
}

if (options.cssImports) {
onResolve({filter: /^~.*\.css$/}, ({path, importer, resolveDir}) => {
return resolve(path.slice(1), {importer, resolveDir, kind: 'import-rule'})
if (shouldExclude(path)) {
return null;
} else {
return resolve(path.slice(1), {importer, resolveDir, kind: 'import-rule'})
}
})
}

const transform = options.transform ? options.transform.bind(options) : null

const cssChunks:Record<string, string | Uint8Array | undefined> = {}

if (transform) {
const namespace = 'esbuild-sass-plugin';

onResolve({filter: /^css-chunk:/}, ({path})=>({
path,
namespace
}))

onLoad({filter: /./, namespace}, ({path})=>({
contents: cssChunks[path],
loader: 'css'
}))
}

const renderSync = createRenderer(options, options.sourceMap ?? sourcemap)

onLoad({filter: options.filter ?? DEFAULT_FILTER}, useCache(options, async path => {
try {
if (shouldExclude(path)) {
return null;
} else try {
let {cssText, watchFiles} = renderSync(path)

watched[path] = watchFiles
Expand All @@ -63,9 +90,21 @@ export function sassPlugin(options: SassPluginOptions = {}): Plugin {
if (transform) {
const out: string | OnLoadResult = await transform(cssText, resolveDir, path)
if (typeof out !== 'string') {
let {contents, pluginData} = out
if (type === "css") {
let name = `css-chunk:${relative(cwd, path)}`
cssChunks[name] = contents
contents = `import '${name}';`
} else if (type === "style") {
contents = makeModule(String(contents), 'style', nonce)
} else {
return {
errors: [{text: `unsupported type '${type}' for postCSS modules`}]
}
}
return {
contents: out.contents,
loader: out.loader,
contents: `${contents}export default ${pluginData.exports};`,
loader: 'js',
resolveDir,
watchFiles: [...watchFiles, ...(out.watchFiles || [])],
watchDirs: out.watchDirs || []
Expand Down
Loading

0 comments on commit 58bc668

Please sign in to comment.