evanw / esbuild

An extremely fast bundler for the web
https://esbuild.github.io/
MIT License
38.13k stars 1.15k 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.

gotjoshua commented 2 years ago

I am importing my css files with the builtin dataurl strategy, and i notice that it does not follow any @import directives (so the data url will be useless if the css is modular)

@evanw is there anyway currently to get a dataurl that fully traversed the @import tree?

moishinetzer commented 1 year ago

It would be lovely to have support for anonymous imports:

import "./styles.css"

Which would scramble the classes in the CSS file and also in the component files (React in my use case)

This has never been developed for any bundler and is a common issue component library developers face when deciding how to build their libraries. I specifically came across this issue while creating a framework for component libraries https://github.com/moishinetzer/PBandJ

On the one hand, using named imports slows down development and requires styles to be referenced all over the file. On the other hand, if you go with CSS injection, you have no guarantee that your classes won't clash with your users' projects!

For example, you (realistically) could never use the class name .button as that will most definitely be used throughout your users' projects.

This has been tried before here:

In reality, asking users to import a bundled CSS file is unrealistic as it does not get past the first point and only passes on the responsibility to the user to decide how to deal with these issues.

To conclude, this feature could be groundbreaking as no one provides this level of support (in-JS anonymous CSS imports that are bundled and local to its component). This includes tsup, rollup, and parcel, and I genuinely couldn't find any bundler that does this. (I would love to stand corrected)

mikestopcontinues commented 1 year ago

It would be lovely to have support for anonymous imports:

import "./styles.css"

Which would scramble the classes in the CSS file and also in the component files (React in my use case)

I think this is a bad idea. There's no way to guarantee you are in fact scrambling the correct strings in the JS files. I imagine you're thinking of Tailwind here, but remember that TW does not change your source files. It actually accepts that it may compile additional classes that aren't actually used.

Also, importing a style object is a strength. With the right TS config, you can gain type safety across this boundary, ensuring that you aren't referencing classes that don't exist. Even without this benefit, the compiler flattens these references. There's no downside.

Lastly, there's a place for css imports that don't mangle, and we already have a convention for it. The burden of proof for giving up a convention should rightly be greater than for greenfield experimentation.

moishinetzer commented 1 year ago

I think this is a bad idea. There's no way to guarantee you are in fact scrambling the correct strings in the JS files.

I don't know what you mean by there's no way to guarantee the scramble. It's exactly how regular CSS modules work rather it runs through them iteratively rather than on an import basis.

I imagine you're thinking of Tailwind here, but remember that TW does not change your source files. It actually accepts that it may compile additional classes that aren't actually used.

Funnily enough, I thought tailwind would be a problem for my suggested implementation, given you must import the tailwind derivatives, and they have to be global inherently to apply to all of the classes in every component. On your last point, it doesn't compile additional classes that aren't used, given it now solely runs on its JIT compiler.

Also, importing a style object is a strength. With the right TS config, you can gain type safety across this boundary, ensuring that you aren't referencing classes that don't exist. Even without this benefit, the compiler flattens these references. There's no downside.

I've never seen this being implemented properly (the .d.css files never quite work properly for CSS, but it's a reasonable point. This does not, however, detract from how the implementation could still support named imports. My suggestion is specifically based on anonymous imports allowing developers to use other stylesheets that aren't modules or allowing them not to need to tag on from the styles being imported.

Lastly, there's a place for css imports that don't mangle, and we already have a convention for it. The burden of proof for giving up a convention should rightly be greater than for greenfield experimentation.

The goal of this is, as I mentioned clearly in my original text: to simplify the developer experience by allowing anonymous imports (not everyone enjoys/knows how to use css modules), which will also allow external CSS files to be imported and release the stress of component library developers on how they bundle and release styles along with their components (It's a tough problem! Giving it a proper shot will show how hard it can be to do)

clshortfuse commented 1 year ago

The spec version of CSS Modules returns a CSSStyleSheet which is perfect for Web Components. There's no reading of the stylesheet either. You just attach it via adoptedStyleSheets, either in Shadow DOM or document . No mangling, no class inspection, no re-writing.

I would imagine a modern use case of an anonymous import would rewrite this:

import globalStyles from './ButtonGlobals.css' assert { type: 'css' };

document.adoptedStyleSheets = [...document.adoptedStyleSheets, globalStyles];

as this:

import './ButtonGlobals.css' assert { type: 'css' };

If you need some sort of customization of reading and parsing the actual CSSStyleSheet, it would make sense to use build.onLoad on an esbuild plugin that converts it to whatever custom, (class-based in your use case) Javascript Object.

ctjlewis commented 1 year ago

For some reason the statements:

@import "tailwindcss/base.css";
@import "tailwindcss/utilities.css";
@import "tailwindcss/components.css";

Are transformed into:

/* ../node_modules/.pnpm/tailwindcss@3.2.7_postcss@8.4.21/node_modules/tailwindcss/base.css */
@tailwind base;

/* ../node_modules/.pnpm/tailwindcss@3.2.7_postcss@8.4.21/node_modules/tailwindcss/utilities.css */
@tailwind utilities;

/* ../node_modules/.pnpm/tailwindcss@3.2.7_postcss@8.4.21/node_modules/tailwindcss/components.css */
@tailwind components;

Rather than simply inlining them as desired with:

esbuild --bundle src/index.css

These naturally are not valid CSS statements and will not display the necessary styles.

terpimost commented 1 year ago

I wonder what should happen if css module is imported inside JS/TS which is called inside a worker? Right now we just import css and it is attached to the global css code but there is no css encapsulation. Is the intention to have a single css output but with modified class names to get "modularity"?

clshortfuse commented 1 year ago

I wonder what should happen if css module is imported inside JS/TS which is called inside a worker?

CSSStyleSheet is part of window (window.CSSStyleSheet). It means Web Workers and Service Workers can't natively build their own CSSStyleSheet. It means, even if they support the import, you can't polyfill it. I do believe it's supported, but haven't tried it.

Is the intention to have a single css output but with modified class names to get "modularity"?

That sounds like custom encapsulation. You don't need classes with Web Components which is the direction of spec (see adoptedStyleSheets). There's no need to tag elements with classes to identify them. You can use #id for identification because of Shadow Root. If you need scoping, then you need to modify the style sheet. (edit: or write the CSS already scoped)

If you do want to modify (scope) CSS, then you have to run the custom steps in compile phase before passing the CSS (preprocessor) or as a build script (postprocessor). I'm not sure esbuild has the ability to stream the CSS tokens as it reads them, so if we're talking compiler phase, you'd have to use your own CSS processor (cssnano).

Depending on your usage, runtime may be better, especially if you can tap into web workers, because in theory, you can run import CSSStyleSheet objects in parallel, and pass back to the main thread with your encapsulation. Anything compiler-phase will hit the main thread and all you'd get with native CSS imports is parallel CSSStyleSheet parsing done by the browser (hits your main thread as a ready to use CSSStyleSheet instead of a string).

jimmywarting commented 1 year ago

Most think of css modules in term of the original css-module, aka:

import styles from './style.css'
// import { className } from './style.css'

element.innerHTML = '<div class="' + styles.className + '">'

This is very useful way of turning them into kind of vanilla javascript modules that can be treeshaked later on. And it's useful but i don't think we should be striving for this non-standard solution cuz that's not how native import without build steps works... importing a css with import stylesheet from './style.css' assert { type: 'css' } should return a CSSStyleSheet So that's what should be imported.

I'm uncertain if it would be good to inlined the css and then constructing a CSSStyleSheet A css can also be imported with <link>. and if some library imports jquery-ui via a cdn from a <link> then import should really be fetching the same css and use the same cache. i also might want to put the css into CacheStorage with service workers.

So i guess it would be better if when you try to import a css like this:

import style from './style.css' assert { type: css }

then it turns into this top level await solution:

export default await fetch(import.meta.resolve('./style.css'))
  .then(res => res.text())
  .then(css => {
    const style = new CSSStyleSheet()
    style.replace(css)
    return style
  })
clshortfuse commented 1 year ago

@jimmywarting Hi đź‘‹

The problem with the top level await is that it makes it asynchronous. For JS executing before first render you might want that CSSStylesheet parsed before first paint.

My use case is Web Components, so they are registered before DOMContentLoad, constructed, and adoptedStyleSheet added to the shadow root all before the first browser "tick". Right now it all works without bundling on Chrome, so I'd expect esbuild to just minify/bundle without any side effects.

Async imports does a syntax, so I'd imagine the bundler can analyze for that.

import("foo.css", { with: { type: "css" } })

Spec has changed a bit. See

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

I have a comment a bit higher up that shows how to replace the import with anything you want. It's not perfect, but works around Safari not having constructable CSSStyleSheet. Note that new CSSStyleSheet is part of window so I don't think it'll work on Service Workers which may cause an issue with the your cache strategy. (Haven't tried it personally).

I put my implementation on ice, since Safari team is waiting for spec to get finalized before implementing and it's impossible to polyfill for unbundled environments. But last I tried, it worked fine.

Considering we all have different use cases, it might be helpful for esbuild to give us devs a more streamlined approach to implementing all our different strategies.

Edit: Just in case, here is the actual code I was using.

jimmywarting commented 1 year ago

so I'd expect esbuild to just minify/bundle without any side effects.

That was mostly what i wanted to get out of my comment... i would not expect either to get anything else out other than a CSSStylesheet if it now works natively in chrome. my solution is just brainstorming solutions to deliver it.

intrnl commented 1 year ago

^ ooo we finally have CSS modules / local class names implemented :eyes:

mhsdesign commented 1 year ago

Thanks ❤️ for the native implementation.

In case you need support for composes and local names for keyframe animations, grid lines, etc you can use parcel's Lightning CSS via my adapter: https://github.com/mhsdesign/esbuild-plugin-lightningcss-modules

joelmoss commented 1 year ago

Amazing! But unfortunately it's only useful for use with JS imports. And that is no good for use within server rendered HTML. Step in the right direction, but quite limited - at least for me.

terpimost commented 1 year ago

Amazing! But unfortunately it's only useful for use with JS imports. And that is no good for use within server rendered HTML. Step in the right direction, but quite limited - at least for me.

Wait, the renaming is happening in the build step and imports are just references to string values of css classes. Am I missing something?

joelmoss commented 1 year ago

Wait, the renaming is happening in the build step and imports are just references to string values of css classes. Am I missing something?

Yes, but the exported class names will get renamed if and when collisions occur.

evanw commented 1 year ago

An initial implementation of the local CSS name transform has now shipped in version 0.18.14. It's off by default for now and must be manually enabled with either the local-css or global-css loaders. Documentation for this is now available here: https://esbuild.github.io/content-types/#local-css.

In addition, esbuild's support for CSS source maps was overhauled to be closer to parity with JS. There are now per-token source mappings in esbuild's CSS output, and esbuild uses the names source map field to provide the original name for renamed CSS names.

If anyone wants to try it out, it would be interesting to hear what you think. Please consider the implementation experimental and subject to change as I plan to improve it more in the future (which is why this issue is still open).

jimmywarting commented 1 year ago

đź‘Ť I think it was a good decision to rename it to something else entirely and having it off by default (for now) given now that it's possible to natively import css files without bundlers now.

I could expect that this css preprocessor tech becomes less relevant the more ppl start to use custom elements (web components)

It's unfortunately that the term "CSS Module" became overloaded.

clshortfuse commented 1 year ago

I tested my existing plugin for native CSS modules and because plugins are resolved first, it's not an issue even if default is turned on. Though right now we can't differentiate between code that loads

import { button } from './button.css';
import globalStyleSheet from './globals.css' assert { type: 'css' };

Also, files only load once meaning you can't opt into using local-css based on one import type and opt into using the plugin for native CSS Modules.

import globalStyleSheet from './globals.css' assert { type: 'css' };
import { button } from './globals.css';

Nothing major, but just edge cases. Maybe something in OnLoadOptions or OnLoadArgs can be passed to differentiate the two. Nice work!

evanw commented 1 year ago

Using the assert keyword like that is problematic for a few reasons. First of all, it has been deprecated since it was introduced and will never be a part of JavaScript (the standards committee changed their minds about it). Second of all, import assertions by design can’t affect how the module is loaded (the spec says this explicitly).

Import assertions are potentially going to be replaced with something else called import attributes, which will hypothetically use the with keyword and which may fix this limitation (so attributes will be able to affect how the module is loaded. However, this feature hasn’t been added to any real JavaScript runtime yet so it remains to be seen how it will actually work when it becomes a part of JavaScript for real.

Given that the standards committee has already changed their mind and "unshipped" this proposal after it reached Stage 3, I’m planning to wait to add support for import attributes to esbuild until after there’s an implementation of it shipping in a real JavaScript runtime. This also means waiting to add support for it to any built-in CSS behavior as well as to esbuild’s plugin API.

jimmywarting commented 1 year ago

How would ppl feel about this kind of hypothetical syntax?

import globalStyleSheet from './globals.css' with { type: 'css' } // native (untouched)
import { button } from './globals.css' with { type: 'x-css-local' } // esbuild preprocess solution.
import * as util from './util.ts' with { type: 'x-typescript' }
joelmoss commented 1 year ago

As I mentioned earlier, this is only useful for use with JS imports. And that is no good for use within server rendered HTML. So is quite limited.

Do you have plans to expand this to expose the renamed class names, perhaps as part of the metafile?

evanw commented 1 year ago

@jimmywarting This issue is about implementing https://github.com/css-modules/css-modules, not about import attributes. We can discuss how to integrate import attributes into esbuild after they are shipped in a real browser. Please leave the discussion of import attributes out of this issue.

@joelmoss I saw your comment but I'm confused. By server-rendered HTML I'm imagining something like this:

import { button } from './button.css'
console.log(`<div class="${button}"></div>`)

This should already works with what was shipped, as you can see here. Can you elaborate about why that doesn't work in your case?

joelmoss commented 1 year ago

Can you elaborate about why that doesn't work in your case?

The problem is that you are assuming that I will only be using JS and importing the CSS, which is not always true. Sometimes I'm including CSS with <link> tags in HTML. So this implementation will not work in that case.

To make local-css work outside of JS, esbuild will need to expose the exported CSS classes perhaps as part of the metafile, or support a way of making the exported class names deterministic, similarl to how webpack's CSS-loader does it using localIdentName.

Right now it is not a huge issue for me, as I have already built my own naive implementation of CSS modules, which works so far. But I would much prefer an esbuild native solution.

Hope that helps explain things a little. thx

evanw commented 1 year ago

I think including the CSS with a <link> tag in the HTML should be fine if the HTML is JS-generated. Something like this. If you're talking about linking to CSS from hand-written (non JS-generated) HTML and then minifying CSS names within the HTML itself then yes, esbuild's local CSS name transform won't work for that use case. But I'd argue that in that situation you probably shouldn't be processing the linked CSS using the local CSS name transform. Maybe providing an example of what you're trying to achieve would help.

To answer your deeper question: I'm open exposing the name mapping somehow at some point, but I haven't figured out how yet. Having specific example use cases for this data to think through will be helpful when adding that feature so thanks for describing what you want to achieve.

joelmoss commented 1 year ago

My specific use case is with Ruby on Rails, so the server generated HTML is from Ruby, not JS. But sometimes I use React, which is why I have a mix of JS and server (Ruby) generated HTML.

When the HTML is generated from the server, one or more stylesheets are also included as <link> tags, with the src of each being generated by esbuild, and my custom CSS module plugin. If the name of the CSS files ends with .module.css all classnames in that file are renamed to something like [className]-[filenameHash].

Now because these renamed class names are deterministic, and I already know the filenameHash and className, I can then use said class names anywhere in the server generated HTML.

I have a little helper that I can call on the server by simply passing it the name of the class and the path of the CSS file.

So as an example, I have a CSS file at /some/path/to/styles.module.css and a class name of myclass. I add the <link> tag with a src of /some/path/to/styles.module.css into the HTML. Then I calculate the hash of that path, giving me this:

.myclass-abcd1234 {
  color: red;
}
<div class="myclass-abcd1234" />

The above would actually be far better than reading any exposed name mapping, as I do not not need to know anything about the mapping. It would also be a lot less work to implement. There is also no chance of conflicts, as it is safe to assume that all classes within each stylesheet are unique.

Hope that helps explain my use case.

strogonoff commented 1 year ago

Can’t wait for built-in implementation! The existing plugins don’t work well in some cases because they all seem to be based on lightningcss, which requires native Node modules and appears to be poorly compatible with Yarn’s zero-install mode.

rmannibucau commented 1 year ago

+1 for the provided impl in "last" release, fulfills my expectations and needs for now. Thanks a lot.

asbjorn-brevio commented 1 year ago

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.

Question about the (highlighted) last sentence above: Is this supported in the current version (0.18.17)? If not, are there still plans for supporting it?

I tried setting local-css for .module.scss, .module.css, .scss and .css and as far as I can tell none of the classes from the SCSS files are getting renamed (even conflicting ones). The output JS seems to have been transformed as expected though

clshortfuse commented 1 year ago

@asbjorn-brevio

with a plugin

I believe you need a plug-in to transform SCSS to the custom CSS syntax with :global() and :local() support (not just regular CSS). After that the plugin calls esbuild with the local-css or global-css loader on the transformed CSS and returns its result.

asbjorn-brevio commented 1 year ago

@clshortfuse Yeah, I'm using esbuild-sass-plugin to transform the SCSS. Is the plugin the problem?

hyrious commented 1 year ago

@asbjorn-brevio Yes. If a plugin wants to make use of the local-css loader, it has to set that in the onLoad() callback, or at least return {loader: 'default'} and let user set that in the build options. Here's a minimal example to write such plugin:

// build.mjs
import { build } from "esbuild";
import { compile } from "sass";

var sass_module_plugin = {
  name: "local-sass",
  setup({ onLoad }) {
    onLoad({ filter: /\.module\.scss$/ }, (args) => {
      const { css } = compile(args.path);

      return { contents: css, loader: "local-css" };
      //                              ^^^^^^^^^^^
    });
  },
};

await build({
  entryPoints: ["index.js"],
  bundle: true,
  outdir: "dist",
  plugins: [sass_module_plugin],
}).catch(() => process.exit(1));
// index.js
import { foo } from "./foo.module.scss";
console.log(foo);
/* foo.module.scss */
$name: "foo";
.#{$name} {
  color: red;
}
asbjorn-brevio commented 1 year ago

@hyrious Got it, thanks!

I've postponed migrating to esbuild awaiting this feature so I haven't internalized how this all works yet. Sorry for the noise.

strogonoff commented 1 year ago

FWIW, local-css is working well for CSS in my simple case so far. I haven’t yet tested it very thoroughly, but in a very simple (for now) frontend migration from esbuild-css-modules-plugin to local-css loader was painless, it required no changes to TSX/CSS sources and let me shed a lot of dependencies right away.

evanw commented 1 year ago

I'm considering this to be implemented now that support for composes is in (see the latest release notes for details). Any additional improvements and/or bug fixes can be tracked using new issues.

senro commented 3 months ago

//index.module.less

.roleAuthorization {
  :global {
     .header::after,.content::after{
            content: '88888888';
      }
  }
}

after local-css loader, turn css like:

.index_module_roleAuthorization .header::after,
.roleAuthorization .content::after {
  content: "88888888";
}

the right css should be:

.index_module_roleAuthorization .header::after,
.index_module_roleAuthorization .content::after {
  content: "88888888";
}

is local-css can't support ',' in css?