evanw / esbuild

An extremely fast bundler for the web
https://esbuild.github.io/
MIT License
37.91k stars 1.13k forks source link

[MVP] Bundle CSS modules #20

Closed evanw closed 1 year ago

evanw commented 4 years ago

I want esbuild to demonstrate that it's possible for a normal web development workflow to have high-performance tools. I consider bundling CSS modules a part of my initial MVP feature set for esbuild since some form of CSS modularity is essential for a large-scale web app and I find CSS modules an elegant solution. This issue tracks the CSS module bundling part of my MVP.

At the bare minimum, importing a CSS file should ensure it's included in the bundle. I plan to also implement CSS modules, which makes CSS selector names local to the file and allows JavaScript to import the selector names as strings.

There are interesting extensions to think about once I get everything working. Tree shaking of unused CSS is an optimization that seems worth exploring, for example. That should be possible with CSS modules since unused imports correspond to unused CSS.

eberkund commented 4 years ago

Would this include font files, or is that a separate task?

evanw commented 4 years ago

Binary resources that don't need to be parsed such as PNG, JPEG, and font files are all the same to a bundler. Fonts don't need to be special cased as far as I'm aware. I think the existing loaders such as dataurl and file should cover fonts.

evanw commented 3 years ago

I'm starting to work on CSS support. I just landed a basic parser and AST, and will be working on integrating it into the build system next.

Note that I'm not planning to support the full CSS ecosystem with this feature. Today's CSS is a very diverse ecosystem with many non-standard language variants. This is possible in part due to the fault-tolerant nature of CSS. I'm imagining this feature as mainly a "CSS linker" that is responsible for the critical parts of joining CSS files together and removing unused CSS code. The CSS parser will be expecting real CSS for use with browsers. Non-standard extensions may still be parsed without syntax errors but may be passed through unmodified (e.g. not properly minified or renamed) since they aren't fully understood by the parser. It should be possible to use syntax extensions (e.g. SASS) with a plugin that transforms the source code before esbuild reads it.

LarsDenBakker commented 3 years ago

@evanw that's great to hear! There are different kinds of CSS modules out there, and there is also ongoing for a web standard for CSS modules (see https://github.com/whatwg/html/pull/4898 and https://github.com/tc39/proposal-import-assertions). WIll esbuild allow specifying which variant to use?

nitsky commented 3 years ago

@evanw will the CSS feature inline the minified CSS in the javascript bundle or emit it as a separate file? While inlining is a good fit for client rendered applications, it is not ideal for server or static rendered applications. Ideally, esbuild would emit a number of CSS chunks, and the metafile would detail which CSS chunks correspond to each javascript entrypoint. A server or build tool would then be able to add references to the CSS for each page using <link> tags in the HTML it generates.

evanw commented 3 years ago

https://github.com/tc39/proposal-import-assertions

Import assertions seem unrelated to CSS to me. They just look like a way to tell the runtime what the file type is. Bundlers already have a way of doing this: they just use the file extension without needing an inline annotation, so this information is not necessary for bundlers.

https://github.com/whatwg/html/pull/4898

This seems like an API to inject normal CSS inside a shadow DOM element? It looks like it would just be normal CSS as far as the bundler is concerned.

The CSS module implementation I'm thinking of adopting is the original one: https://github.com/css-modules/css-modules. Specifically, the use of local-by-default names, the :local and :global selectors, the composes declarations, and being able to import local names as strings in JavaScript.

will the CSS feature inline the minified CSS in the javascript bundle or emit it as a separate file?

It will be in a separate file. I believe this is the current expected behavior. It's how Parcel works, for example. I was not considering inlining it in the JavaScript code. I am also planning on having code splitting work the same way across JavaScript and CSS files.

LarsDenBakker commented 3 years ago

I listed import assertions just to indicate that there will be a standard way to import non-JS files.

The idea for the standard CSS modules is that they will return an instance of CSSStyleSheet (https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet and https://developers.google.com/web/updates/2019/02/constructable-stylesheets). So a "polyfill" would instantiate it with the css text. Shadow roots have a nice API for using those stylesheets, but they're not necessarily coupled to it. Hope fully it will be adopted more broadly.

My main concern is about what's enabled by default in esbuild, so far most (all?) things require an explicit opt-in which I think is important.

evanw commented 3 years ago

The idea for the standard CSS modules is that they will return an instance of CSSStyleSheet (https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet and https://developers.google.com/web/updates/2019/02/constructable-stylesheets). So a "polyfill" would instantiate it with the css text. Shadow roots have a nice API for using those stylesheets, but they're not necessarily coupled to it. Hope fully it will be adopted more broadly.

Interesting, thanks for the clarification. FWIW I think this should be trivial to do with a plugin. That will let you experiment with this proposal.

My main concern is about what's enabled by default in esbuild, so far most (all?) things require an explicit opt-in which I think is important.

My current plan:

LarsDenBakker commented 3 years ago

Thanks, I think that default makes sense šŸ‘

nitsky commented 3 years ago

That behavior sounds ideal. Can't wait for it!

evanw commented 3 years ago

The newly-released version 0.7.7 now has experimental CSS support. From the release notes:

This release introduces the new css loader, enabled by default for .css files. It has the following features:

  • You can now use esbuild to process CSS files by passing a CSS file as an entry point. This means CSS is a new first-class file type and you can use it without involving any JavaScript code at all.

  • When bundling is enabled, esbuild will bundle multiple CSS files together if they are referenced using the @import "./file.css"; syntax. CSS files can be excluded from the bundle by marking them as external similar to JavaScript files.

  • There is basic support for pretty-printing CSS, and for whitespace removal when the --minify flag is present. There isn't any support for CSS syntax compression yet. Note that pretty-printing and whitespace removal both rely on the CSS syntax being recognized. Currently esbuild only recognizes certain CSS syntax and passes through unrecognized syntax unchanged.

Some things to keep in mind:

  • CSS support is a significant undertaking and this is the very first release. There are almost certainly going to be issues. This is an experimental release to land the code and get feedback.

  • There is no support for CSS modules yet. Right now all class names are in the global namespace. Importing a CSS file into a JavaScript file will not result in any import names.

  • There is currently no support for code splitting of CSS. I haven't tested multiple entry-point scenarios yet and code splitting will require additional changes to the AST format.

There's still a long way to go but this feels like a good point to publish what's there so far. It should already be useful for a limited set of use cases, and then I will expand use cases over time.

This feature will be especially useful with the addition of the plugin API (see issue #111) because then esbuild's bundler can be a "CSS linker" that runs on the output of whatever CSS post-processor you're using. The plugin API hasn't been released yet because I wanted to get basic CSS support in first so that there are at least two different core file types for the API to abstract over.

evanw commented 3 years ago

FYI for people following this issue: #415 was recently implemented making it possible to bundle url(...) references to images in CSS.

wessberg commented 3 years ago

Hey there, good work on this.

Just wanted to share my experience of trying this out. I was hoping that import styles from "./my-css-file.css" would yield a CSSStyleSheet that can be used in conjunction with Constructable Stylesheets, as @LarsDenBakker was also pointing out in this comment. As it stands right now, I'm actually not seeing how I might be able to retrieve the StyleSheet or a string representing it such that I can work with it (without implementing it in JS/TS).

I'm aware that there is a gazillion ways of handling styles on the web. I'm also aware that the expectations for how this might work varies greatly from web developer to web developer. I'm also aware that many tools do the module-returning-class-names thing, probably the vast majority. But I would argue strongly in favor of .css files becoming modules with a default export pointing to a CSSStyleSheet. For the following reasons:

Web developers who work with Shadow DOM are relying on being able to append stylesheets to a root at any point in the DOM tree, and won't benefit from, say, the imported styles being appended to the document root.

evanw commented 3 years ago

@wessberg Can you confirm if the plugin API lets you write a plugin to enable the CSSStyleSheet use case? I'm unfamiliar with the proposal but I imagine writing a plugin for this should be pretty simple if you just need to construct an object and return it.

The form of CSS modules where class names are local to the file and are exported to JavaScript seems better for building scalable web applications to me. That enables tree shaking and code splitting for CSS, which other solutions don't support AFAIK. Tree shaking of CSS isn't really possible to build as an esbuild plugin because it requires deep integration with the JavaScript layer. So I am still planning to explore this in esbuild itself.

wessberg commented 3 years ago

I will test it out and get back to you :-)

And again, while I realize some other bundlers are doing something similar to what you're planning for with CSS support in esbuild, I'm just seeing a pretty massive gap between what is being worked on in the standards bodies and what is being implemented or have already been implemented across some bundlers. This might lead to confusion when the native runtime does things rapidly different to bundlers.

Importantly, it leaves out everyone working with Shadow DOM where styles are applied at roots across the DOM tree rather than globally at the document root, and where styles are already local to the tree from the Shadow root they are being applied to. These users will either need to declare the styles in JS/TS and either inline them in a <style> tag appended to the root or manually generate CSSStyleSheets from them or write a custom plugin as suggested, and add it to adoptedStyleSheets for that root.

I think that the more "standards-compliant" (I put this in quotes, because standardization efforts are still ongoing here) approach should be default, and the more application-specific behavior should come from plugins.

But I of course respect your decision. I come from Rollup, which is pretty founded in being as close to native ES module semantics as practically possible, but there are obvious advantages to esbuild (ease of use, performance), and I want this tool to succeed for everyone, including those that use Shadow DOM :-)

wessberg commented 3 years ago

Promised I would get back to you. The plugin system works great! :-) It works as expected. Would be neat if plugins could get ahold of the esbuild options. For example, I wanted to know if esbuild was instructed to produce source maps from inside the plugin but didn't have any way of knowing, as far as I'm aware.

iamakulov commented 3 years ago

Hey Evan!

I was just curious if you have any roadmap for CSS, by chance. I saw mentions of features that are still lacking (https://github.com/evanw/esbuild/pull/468#issuecomment-711129911) plus mentions that a CSS rewrite is on the to-do list (https://github.com/evanw/esbuild/issues/519#issuecomment-733619132), but thereā€™s no full picture at the moment, as far as Iā€™m aware.

Iā€™m writing this primary because we at Framer are migrating to ESBuild, and thereā€™re two CSS issues weā€™re currently facing:

So Iā€™m trying to figure out how much we should invest into workarounds for these on our side. (Like, what if the CSS rewrites comes out in a week and fixes both issues?)

evanw commented 3 years ago

Yeah CSS support isn't fully baked yet. My first priority right now is to fix JavaScript code splitting since that's a more widely-used feature. After that, getting CSS to a better state will be my first priority.

I'm currently working on a rewrite of JavaScript code splitting to address a number of related features: #399, splitting for iife and cjs, manual chunks, top-level await, entry point hashes. The main aspect of the rewrite is changing code in shared chunks to be lazily-evaluated, which makes a lot of additional use cases possible. Getting all of those working together is pretty complicated and it's unfortunately taking longer than I'd like.

I have limited time to work on this at the moment due to the holidays, so all of this is not going to come out next week. I'm hoping for it to land in the next few months.

xPaw commented 3 years ago

Is it possible to tell esbuild to only bundle @import statements, but not other urls like background urls or font files? Similar to postcss-import.

I tried --external:woff2 or --external:jpg, but that did not work.

evanw commented 3 years ago

I tried --external:woff2 or --external:jpg, but that did not work.

Marking files as external by file extension requires a wildcard: --external:*.woff2. Documentation for this feature is here: https://esbuild.github.io/api/#external.

xPaw commented 3 years ago

Marking files as external by file extension requires a wildcard

Ah yes, that partially works.

For example: I am using --external:*.jpg --external:*.gif --external:*.woff2 --bundle which is slightly error prone due to requering every extension to be listed.

It worked for url(/static/img/steam.gif), but not for url(/static/img/salesbg.jpg?v=2) or ../fonts/Inter-Regular.woff2?v=3.15. It doesn't like the query parameter in urls.

evanw commented 3 years ago

Ah, I see. The fully general form of marking a file as external is to use an on-resolve plugin: https://esbuild.github.io/plugins/#resolve-callbacks. That works for arbitrarily complex requirements.

rizrmd commented 3 years ago

Does esbuild handle css injection to DOM ? Current behavior of another builder is to generate js to inject imported css to DOM, so for example:

import "./index.css"

will generate javascript code to inject a <style> to <head> or <body>, and then replacing import "./index.css" with the generated code.

rizrmd commented 3 years ago

Does esbuild handle css injection to DOM ? Current behavior of another builder is to generate js to inject imported css to DOM, so for example:

import "./index.css"

will generate javascript code to inject a <style> to <head> or <body>, and then replacing import "./index.css" with the generated code.

I just did this as a plugin:

export const cssLoader = () => ({
  name: 'css-loader',
  setup: function (build: any) {
    build.onLoad({ filter: /\.s?css$/ }, async (args: any) => {
      return {
        contents: `loadStyle("${args.path}")`,
        loader: 'js',
      }
    })
  },
})

The missing part is to create loadStyle function that must be accessible in any of the js file.

evanw commented 3 years ago

The missing part is to create loadStyle function that must be accessible in any of the js file.

Plugins can introduce new virtual modules to do that:

build.onResolve({ filter: /^loadStyle$/ }, () => {
  return { path: 'loadStyle', namespace: 'loadStyleShim' }
})
build.onLoad({ filter: /^loadStyle$/, namespace: 'loadStyleShim' }, () => {
  return { contents: `export function loadStyle() { ... }` }
})

You could then do this:

return {
  contents: `
    import {loadStyle} from 'loadStyle'
    loadStyle(${JSON.stringify(args.path)})
  `,
  loader: 'js',
}
rizrmd commented 3 years ago

The missing part is to create loadStyle function that must be accessible in any of the js file.

Plugins can introduce new virtual modules to do that:

build.onResolve({ filter: /^loadStyle$/ }, () => {
  return { path: 'loadStyle', namespace: 'loadStyleShim' }
})
build.onLoad({ filter: /^loadStyle$/, namespace: 'loadStyleShim' }, () => {
  return { contents: `export function loadStyle() { ... }` }
})

You could then do this:

return {
  contents: `
    import {loadStyle} from 'loadStyle'
    loadStyle(${JSON.stringify(args.path)})
  `,
  loader: 'js',
}

Wow, thanks, this is great, now what's left is the meat - creating style loader that handles css/scss with option to include postcss so we can use tailwind in esbuild...

endreymarcell commented 3 years ago

Here's my naive implementation:

const fs = require('fs');

const styleLoaderPlugin = {
    name: 'styleLoader',
    setup: build => {
        // replace CSS imports with synthetic 'loadStyle' imports
        build.onLoad({ filter: /\.css$/ }, async args => {
            return {
                contents: `
import {loadStyle} from 'loadStyle';
loadStyle(${JSON.stringify(args.path)});
`,
                loader: 'js',
            };
        });

        // resolve 'loadStyle' imports to the virtual loadStyleShim namespace which is this plugin
        build.onResolve({ filter: /^loadStyle$/ }, args => {
            return { path: `loadStyle(${JSON.stringify(args.importer)})`, namespace: 'loadStyleShim' };
        });

        // define the loadStyle() function that injects CSS as a style tag
        build.onLoad({ filter: /^loadStyle\(.*\)$/, namespace: 'loadStyleShim' }, async args => {
            const match = /^loadStyle\(\"(.*)"\)$/.exec(args.path);
            const cssFilePath = match[1];
            const cssFileContents = String(fs.readFileSync(cssFilePath));
            return {
                contents: `
export function loadStyle() {
    const style = document.createElement('style');
    style.innerText = \`${cssFileContents}\`;
    document.querySelector('head').appendChild(style);
}
`,
            };
        });
    },
};

module.exports = {
    styleLoaderPlugin,
};

This seems to work and mimics the webpack style-loader functionality of injecting a <style> tag into the HTML document's head.

However, I also have a .less file inside one of my node_modules dependencies, and I'm a bit stuck on figuring out how exactly to deal with that. I can use the less package to compile the file to CSS, I can even include that in this plugin, but what I'd like at the end of the day is: LESS is compiled to CSS -- esbuild builds it as a CSS entry point, resolving imports and bundling it into a single file (either on the disk or just in memory so that I have access to it from JS) -- my plugin above injects that into the HTML. I'm not quite sure if it's possible to have content handled this way - @evanw could you enlighten me, please?

endreymarcell commented 3 years ago

In other news, I'm also tripping up on the tilde-prefix convention that Webpack's style-loader uses to resolve imports from node_module (https://webpack.js.org/loaders/css-loader/#import). Specifically, these occur in @import statements inside of CSS/LESS files. I tried to extend the esbuild plugin to handle these imports but it doesn't seem to catch them - does the plugin even run on CSS files or JS/TS only?

mjackson commented 3 years ago

Hey @evanw :) First off, just wanted to say great work on esbuild. We (@ryanflorence and I) have been loving it so far! We are using it to build Remix (https://remix.run).

Side note: I thought it was awesome when I saw unpkg being used in the esbuild docs about how to build a plugin. Seeing something I built being used in a quality project like esbuild is just so cool šŸ¤˜

I just wanted to share a few thoughts here. My initial thought is that I wish loading CSS worked more like the file loader. If you're creating an extra file in the build output, what I really need is the URL to that file. Then, I can include it with a <link> tag.

A few reasons why I prefer using a <link> tag (instead of loading CSS via JavaScript, or inlining it into the page):

In addition, regardless of whether you use a <link> tag or a loadStyle() function (as was previously discussed), both methods really just need a URL to the stylesheet.

Here's how you'd use a <link> tag:

import typography from './typography.css';
import typographyDark from './typographyDark.css';

function MyApp() {
  return (
    <html>
      <head>
        <link rel="stylesheet" href={typography} media="(prefers-color-scheme: light)" />
        <link rel="stylesheet" href={typographyDark} media="(prefers-color-scheme: dark)" />
      </head>
    </html>
  );
}

And here's what you'd do if you prefer loadStyle():

import typography from './typography.css';
import typographyDark from './typographyDark.css';

if (window.matchMedia('(prefers-color-scheme: dark)')) {
  loadStyle(typographyDark);
} else {
  loadStyle(typography);
}

I also realize you're thinking about doing some code splitting with CSS. In that case, maybe an array of URLs could be returned? šŸ¤·

Anyway, as I already said we've been really happy with using esbuild to build Remix and we appreciate the amount of care you've taken to get it right. I hope our perspective here helps as you consider what to do about first-class CSS support in esbuild.

evanw commented 3 years ago

Some things to try:

const cssFilePlugin = {
  name: 'css-file',
  setup(build) {
    const path = require('path')

    build.onResolve({ filter: /\.css$/ }, args => {
      return {
        path: path.join(args.resolveDir, args.path),
        namespace: 'css-file',
      }
    })

    build.onLoad({ filter: /.*/, namespace: 'css-file' }, async (args) => {
      const result = await esbuild.build({
        entryPoints: [args.path],
        bundle: true,
        write: false,
      })
      return {
        contents: result.outputFiles[0].text,
        loader: 'file'
      }
    })
  },
}
endreymarcell commented 3 years ago

Hey @evanw, sorry for nagging but I'm still really hoping you could point me in the right direction regarding these 2 questions:

  1. How should I approach less files if I'm handling the css files with a self-written style-loader plugin that injects them into a style tag in the page?
  2. How should I approach the tilde-prefixed imports in css/less files? Specifically, can I intercept the css import resolution from an esbuild plugin or does that only work for javascript files?
evanw commented 3 years ago
  1. That's up to you, but it would probably look like some kind of on-load plugin that returns a JavaScript stub for esbuild to process: https://esbuild.github.io/plugins/#load-callbacks. That JavaScript stub could then do whatever you need it to.
  2. Intercepting import paths is done with an on-resolve plugin: https://esbuild.github.io/plugins/#resolve-callbacks. Plugins run for all file types including JavaScript and CSS.
Valexr commented 3 years ago

I have splitting bundles with css-chunks, but i can't load css on page...

Screenshot 2021-03-21 at 10 01 24

index.js Screenshot 2021-03-21 at 10 09 45

index.css Screenshot 2021-03-21 at 10 09 19

I have some resolver for this, but its not working with splitting: true...

let fakecsspathResolvePlugin = {
    name: 'fakecsspath',
    setup(build) {
        let path = require('path')

        build.onResolve({ filter: /\.esbuild-svelte-fake-css$/ }, ({ path }) => {
            return { path, namespace: 'fakecss' }
        })

        build.onLoad({ filter: /\.esbuild-svelte-fake-css$/, namespace: 'fakecss' }, ({ path }) => {
            const css = cssCode.get(path);
            return css ? { contents: css, loader: "css" } : null;
        })
    },
}
evanw commented 3 years ago

Code splitting doesn't work well with CSS at the moment, sorry. I'm working hard to fix this but it's a big undertaking. This is why there are warning signs next to code splitting and CSS in the documentation. See also #608.

Valexr commented 3 years ago

Splitting css by js-chunks ok, but havenā€™t any loaders logic...

arnog commented 3 years ago

I needed a loader that could import a .less file in TypeScript as a string (compiled to CSS), and I couldn't find anything to do that, so I wrote a plugin for it: https://github.com/arnog/esbuild-plugin-less

I hope someone else find this useful, and that this use case will be supported when the full CSS support is in.

Just switched to esbuild and I'm super impressed. Great job @evanw !

Valexr commented 3 years ago

I have splitting bundles with css-chunks, but i can't load css on page...

Screenshot 2021-03-21 at 10 01 24

index.js Screenshot 2021-03-21 at 10 09 45

index.css Screenshot 2021-03-21 at 10 09 19

I have some resolver for this, but its not working with splitting: true...

let fakecsspathResolvePlugin = {
    name: 'fakecsspath',
    setup(build) {
        let path = require('path')

        build.onResolve({ filter: /\.esbuild-svelte-fake-css$/ }, ({ path }) => {
            return { path, namespace: 'fakecss' }
        })

        build.onLoad({ filter: /\.esbuild-svelte-fake-css$/, namespace: 'fakecss' }, ({ path }) => {
            const css = cssCode.get(path);
            return css ? { contents: css, loader: "css" } : null;
        })
    },
}

Fixed šŸ‘šŸ»from v0.12.0 @evanw Tx for great bundleršŸ¤“

bl-ue commented 3 years ago

What's the progress here? And how will we be able to deal with sass/scss?

evanw commented 3 years ago

And how will we be able to deal with sass/scss?

If you are using another language that sits on top of CSS, you will likely always need to use a plugin for it with esbuild.

DanAndreasson commented 2 years ago

@evanw do you have an estimate of when this will be released? Awesome work on esbuild!

asbjornh commented 2 years ago

And how will we be able to deal with sass/scss?

If you are using another language that sits on top of CSS, you will likely always need to use a plugin for it with esbuild.

Could you clarify this a bit? Is it conceivable that scss modules could be supported provided that a plug-in does the compilation of scss to css?

rmannibucau commented 2 years ago

Hi,

Any status on this issue? I'm using a fork of https://www.npmjs.com/package/esbuild-css-modules-plugin but would be great to have something built-in.

Thanks a lot for the hard work!

ar2r13 commented 2 years ago

Hello @evanw. I am writing an application for Electron. Chrome now supports import assertions, I trying to prevent transforming line import style from 'style.css assert { type: 'css' }, but i want to use esbuild for css bundling. Is it possible to transform css by esbuild and prevent transform import line (just replace import path)?

nicksrandall commented 2 years ago

@evanw One big benefit of CSS Modules is that it that it transparently mangles selectors thus giving the guarantee that all selectors will be unique. This effectively mitigates nearly all css ordering issues.

When CSS Modules is implemented, we should also be able to code split css to the same granularity that we code split JS -- which would be awesome.

IanVS commented 2 years ago

This effectively mitigates nearly all css ordering issues.

I believe that is true only if you are using exclusively css modules. If you have a mix of global css and css modules, which is common, then it's important to maintain the import order so the cascade is correct.

TheLonelyAdventurer commented 2 years ago

Probably unrelated, but I created a plugin that does so with scss, also supporting postcss plugin chains

https://github.com/Squirrel-Network/esbuild-sass-modules-plugin

clshortfuse commented 2 years ago

I wasn't able to get CSS files to be minified when using:

import styles from './MDWButton.css' assert { type: 'css' };

My JS files load fine in Chrome natively, but to make it work on FireFox, I have to convert to CSSStyleSheet:

import { readFile } from 'node:fs/promises';

import CleanCSS from 'clean-css';
import esbuild from 'esbuild';

const cleanCss = new CleanCSS();

await esbuild.build({
  entryPoints: ['index.js'],
  format: 'esm',
  sourcemap: true,
  minify: true,
  bundle: true,
  outfile: 'index.min.js',
  plugins: [{
    name: 'css import assertions',
    setup: (build) => {
      build.onLoad({ filter: /\.css$/ }, async (args) => {
        const css = await readFile(args.path, 'utf8');
        const { styles } = cleanCss.minify(css);
        const contents = `
          let contents = \`${styles.replaceAll(/`/g, '\\`')}\`;
          let styles;
          try {
            styles = new CSSStyleSheet();
            styles.replaceSync(contents);
          } catch (e) {
            styles = contents;
          }
          export default styles;`;
        return { contents };
      });
    },
  }],
});

As for Safari, which doesn't support CSSStyleSheet, I just return the string and rebuild it as an inline style. That's all fine, but is there a way to make minify with the built-in CSS minifier? I was trying to avoid extra dependencies than just npx esbuild.

hyrious commented 2 years ago

is there a way to make minify with the built-in CSS minifier?

@clshortfuse I guess you're asking how to use esbuild instead of clean-css to minify a css file. This is quite easy:

let args_path = '/path/to/a.css'
let r = await esbuild.build({
    entryPoints: [args_path],
    bundle: true,
    minify: true,
    write: false, // <- get result in memory
    target: ['safari6'], // <- adjust css features
})
let minified = r[0].text.trimEnd()
clshortfuse commented 2 years ago

@hyrious Thanks!

I didn't even consider just calling esbuild within esbuild:

import esbuild from 'esbuild';

/** @type {import('esbuild').Plugin} */
export default {
  name: 'css import assertions',
  setup(build) {
    build.onLoad({ filter: /\.css$/ }, async (args) => {
      const { outputFiles } = await esbuild.build({
        entryPoints: [args.path],
        bundle: true,
        minify: build.initialOptions.minify,
        minifyWhitespace: build.initialOptions.minifyWhitespace,
        minifyIdentifiers: build.initialOptions.minifyIdentifiers,
        minifySyntax: build.initialOptions.minifySyntax,
        target: build.initialOptions.target,
        write: false,
      });
      const [file] = outputFiles;
      const { text } = file;
      const jsText = text.trim()
        .replaceAll(/`/g, '\\`')
        .replaceAll(/\\([\da-f]+)/gi, (match, p1) => String.fromCodePoint(Number.parseInt(p1, 16)));
      const contents = /* js */ `
        let contents = \`${jsText}\`;
        let sheet;
        try {
          sheet = new CSSStyleSheet();
          sheet.replaceSync(contents);
        } catch (e) {
          const doc = document.implementation.createHTMLDocument()
          const style = doc.createElement('style');
          style.textContent = contents;
          doc.head.append(style);
          sheet = style.sheet;
          // Note: Removing style from document will nullify sheet
        }
        export default sheet;`;
      return { contents };
    });
  },
};

Works great! Thanks, again.


Edit (2023-01-26): Rewritten as ESM Plugin and fixed Unicode. Also, now always returns CSSStyleSheet which makes more sense for type-checking. On browsers that don't support constructable CSSStyleSheet, you'll have to convert it in runtime to whatever works for you (HTMLStyleElement or just raw string), like so:

const asString = [...styleSheet.cssRules].map((r) => r.cssText).join('\n');
const asElement = document.createElement('style');
el.textContent = asString;
Valexr commented 2 years ago

One more simple solution with configurable hash - https://github.com/Valexr/Slidy/blob/master/packages/svelte/cssmodules.js