Skip to content

Commit

Permalink
fix #20: implement composes from css modules
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Aug 7, 2023
1 parent a470f0a commit a0910fd
Show file tree
Hide file tree
Showing 17 changed files with 659 additions and 143 deletions.
78 changes: 78 additions & 0 deletions CHANGELOG.md
Expand Up @@ -2,6 +2,84 @@

## Unreleased

* Implement `composes` from CSS modules ([#20](https://github.com/evanw/esbuild/issues/20))

This release implements the `composes` annotation from the [CSS modules specification](https://github.com/css-modules/css-modules#composition). It provides a way for class selectors to reference other class selectors (assuming you are using the `local-css` loader). And with the `from` syntax, this can even work with local names across CSS files. For example:

```js
// app.js
import { submit } from './style.css'
const div = document.createElement('div')
div.className = submit
document.body.appendChild(div)
```

```css
/* style.css */
.button {
composes: pulse from "anim.css";
display: inline-block;
}
.submit {
composes: button;
font-weight: bold;
}
```

```css
/* anim.css */
@keyframes pulse {
from, to { opacity: 1 }
50% { opacity: 0.5 }
}
.pulse {
animation: 2s ease-in-out infinite pulse;
}
```

Bundling this with esbuild using `--bundle --outdir=dist --loader:.css=local-css` now gives the following:

```js
(() => {
// style.css
var submit = "anim_pulse style_button style_submit";

// app.js
var div = document.createElement("div");
div.className = submit;
document.body.appendChild(div);
})();
```

```css
/* anim.css */
@keyframes anim_pulse {
from, to {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.anim_pulse {
animation: 2s ease-in-out infinite anim_pulse;
}

/* style.css */
.style_button {
display: inline-block;
}
.style_submit {
font-weight: bold;
}
```

Import paths in the `composes: ... from` syntax are resolved using the new `composes-from` import kind, which can be intercepted by plugins during import path resolution when bundling is enabled.

Note that the order in which composed CSS classes from separate files appear in the bundled output file is deliberately _**undefined**_ by design (see [the specification](https://github.com/css-modules/css-modules#composing-from-other-files) for details). You are not supposed to declare the same CSS property in two separate class selectors and then compose them together. You are only supposed to compose CSS class selectors that declare non-overlapping CSS properties.

Issue [#20](https://github.com/evanw/esbuild/issues/20) (the issue tracking CSS modules) is esbuild's most-upvoted issue! With this change, I now consider esbuild's implementation of CSS modules to be complete. There are still improvements to make and there may also be bugs with the current implementation, but these can be tracked in separate issues.

* Fix non-determinism with `tsconfig.json` and symlinks ([#3284](https://github.com/evanw/esbuild/issues/3284))

This release fixes an issue that could cause esbuild to sometimes emit incorrect build output in cases where a file under the effect of `tsconfig.json` is inconsistently referenced through a symlink. It can happen when using `npm link` to create a symlink within `node_modules` to an unpublished package. The build result was non-deterministic because esbuild runs module resolution in parallel and the result of the `tsconfig.json` lookup depended on whether the import through the symlink or not through the symlink was resolved first. This problem was fixed by moving the `realpath` operation before the `tsconfig.json` lookup.
Expand Down
4 changes: 4 additions & 0 deletions cmd/esbuild/service.go
Expand Up @@ -789,6 +789,8 @@ func resolveKindToString(kind api.ResolveKind) string {
// CSS
case api.ResolveCSSImportRule:
return "import-rule"
case api.ResolveCSSComposesFrom:
return "composes-from"
case api.ResolveCSSURLToken:
return "url-token"

Expand All @@ -815,6 +817,8 @@ func stringToResolveKind(kind string) (api.ResolveKind, bool) {
// CSS
case "import-rule":
return api.ResolveCSSImportRule, true
case "composes-from":
return api.ResolveCSSComposesFrom, true
case "url-token":
return api.ResolveCSSURLToken, true
}
Expand Down
19 changes: 18 additions & 1 deletion internal/ast/ast.go
Expand Up @@ -35,6 +35,9 @@ const (
// A CSS "@import" rule with import conditions
ImportAtConditional

// A CSS "composes" declaration
ImportComposesFrom

// A CSS "url(...)" token
ImportURL
)
Expand All @@ -51,6 +54,8 @@ func (kind ImportKind) StringForMetafile() string {
return "require-resolve"
case ImportAt, ImportAtConditional:
return "import-rule"
case ImportComposesFrom:
return "composes-from"
case ImportURL:
return "url-token"
case ImportEntryPoint:
Expand All @@ -61,7 +66,19 @@ func (kind ImportKind) StringForMetafile() string {
}

func (kind ImportKind) IsFromCSS() bool {
return kind == ImportAt || kind == ImportURL
switch kind {
case ImportAt, ImportAtConditional, ImportComposesFrom, ImportURL:
return true
}
return false
}

func (kind ImportKind) MustResolveToCSS() bool {
switch kind {
case ImportAt, ImportAtConditional, ImportComposesFrom:
return true
}
return false
}

type ImportRecordFlags uint16
Expand Down
56 changes: 13 additions & 43 deletions internal/bundler/bundler.go
Expand Up @@ -2009,13 +2009,23 @@ func (s *scanner) processScannedFiles(entryPointMeta []graph.EntryPoint) []scann
}

switch record.Kind {
case ast.ImportComposesFrom:
// Using a JavaScript file with CSS "composes" is not allowed
if _, ok := otherFile.inputFile.Repr.(*graph.JSRepr); ok && otherFile.inputFile.Loader != config.LoaderEmpty {
s.log.AddErrorWithNotes(&tracker, record.Range,
fmt.Sprintf("Cannot use \"composes\" with %q", otherFile.inputFile.Source.PrettyPath),
[]logger.MsgData{{Text: fmt.Sprintf(
"You can only use \"composes\" with CSS files and %q is not a CSS file (it was loaded with the %q loader).",
otherFile.inputFile.Source.PrettyPath, config.LoaderToString[otherFile.inputFile.Loader])}})
}

case ast.ImportAt, ast.ImportAtConditional:
// Using a JavaScript file with CSS "@import" is not allowed
if _, ok := otherFile.inputFile.Repr.(*graph.JSRepr); ok && otherFile.inputFile.Loader != config.LoaderEmpty {
s.log.AddErrorWithNotes(&tracker, record.Range,
fmt.Sprintf("Cannot import %q into a CSS file", otherFile.inputFile.Source.PrettyPath),
[]logger.MsgData{{Text: fmt.Sprintf(
"An \"@import\" rule can only be used to import another CSS file, and %q is not a CSS file (it was loaded with the %q loader).",
"An \"@import\" rule can only be used to import another CSS file and %q is not a CSS file (it was loaded with the %q loader).",
otherFile.inputFile.Source.PrettyPath, config.LoaderToString[otherFile.inputFile.Loader])}})
} else if record.Kind == ast.ImportAtConditional {
s.log.AddError(&tracker, record.Range,
Expand Down Expand Up @@ -2067,55 +2077,15 @@ func (s *scanner) processScannedFiles(entryPointMeta []graph.EntryPoint) []scann
sourceIndex := s.allocateSourceIndex(stubKey, cache.SourceIndexJSStubForCSS)
source := otherFile.inputFile.Source
source.Index = sourceIndex

// Export all local CSS names for JavaScript to use
exports := js_ast.EObject{}
cssSourceIndex := record.SourceIndex.GetIndex()
for innerIndex, symbol := range css.AST.Symbols {
if symbol.Kind == ast.SymbolLocalCSS {
ref := ast.Ref{SourceIndex: cssSourceIndex, InnerIndex: uint32(innerIndex)}
loc := css.AST.DefineLocs[ref]
value := js_ast.Expr{Loc: loc, Data: &js_ast.ENameOfSymbol{Ref: ref}}
visited := map[ast.Ref]bool{ref: true}
var parts []js_ast.TemplatePart
var visitComposes func(ast.Ref)
visitComposes = func(ref ast.Ref) {
if composes, ok := css.AST.Composes[ref]; ok {
for _, name := range composes.Names {
if !visited[name.Ref] {
visited[name.Ref] = true
visitComposes(name.Ref)
parts = append(parts, js_ast.TemplatePart{
Value: js_ast.Expr{Loc: name.Loc, Data: &js_ast.ENameOfSymbol{Ref: name.Ref}},
TailCooked: []uint16{' '},
TailLoc: name.Loc,
})
}
}
}
}
visitComposes(ref)
if len(parts) > 0 {
value.Data = &js_ast.ETemplate{Parts: append(parts, js_ast.TemplatePart{
Value: value,
TailLoc: value.Loc,
})}
}
exports.Properties = append(exports.Properties, js_ast.Property{
Key: js_ast.Expr{Loc: loc, Data: &js_ast.EString{Value: helpers.StringToUTF16(symbol.OriginalName)}},
ValueOrNil: value,
})
}
}

s.results[sourceIndex] = parseResult{
file: scannerFile{
inputFile: graph.InputFile{
Source: source,
Loader: otherFile.inputFile.Loader,
Repr: &graph.JSRepr{
// Note: The actual export object will be filled in by the linker
AST: js_parser.LazyExportAST(s.log, source,
js_parser.OptionsFromConfig(&s.options), js_ast.Expr{Data: &exports}, ""),
js_parser.OptionsFromConfig(&s.options), js_ast.Expr{Data: js_ast.ENullShared}, ""),
CSSSourceIndex: ast.MakeIndex32(record.SourceIndex.GetIndex()),
},
},
Expand Down

0 comments on commit a0910fd

Please sign in to comment.