Skip to content

Commit a0910fd

Browse files
committed
fix #20: implement composes from css modules
1 parent a470f0a commit a0910fd

File tree

17 files changed

+659
-143
lines changed

17 files changed

+659
-143
lines changed

CHANGELOG.md

+78
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,84 @@
22

33
## Unreleased
44

5+
* Implement `composes` from CSS modules ([#20](https://github.com/evanw/esbuild/issues/20))
6+
7+
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:
8+
9+
```js
10+
// app.js
11+
import { submit } from './style.css'
12+
const div = document.createElement('div')
13+
div.className = submit
14+
document.body.appendChild(div)
15+
```
16+
17+
```css
18+
/* style.css */
19+
.button {
20+
composes: pulse from "anim.css";
21+
display: inline-block;
22+
}
23+
.submit {
24+
composes: button;
25+
font-weight: bold;
26+
}
27+
```
28+
29+
```css
30+
/* anim.css */
31+
@keyframes pulse {
32+
from, to { opacity: 1 }
33+
50% { opacity: 0.5 }
34+
}
35+
.pulse {
36+
animation: 2s ease-in-out infinite pulse;
37+
}
38+
```
39+
40+
Bundling this with esbuild using `--bundle --outdir=dist --loader:.css=local-css` now gives the following:
41+
42+
```js
43+
(() => {
44+
// style.css
45+
var submit = "anim_pulse style_button style_submit";
46+
47+
// app.js
48+
var div = document.createElement("div");
49+
div.className = submit;
50+
document.body.appendChild(div);
51+
})();
52+
```
53+
54+
```css
55+
/* anim.css */
56+
@keyframes anim_pulse {
57+
from, to {
58+
opacity: 1;
59+
}
60+
50% {
61+
opacity: 0.5;
62+
}
63+
}
64+
.anim_pulse {
65+
animation: 2s ease-in-out infinite anim_pulse;
66+
}
67+
68+
/* style.css */
69+
.style_button {
70+
display: inline-block;
71+
}
72+
.style_submit {
73+
font-weight: bold;
74+
}
75+
```
76+
77+
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.
78+
79+
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.
80+
81+
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.
82+
583
* Fix non-determinism with `tsconfig.json` and symlinks ([#3284](https://github.com/evanw/esbuild/issues/3284))
684

785
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.

cmd/esbuild/service.go

+4
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,8 @@ func resolveKindToString(kind api.ResolveKind) string {
789789
// CSS
790790
case api.ResolveCSSImportRule:
791791
return "import-rule"
792+
case api.ResolveCSSComposesFrom:
793+
return "composes-from"
792794
case api.ResolveCSSURLToken:
793795
return "url-token"
794796

@@ -815,6 +817,8 @@ func stringToResolveKind(kind string) (api.ResolveKind, bool) {
815817
// CSS
816818
case "import-rule":
817819
return api.ResolveCSSImportRule, true
820+
case "composes-from":
821+
return api.ResolveCSSComposesFrom, true
818822
case "url-token":
819823
return api.ResolveCSSURLToken, true
820824
}

internal/ast/ast.go

+18-1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ const (
3535
// A CSS "@import" rule with import conditions
3636
ImportAtConditional
3737

38+
// A CSS "composes" declaration
39+
ImportComposesFrom
40+
3841
// A CSS "url(...)" token
3942
ImportURL
4043
)
@@ -51,6 +54,8 @@ func (kind ImportKind) StringForMetafile() string {
5154
return "require-resolve"
5255
case ImportAt, ImportAtConditional:
5356
return "import-rule"
57+
case ImportComposesFrom:
58+
return "composes-from"
5459
case ImportURL:
5560
return "url-token"
5661
case ImportEntryPoint:
@@ -61,7 +66,19 @@ func (kind ImportKind) StringForMetafile() string {
6166
}
6267

6368
func (kind ImportKind) IsFromCSS() bool {
64-
return kind == ImportAt || kind == ImportURL
69+
switch kind {
70+
case ImportAt, ImportAtConditional, ImportComposesFrom, ImportURL:
71+
return true
72+
}
73+
return false
74+
}
75+
76+
func (kind ImportKind) MustResolveToCSS() bool {
77+
switch kind {
78+
case ImportAt, ImportAtConditional, ImportComposesFrom:
79+
return true
80+
}
81+
return false
6582
}
6683

6784
type ImportRecordFlags uint16

internal/bundler/bundler.go

+13-43
Original file line numberDiff line numberDiff line change
@@ -2009,13 +2009,23 @@ func (s *scanner) processScannedFiles(entryPointMeta []graph.EntryPoint) []scann
20092009
}
20102010

20112011
switch record.Kind {
2012+
case ast.ImportComposesFrom:
2013+
// Using a JavaScript file with CSS "composes" is not allowed
2014+
if _, ok := otherFile.inputFile.Repr.(*graph.JSRepr); ok && otherFile.inputFile.Loader != config.LoaderEmpty {
2015+
s.log.AddErrorWithNotes(&tracker, record.Range,
2016+
fmt.Sprintf("Cannot use \"composes\" with %q", otherFile.inputFile.Source.PrettyPath),
2017+
[]logger.MsgData{{Text: fmt.Sprintf(
2018+
"You can only use \"composes\" with CSS files and %q is not a CSS file (it was loaded with the %q loader).",
2019+
otherFile.inputFile.Source.PrettyPath, config.LoaderToString[otherFile.inputFile.Loader])}})
2020+
}
2021+
20122022
case ast.ImportAt, ast.ImportAtConditional:
20132023
// Using a JavaScript file with CSS "@import" is not allowed
20142024
if _, ok := otherFile.inputFile.Repr.(*graph.JSRepr); ok && otherFile.inputFile.Loader != config.LoaderEmpty {
20152025
s.log.AddErrorWithNotes(&tracker, record.Range,
20162026
fmt.Sprintf("Cannot import %q into a CSS file", otherFile.inputFile.Source.PrettyPath),
20172027
[]logger.MsgData{{Text: fmt.Sprintf(
2018-
"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).",
2028+
"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).",
20192029
otherFile.inputFile.Source.PrettyPath, config.LoaderToString[otherFile.inputFile.Loader])}})
20202030
} else if record.Kind == ast.ImportAtConditional {
20212031
s.log.AddError(&tracker, record.Range,
@@ -2067,55 +2077,15 @@ func (s *scanner) processScannedFiles(entryPointMeta []graph.EntryPoint) []scann
20672077
sourceIndex := s.allocateSourceIndex(stubKey, cache.SourceIndexJSStubForCSS)
20682078
source := otherFile.inputFile.Source
20692079
source.Index = sourceIndex
2070-
2071-
// Export all local CSS names for JavaScript to use
2072-
exports := js_ast.EObject{}
2073-
cssSourceIndex := record.SourceIndex.GetIndex()
2074-
for innerIndex, symbol := range css.AST.Symbols {
2075-
if symbol.Kind == ast.SymbolLocalCSS {
2076-
ref := ast.Ref{SourceIndex: cssSourceIndex, InnerIndex: uint32(innerIndex)}
2077-
loc := css.AST.DefineLocs[ref]
2078-
value := js_ast.Expr{Loc: loc, Data: &js_ast.ENameOfSymbol{Ref: ref}}
2079-
visited := map[ast.Ref]bool{ref: true}
2080-
var parts []js_ast.TemplatePart
2081-
var visitComposes func(ast.Ref)
2082-
visitComposes = func(ref ast.Ref) {
2083-
if composes, ok := css.AST.Composes[ref]; ok {
2084-
for _, name := range composes.Names {
2085-
if !visited[name.Ref] {
2086-
visited[name.Ref] = true
2087-
visitComposes(name.Ref)
2088-
parts = append(parts, js_ast.TemplatePart{
2089-
Value: js_ast.Expr{Loc: name.Loc, Data: &js_ast.ENameOfSymbol{Ref: name.Ref}},
2090-
TailCooked: []uint16{' '},
2091-
TailLoc: name.Loc,
2092-
})
2093-
}
2094-
}
2095-
}
2096-
}
2097-
visitComposes(ref)
2098-
if len(parts) > 0 {
2099-
value.Data = &js_ast.ETemplate{Parts: append(parts, js_ast.TemplatePart{
2100-
Value: value,
2101-
TailLoc: value.Loc,
2102-
})}
2103-
}
2104-
exports.Properties = append(exports.Properties, js_ast.Property{
2105-
Key: js_ast.Expr{Loc: loc, Data: &js_ast.EString{Value: helpers.StringToUTF16(symbol.OriginalName)}},
2106-
ValueOrNil: value,
2107-
})
2108-
}
2109-
}
2110-
21112080
s.results[sourceIndex] = parseResult{
21122081
file: scannerFile{
21132082
inputFile: graph.InputFile{
21142083
Source: source,
21152084
Loader: otherFile.inputFile.Loader,
21162085
Repr: &graph.JSRepr{
2086+
// Note: The actual export object will be filled in by the linker
21172087
AST: js_parser.LazyExportAST(s.log, source,
2118-
js_parser.OptionsFromConfig(&s.options), js_ast.Expr{Data: &exports}, ""),
2088+
js_parser.OptionsFromConfig(&s.options), js_ast.Expr{Data: js_ast.ENullShared}, ""),
21192089
CSSSourceIndex: ast.MakeIndex32(record.SourceIndex.GetIndex()),
21202090
},
21212091
},

0 commit comments

Comments
 (0)