From a0910fd20042209b3f3beef3b3b1ecba4ca7cfa3 Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Sun, 6 Aug 2023 18:07:22 -0400 Subject: [PATCH] fix #20: implement `composes` from css modules --- CHANGELOG.md | 78 +++++++ cmd/esbuild/service.go | 4 + internal/ast/ast.go | 19 +- internal/bundler/bundler.go | 56 ++---- internal/bundler_tests/bundler_css_test.go | 190 +++++++++++++++++- .../bundler_tests/snapshots/snapshots_css.txt | 104 ++++++++-- internal/css_ast/css_ast.go | 17 +- internal/css_parser/css_decls.go | 2 +- internal/css_parser/css_decls_composes.go | 30 ++- internal/css_parser/css_parser.go | 113 ++++++----- internal/css_parser/css_parser_test.go | 8 +- internal/linker/linker.go | 129 ++++++++++-- internal/resolver/resolver.go | 20 +- lib/shared/types.ts | 1 + pkg/api/api.go | 1 + pkg/api/api_impl.go | 4 + scripts/plugin-tests.js | 26 +++ 17 files changed, 659 insertions(+), 143 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 638e303b6f3..02b7b922dfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/cmd/esbuild/service.go b/cmd/esbuild/service.go index b63f598d072..2aa8cd717cc 100644 --- a/cmd/esbuild/service.go +++ b/cmd/esbuild/service.go @@ -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" @@ -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 } diff --git a/internal/ast/ast.go b/internal/ast/ast.go index 38e0218bc81..efb8d562bd1 100644 --- a/internal/ast/ast.go +++ b/internal/ast/ast.go @@ -35,6 +35,9 @@ const ( // A CSS "@import" rule with import conditions ImportAtConditional + // A CSS "composes" declaration + ImportComposesFrom + // A CSS "url(...)" token ImportURL ) @@ -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: @@ -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 diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go index 4771fd509e6..6176fb2281f 100644 --- a/internal/bundler/bundler.go +++ b/internal/bundler/bundler.go @@ -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, @@ -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()), }, }, diff --git a/internal/bundler_tests/bundler_css_test.go b/internal/bundler_tests/bundler_css_test.go index ca33c717c83..83328f4ea0f 100644 --- a/internal/bundler_tests/bundler_css_test.go +++ b/internal/bundler_tests/bundler_css_test.go @@ -630,10 +630,16 @@ func TestImportCSSFromJSComposes(t *testing.T) { css_suite.expectBundled(t, bundled{ files: map[string]string{ "/entry.js": ` - import styles from "./styles.css" + import styles from "./styles.module.css" console.log(styles) `, - "/styles.css": ` + "/global.css": ` + .GLOBAL1 { + color: black; + } + `, + "/styles.module.css": ` + @import "global.css"; .local0 { composes: local1; :global { @@ -659,6 +665,182 @@ func TestImportCSSFromJSComposes(t *testing.T) { color: red; composes: local3; } + .fromOtherFile { + composes: local0 from "other1.module.css"; + composes: local0 from "other2.module.css"; + } + `, + "/other1.module.css": ` + .local0 { + composes: base1 base2 from "base.module.css"; + color: blue; + } + `, + "/other2.module.css": ` + .local0 { + composes: base1 base3 from "base.module.css"; + background: purple; + } + `, + "/base.module.css": ` + .base1 { + cursor: pointer; + } + .base2 { + display: inline; + } + .base3 { + float: left; + } + `, + }, + entryPaths: []string{"/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputDir: "/out", + ExtensionToLoader: map[string]config.Loader{ + ".js": config.LoaderJS, + ".css": config.LoaderCSS, + ".module.css": config.LoaderLocalCSS, + }, + }, + }) +} + +func TestImportCSSFromJSComposesFromMissingImport(t *testing.T) { + css_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/entry.js": ` + import styles from "./styles.module.css" + console.log(styles) + `, + "/styles.module.css": ` + .foo { + composes: x from "file.module.css"; + composes: y from "file.module.css"; + composes: z from "file.module.css"; + composes: x from "file.css"; + } + `, + "/file.module.css": ` + .x { + color: red; + } + :global(.y) { + color: blue; + } + `, + "/file.css": ` + .x { + color: red; + } + `, + }, + entryPaths: []string{"/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputDir: "/out", + ExtensionToLoader: map[string]config.Loader{ + ".js": config.LoaderJS, + ".module.css": config.LoaderLocalCSS, + ".css": config.LoaderCSS, + }, + }, + expectedCompileLog: `styles.module.css: ERROR: Cannot use global name "y" with "composes" +file.module.css: NOTE: The global name "y" is defined here: +NOTE: Use the ":local" selector to change "y" into a local name. +styles.module.css: ERROR: The name "z" never appears in "file.module.css" +styles.module.css: ERROR: Cannot use global name "x" with "composes" +file.css: NOTE: The global name "x" is defined here: +NOTE: Use the "local-css" loader for "file.css" to enable local names. +`, + }) +} + +func TestImportCSSFromJSComposesFromNotCSS(t *testing.T) { + css_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/entry.js": ` + import styles from "./styles.css" + console.log(styles) + `, + "/styles.css": ` + .foo { + composes: bar from "file.txt"; + } + `, + "/file.txt": ` + .bar { + color: red; + } + `, + }, + entryPaths: []string{"/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputDir: "/out", + ExtensionToLoader: map[string]config.Loader{ + ".js": config.LoaderJS, + ".css": config.LoaderLocalCSS, + ".txt": config.LoaderText, + }, + }, + expectedScanLog: `styles.css: ERROR: Cannot use "composes" with "file.txt" +NOTE: You can only use "composes" with CSS files and "file.txt" is not a CSS file (it was loaded with the "text" loader). +`, + }) +} + +func TestImportCSSFromJSComposesCircular(t *testing.T) { + css_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/entry.js": ` + import styles from "./styles.css" + console.log(styles) + `, + "/styles.css": ` + .foo { + composes: bar; + } + .bar { + composes: foo; + } + .baz { + composes: baz; + } + `, + }, + entryPaths: []string{"/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputDir: "/out", + ExtensionToLoader: map[string]config.Loader{ + ".js": config.LoaderJS, + ".css": config.LoaderLocalCSS, + }, + }, + }) +} + +func TestImportCSSFromJSComposesFromCircular(t *testing.T) { + css_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/entry.js": ` + import styles from "./styles.css" + console.log(styles) + `, + "/styles.css": ` + .foo { + composes: bar from "other.css"; + } + .bar { + composes: bar from "styles.css"; + } + `, + "/other.css": ` + .bar { + composes: foo from "styles.css"; + } `, }, entryPaths: []string{"/entry.js"}, @@ -709,7 +891,7 @@ func TestImportJSFromCSS(t *testing.T) { AbsOutputDir: "/out", }, expectedScanLog: `entry.css: ERROR: Cannot import "entry.js" into a CSS file -NOTE: An "@import" rule can only be used to import another CSS file, and "entry.js" is not a CSS file (it was loaded with the "js" loader). +NOTE: An "@import" rule can only be used to import another CSS file and "entry.js" is not a CSS file (it was loaded with the "js" loader). `, }) } @@ -730,7 +912,7 @@ func TestImportJSONFromCSS(t *testing.T) { AbsOutputDir: "/out", }, expectedScanLog: `entry.css: ERROR: Cannot import "entry.json" into a CSS file -NOTE: An "@import" rule can only be used to import another CSS file, and "entry.json" is not a CSS file (it was loaded with the "json" loader). +NOTE: An "@import" rule can only be used to import another CSS file and "entry.json" is not a CSS file (it was loaded with the "json" loader). `, }) } diff --git a/internal/bundler_tests/snapshots/snapshots_css.txt b/internal/bundler_tests/snapshots/snapshots_css.txt index 06df29e4c81..860df104954 100644 --- a/internal/bundler_tests/snapshots/snapshots_css.txt +++ b/internal/bundler_tests/snapshots/snapshots_css.txt @@ -716,36 +716,110 @@ TestIgnoreURLsInAtRulePrelude ================================================================================ TestImportCSSFromJSComposes ---------- /out/entry.js ---------- -// styles.css -var styles_default = { - local0: "GLOBAL1 GLOBAL2 styles_local4 styles_local3 styles_local1 GLOBAL3 styles_local2 GLOBAL4 styles_local0", - local1: "styles_local4 styles_local3 styles_local1", - local2: "styles_local2", - local3: "styles_local4 styles_local3", - local4: "styles_local4" +// styles.module.css +var styles_module_default = { + local0: "GLOBAL1 GLOBAL2 styles_module_local4 styles_module_local3 styles_module_local1 GLOBAL3 styles_module_local2 GLOBAL4 styles_module_local0", + local1: "styles_module_local4 styles_module_local3 styles_module_local1", + local2: "styles_module_local2", + local3: "styles_module_local4 styles_module_local3", + local4: "styles_module_local4", + fromOtherFile: "base_module_base1 base_module_base2 other1_module_local0 base_module_base3 other2_module_local0 styles_module_fromOtherFile" }; // entry.js -console.log(styles_default); +console.log(styles_module_default); ---------- /out/entry.css ---------- -/* styles.css */ -.styles_local0 { +/* global.css */ +.GLOBAL1 { + color: black; +} + +/* other1.module.css */ +.other1_module_local0 { + color: blue; +} + +/* base.module.css */ +.base_module_base1 { + cursor: pointer; +} +.base_module_base2 { + display: inline; +} +.base_module_base3 { + float: left; } -.styles_local0 { + +/* other2.module.css */ +.other2_module_local0 { + background: purple; +} + +/* styles.module.css */ +.styles_module_local0 { +} +.styles_module_local0 { background: green; } -.styles_local0 { +.styles_module_local0 { } -.styles_local3 { +.styles_module_local3 { border: 1px solid black; } -.styles_local4 { +.styles_module_local4 { opacity: 0.5; } -.styles_local1 { +.styles_module_local1 { color: red; } +.styles_module_fromOtherFile { +} + +================================================================================ +TestImportCSSFromJSComposesCircular +---------- /out/entry.js ---------- +// styles.css +var styles_default = { + foo: "styles_bar styles_foo", + bar: "styles_foo styles_bar", + baz: "styles_baz" +}; + +// entry.js +console.log(styles_default); + +---------- /out/entry.css ---------- +/* styles.css */ +.styles_foo { +} +.styles_bar { +} +.styles_baz { +} + +================================================================================ +TestImportCSSFromJSComposesFromCircular +---------- /out/entry.js ---------- +// styles.css +var styles_default = { + foo: "other_bar styles_foo", + bar: "styles_bar" +}; + +// entry.js +console.log(styles_default); + +---------- /out/entry.css ---------- +/* other.css */ +.other_bar { +} + +/* styles.css */ +.styles_foo { +} +.styles_bar { +} ================================================================================ TestImportCSSFromJSLocalAtContainer diff --git a/internal/css_ast/css_ast.go b/internal/css_ast/css_ast.go index 559f3306a5c..bda26e93e05 100644 --- a/internal/css_ast/css_ast.go +++ b/internal/css_ast/css_ast.go @@ -30,7 +30,9 @@ type AST struct { Rules []Rule SourceMapComment logger.Span ApproximateLineCount int32 - DefineLocs map[ast.Ref]logger.Loc + LocalSymbols []ast.LocRef + LocalScope map[string]ast.LocRef + GlobalScope map[string]ast.LocRef Composes map[ast.Ref]*Composes } @@ -48,6 +50,19 @@ type Composes struct { // :global .bar { color: red } // Names []ast.LocRef + + // Each of these is local in another file. For example: + // + // .foo { composes: bar from "bar.css" } + // .foo { composes: bar from url(bar.css) } + // + ImportedNames []ImportedComposesName +} + +type ImportedComposesName struct { + Alias string + AliasLoc logger.Loc + ImportRecordIndex uint32 } // We create a lot of tokens, so make sure this layout is memory-efficient. diff --git a/internal/css_parser/css_decls.go b/internal/css_parser/css_decls.go index b8efbbe5e09..ef291153634 100644 --- a/internal/css_parser/css_decls.go +++ b/internal/css_parser/css_decls.go @@ -118,7 +118,7 @@ func (p *parser) processDeclarations(rules []css_ast.Rule, composesContext *comp if !didWarnAboutComposes { didWarnAboutComposes = true p.log.AddIDWithNotes(logger.MsgID_CSS_CSSSyntaxError, logger.Warning, &p.tracker, decl.KeyRange, "\"composes\" only works inside single class selectors", - []logger.MsgData{p.tracker.MsgData(composesContext.problemRange, "This parent selector is not a single class selector because of the syntax here:")}) + []logger.MsgData{p.tracker.MsgData(composesContext.problemRange, "The parent selector is not a single class selector because of the syntax here:")}) } } else { p.handleComposesPragma(*composesContext, decl.Value) diff --git a/internal/css_parser/css_decls_composes.go b/internal/css_parser/css_decls_composes.go index 1376078d62c..d19bcb13de7 100644 --- a/internal/css_parser/css_decls_composes.go +++ b/internal/css_parser/css_decls_composes.go @@ -11,6 +11,7 @@ import ( type composesContext struct { parentRefs []ast.Ref + parentRange logger.Range problemRange logger.Range } @@ -34,9 +35,32 @@ func (p *parser) handleComposesPragma(context composesContext, tokens []css_ast. // A string or a URL is an external file if last.Kind == css_lexer.TString || last.Kind == css_lexer.TURL { - r := css_lexer.RangeOfIdentifier(p.source, t.Loc) - p.log.AddID(logger.MsgID_CSS_CSSSyntaxError, logger.Warning, &p.tracker, r, - "Using \"composes\" with names from other files is not supported yet") + var importRecordIndex uint32 + if last.Kind == css_lexer.TString { + importRecordIndex = uint32(len(p.importRecords)) + p.importRecords = append(p.importRecords, ast.ImportRecord{ + Kind: ast.ImportComposesFrom, + Path: logger.Path{Text: last.Text}, + Range: p.source.RangeOfString(last.Loc), + }) + } else { + importRecordIndex = last.PayloadIndex + p.importRecords[importRecordIndex].Kind = ast.ImportComposesFrom + } + for _, parentRef := range context.parentRefs { + composes := p.composes[parentRef] + if composes == nil { + composes = &css_ast.Composes{} + p.composes[parentRef] = composes + } + for _, name := range names { + composes.ImportedNames = append(composes.ImportedNames, css_ast.ImportedComposesName{ + ImportRecordIndex: importRecordIndex, + Alias: name.text, + AliasLoc: name.loc, + }) + } + } return } diff --git a/internal/css_parser/css_parser.go b/internal/css_parser/css_parser.go index 612b678f1a4..b83deae31a4 100644 --- a/internal/css_parser/css_parser.go +++ b/internal/css_parser/css_parser.go @@ -24,10 +24,10 @@ type parser struct { stack []css_lexer.T importRecords []ast.ImportRecord symbols []ast.Symbol - defineLocs map[ast.Ref]logger.Loc composes map[ast.Ref]*css_ast.Composes - localSymbolMap map[string]ast.Ref - globalSymbolMap map[string]ast.Ref + localSymbols []ast.LocRef + localScope map[string]ast.LocRef + globalScope map[string]ast.LocRef nestingWarnings map[logger.Loc]struct{} tracker logger.LineColumnTracker index int @@ -127,9 +127,8 @@ func Parse(log logger.Log, source logger.Source, options Options) css_ast.AST { allComments: result.AllComments, legalComments: result.LegalComments, prevError: logger.Loc{Start: -1}, - defineLocs: make(map[ast.Ref]logger.Loc), - localSymbolMap: make(map[string]ast.Ref), - globalSymbolMap: make(map[string]ast.Ref), + localScope: make(map[string]ast.LocRef), + globalScope: make(map[string]ast.LocRef), makeLocalSymbols: options.symbolMode == symbolModeLocal, } p.end = len(p.tokens) @@ -145,7 +144,9 @@ func Parse(log logger.Log, source logger.Source, options Options) css_ast.AST { ImportRecords: p.importRecords, ApproximateLineCount: result.ApproximateLineCount, SourceMapComment: result.SourceMapComment, - DefineLocs: p.defineLocs, + LocalSymbols: p.localSymbols, + LocalScope: p.localScope, + GlobalScope: p.globalScope, Composes: p.composes, } } @@ -308,33 +309,38 @@ func (p *parser) unexpected() { func (p *parser) symbolForName(loc logger.Loc, name string) ast.LocRef { var kind ast.SymbolKind - var scope map[string]ast.Ref + var scope map[string]ast.LocRef if p.makeLocalSymbols { kind = ast.SymbolLocalCSS - scope = p.globalSymbolMap + scope = p.localScope } else { kind = ast.SymbolGlobalCSS - scope = p.localSymbolMap + scope = p.globalScope } - ref, ok := scope[name] + entry, ok := scope[name] if !ok { - ref = ast.Ref{ - SourceIndex: p.source.Index, - InnerIndex: uint32(len(p.symbols)), + entry = ast.LocRef{ + Loc: loc, + Ref: ast.Ref{ + SourceIndex: p.source.Index, + InnerIndex: uint32(len(p.symbols)), + }, } p.symbols = append(p.symbols, ast.Symbol{ Kind: kind, OriginalName: name, Link: ast.InvalidRef, }) - scope[name] = ref - p.defineLocs[ref] = loc + scope[name] = entry + if kind == ast.SymbolLocalCSS { + p.localSymbols = append(p.localSymbols, entry) + } } - p.symbols[ref.InnerIndex].UseCountEstimate++ - return ast.LocRef{Loc: loc, Ref: ref} + p.symbols[entry.Ref.InnerIndex].UseCountEstimate++ + return entry } type ruleContext struct { @@ -1931,43 +1937,48 @@ func (p *parser) parseSelectorRule(isTopLevel bool, opts parseSelectorOpts) css_ } // Prepare for "composes" declarations - composesContext := composesContext{} - for _, sel := range list { - first := sel.Selectors[0] - if first.IsSingleAmpersand() && opts.composesContext != nil { - // Support code like this: - // - // .foo { - // :local { composes: bar } - // :global { composes: baz } - // } - // - composesContext.parentRefs = append(composesContext.parentRefs, opts.composesContext.parentRefs...) - } else if first.Combinator.Byte != 0 { - composesContext.problemRange = logger.Range{Loc: first.Combinator.Loc, Len: 1} - } else if first.TypeSelector != nil { - composesContext.problemRange = first.TypeSelector.Range() - } else if first.NestingSelectorLoc.IsValid() { - composesContext.problemRange = logger.Range{Loc: logger.Loc{Start: int32(first.NestingSelectorLoc.GetIndex())}, Len: 1} - } else { - for i, ss := range first.SubclassSelectors { - class, ok := ss.Data.(*css_ast.SSClass) - if i > 0 || !ok { - composesContext.problemRange = ss.Range - } else { - composesContext.parentRefs = append(composesContext.parentRefs, class.Name.Ref) + if opts.composesContext != nil && len(list) == 1 && len(list[0].Selectors) == 1 && list[0].Selectors[0].IsSingleAmpersand() { + // Support code like this: + // + // .foo { + // :local { composes: bar } + // :global { composes: baz } + // } + // + declOpts.composesContext = opts.composesContext + } else { + composesContext := composesContext{parentRange: list[0].Selectors[0].Range()} + if opts.composesContext != nil { + composesContext.problemRange = opts.composesContext.parentRange + } + for _, sel := range list { + first := sel.Selectors[0] + if first.Combinator.Byte != 0 { + composesContext.problemRange = logger.Range{Loc: first.Combinator.Loc, Len: 1} + } else if first.TypeSelector != nil { + composesContext.problemRange = first.TypeSelector.Range() + } else if first.NestingSelectorLoc.IsValid() { + composesContext.problemRange = logger.Range{Loc: logger.Loc{Start: int32(first.NestingSelectorLoc.GetIndex())}, Len: 1} + } else { + for i, ss := range first.SubclassSelectors { + class, ok := ss.Data.(*css_ast.SSClass) + if i > 0 || !ok { + composesContext.problemRange = ss.Range + } else { + composesContext.parentRefs = append(composesContext.parentRefs, class.Name.Ref) + } } } + if composesContext.problemRange.Len > 0 { + break + } + if len(sel.Selectors) > 1 { + composesContext.problemRange = sel.Selectors[1].Range() + break + } } - if composesContext.problemRange.Len > 0 { - break - } - if len(sel.Selectors) > 1 { - composesContext.problemRange = sel.Selectors[1].Range() - break - } + declOpts.composesContext = &composesContext } - declOpts.composesContext = &composesContext selector.Rules = p.parseListOfDeclarations(declOpts) p.inSelectorSubtree-- diff --git a/internal/css_parser/css_parser_test.go b/internal/css_parser/css_parser_test.go index 875c49520ac..ba0263f6d5b 100644 --- a/internal/css_parser/css_parser_test.go +++ b/internal/css_parser/css_parser_test.go @@ -2446,12 +2446,17 @@ func TestComposes(t *testing.T) { expectPrintedLocal(t, ".foo { composes: bar; color: red }", ".foo {\n color: red;\n}\n", "") expectPrintedLocal(t, ".foo { composes: bar baz; color: red }", ".foo {\n color: red;\n}\n", "") expectPrintedLocal(t, ".foo { composes: bar from global; color: red }", ".foo {\n color: red;\n}\n", "") + expectPrintedLocal(t, ".foo { composes: bar from \"file.css\"; color: red }", ".foo {\n color: red;\n}\n", "") + expectPrintedLocal(t, ".foo { composes: bar from url(file.css); color: red }", ".foo {\n color: red;\n}\n", "") + expectPrintedLocal(t, ".foo { & { composes: bar; color: red } }", ".foo {\n & {\n color: red;\n }\n}\n", "") + expectPrintedLocal(t, ".foo { :local { composes: bar; color: red } }", ".foo {\n color: red;\n}\n", "") + expectPrintedLocal(t, ".foo { :global { composes: bar; color: red } }", ".foo {\n color: red;\n}\n", "") expectPrinted(t, ".foo, .bar { composes: bar from github }", ".foo,\n.bar {\n composes: bar from github;\n}\n", "") expectPrintedLocal(t, ".foo { composes: bar from github }", ".foo {\n}\n", ": WARNING: \"composes\" declaration uses invalid location \"github\"\n") badComposes := ": WARNING: \"composes\" only works inside single class selectors\n" + - ": NOTE: This parent selector is not a single class selector because of the syntax here:\n" + ": NOTE: The parent selector is not a single class selector because of the syntax here:\n" expectPrintedLocal(t, "& { composes: bar; color: red }", "& {\n color: red;\n}\n", badComposes) expectPrintedLocal(t, ".foo& { composes: bar; color: red }", "&.foo {\n color: red;\n}\n", badComposes) expectPrintedLocal(t, ".foo.bar { composes: bar; color: red }", ".foo.bar {\n color: red;\n}\n", badComposes) @@ -2459,4 +2464,5 @@ func TestComposes(t *testing.T) { expectPrintedLocal(t, ".foo[href] { composes: bar; color: red }", ".foo[href] {\n color: red;\n}\n", badComposes) expectPrintedLocal(t, ".foo .bar { composes: bar; color: red }", ".foo .bar {\n color: red;\n}\n", badComposes) expectPrintedLocal(t, ".foo, div { composes: bar; color: red }", ".foo,\ndiv {\n color: red;\n}\n", badComposes) + expectPrintedLocal(t, ".foo { .bar { composes: foo; color: red } }", ".foo {\n .bar {\n color: red;\n }\n}\n", badComposes) } diff --git a/internal/linker/linker.go b/internal/linker/linker.go index a3902374ac7..21d606be217 100644 --- a/internal/linker/linker.go +++ b/internal/linker/linker.go @@ -1323,6 +1323,42 @@ func (c *linkerContext) scanImportsAndExports() { } } + // Validate cross-file "composes: ... from" named imports + for _, composes := range repr.AST.Composes { + for _, name := range composes.ImportedNames { + if record := repr.AST.ImportRecords[name.ImportRecordIndex]; record.SourceIndex.IsValid() { + otherFile := &c.graph.Files[record.SourceIndex.GetIndex()] + if otherRepr, ok := otherFile.InputFile.Repr.(*graph.CSSRepr); ok { + if _, ok := otherRepr.AST.LocalScope[name.Alias]; !ok { + if global, ok := otherRepr.AST.GlobalScope[name.Alias]; ok { + var hint string + if otherFile.InputFile.Loader == config.LoaderCSS { + hint = fmt.Sprintf("Use the \"local-css\" loader for %q to enable local names.", otherFile.InputFile.Source.PrettyPath) + } else { + hint = fmt.Sprintf("Use the \":local\" selector to change %q into a local name.", name.Alias) + } + c.log.AddErrorWithNotes(file.LineColumnTracker(), + css_lexer.RangeOfIdentifier(file.InputFile.Source, name.AliasLoc), + fmt.Sprintf("Cannot use global name %q with \"composes\"", name.Alias), + []logger.MsgData{ + otherFile.LineColumnTracker().MsgData( + css_lexer.RangeOfIdentifier(otherFile.InputFile.Source, global.Loc), + fmt.Sprintf("The global name %q is defined here:", name.Alias), + ), + {Text: hint}, + }) + } else { + c.log.AddError(file.LineColumnTracker(), + css_lexer.RangeOfIdentifier(file.InputFile.Source, name.AliasLoc), + fmt.Sprintf("The name %q never appears in %q", + name.Alias, otherFile.InputFile.Source.PrettyPath)) + } + } + } + } + } + } + case *graph.JSRepr: for importRecordIndex := range repr.AST.ImportRecords { record := &repr.AST.ImportRecords[importRecordIndex] @@ -1979,20 +2015,78 @@ func (c *linkerContext) generateCodeForLazyExport(sourceIndex uint32) { if len(part.Stmts) != 1 { panic("Internal error") } - lazy, ok := part.Stmts[0].Data.(*js_ast.SLazyExport) - if !ok { - panic("Internal error") + lazyValue := part.Stmts[0].Data.(*js_ast.SLazyExport).Value + + // If this JavaScript file is a stub from a CSS file, populate the exports of + // this JavaScript stub with the local names from that CSS file. This is done + // now instead of earlier because we need the whole bundle to be present. + if repr.CSSSourceIndex.IsValid() { + cssSourceIndex := repr.CSSSourceIndex.GetIndex() + if css, ok := c.graph.Files[cssSourceIndex].InputFile.Repr.(*graph.CSSRepr); ok { + exports := js_ast.EObject{} + + for _, local := range css.AST.LocalSymbols { + value := js_ast.Expr{Loc: local.Loc, Data: &js_ast.ENameOfSymbol{Ref: local.Ref}} + visited := map[ast.Ref]bool{local.Ref: true} + var parts []js_ast.TemplatePart + var visitName func(*graph.CSSRepr, ast.Ref) + var visitComposes func(*graph.CSSRepr, ast.Ref) + + visitName = func(repr *graph.CSSRepr, ref ast.Ref) { + if !visited[ref] { + visited[ref] = true + visitComposes(repr, ref) + parts = append(parts, js_ast.TemplatePart{ + Value: js_ast.Expr{Data: &js_ast.ENameOfSymbol{Ref: ref}}, + TailCooked: []uint16{' '}, + }) + } + } + + visitComposes = func(repr *graph.CSSRepr, ref ast.Ref) { + if composes, ok := repr.AST.Composes[ref]; ok { + for _, name := range composes.ImportedNames { + if record := repr.AST.ImportRecords[name.ImportRecordIndex]; record.SourceIndex.IsValid() { + otherFile := &c.graph.Files[record.SourceIndex.GetIndex()] + if otherRepr, ok := otherFile.InputFile.Repr.(*graph.CSSRepr); ok { + if otherName, ok := otherRepr.AST.LocalScope[name.Alias]; ok { + visitName(otherRepr, otherName.Ref) + } + } + } + } + + for _, name := range composes.Names { + visitName(repr, name.Ref) + } + } + } + + visitComposes(css, local.Ref) + + if len(parts) > 0 { + value.Data = &js_ast.ETemplate{Parts: append(parts, js_ast.TemplatePart{Value: value})} + } + + exports.Properties = append(exports.Properties, js_ast.Property{ + Key: js_ast.Expr{Loc: local.Loc, Data: &js_ast.EString{Value: helpers.StringToUTF16(c.graph.Symbols.Get(local.Ref).OriginalName)}}, + ValueOrNil: value, + }) + } + + lazyValue.Data = &exports + } } // Use "module.exports = value" for CommonJS-style modules if repr.AST.ExportsKind == js_ast.ExportsCommonJS { part.Stmts = []js_ast.Stmt{js_ast.AssignStmt( - js_ast.Expr{Loc: lazy.Value.Loc, Data: &js_ast.EDot{ - Target: js_ast.Expr{Loc: lazy.Value.Loc, Data: &js_ast.EIdentifier{Ref: repr.AST.ModuleRef}}, + js_ast.Expr{Loc: lazyValue.Loc, Data: &js_ast.EDot{ + Target: js_ast.Expr{Loc: lazyValue.Loc, Data: &js_ast.EIdentifier{Ref: repr.AST.ModuleRef}}, Name: "exports", - NameLoc: lazy.Value.Loc, + NameLoc: lazyValue.Loc, }}, - lazy.Value, + lazyValue, )} c.graph.GenerateSymbolImportAndUse(sourceIndex, 0, repr.AST.ModuleRef, 1, sourceIndex) return @@ -2020,8 +2114,7 @@ func (c *linkerContext) generateCodeForLazyExport(sourceIndex uint32) { } // Unwrap JSON objects into separate top-level variables - jsonValue := lazy.Value - if object, ok := jsonValue.Data.(*js_ast.EObject); ok { + if object, ok := lazyValue.Data.(*js_ast.EObject); ok { for _, property := range object.Properties { if str, ok := property.Key.Data.(*js_ast.EString); ok && (!file.IsEntryPoint() || js_ast.IsIdentifierUTF16(str.Value) || @@ -2053,10 +2146,10 @@ func (c *linkerContext) generateCodeForLazyExport(sourceIndex uint32) { } // Generate the default export - ref, partIndex := generateExport(jsonValue.Loc, file.InputFile.Source.IdentifierName+"_default", "default") - repr.AST.Parts[partIndex].Stmts = []js_ast.Stmt{{Loc: jsonValue.Loc, Data: &js_ast.SExportDefault{ - DefaultName: ast.LocRef{Loc: jsonValue.Loc, Ref: ref}, - Value: js_ast.Stmt{Loc: jsonValue.Loc, Data: &js_ast.SExpr{Value: jsonValue}}, + ref, partIndex := generateExport(lazyValue.Loc, file.InputFile.Source.IdentifierName+"_default", "default") + repr.AST.Parts[partIndex].Stmts = []js_ast.Stmt{{Loc: lazyValue.Loc, Data: &js_ast.SExportDefault{ + DefaultName: ast.LocRef{Loc: lazyValue.Loc, Ref: ref}, + Value: js_ast.Stmt{Loc: lazyValue.Loc, Data: &js_ast.SExpr{Value: lazyValue}}, }}} } @@ -3180,6 +3273,16 @@ func (c *linkerContext) findImportedFilesInCSSOrder(entryPoints []uint32) (exter // Iterate in reverse preorder (will be reversed again later) internalOrder = append(internalOrder, sourceIndex) + // Iterate in the inverse order of "composes" directives. Note that the + // order doesn't matter for these because the output order is explicitly + // undefined in the specification. + records := repr.AST.ImportRecords + for i := len(records) - 1; i >= 0; i-- { + if record := &records[i]; record.Kind == ast.ImportComposesFrom && record.SourceIndex.IsValid() { + visit(record.SourceIndex.GetIndex()) + } + } + // Iterate in the inverse order of top-level "@import" rules outer: for i := len(topLevelRules) - 1; i >= 0; i-- { diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 589d12a8305..d74c17c5ffe 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -193,7 +193,7 @@ type Resolver struct { // order but avoids the scenario where we match an import in a CSS file to a // JavaScript-related file. It's probably not perfect with plugins in the // picture but it's better than some alternatives and probably pretty good. - atImportExtensionOrder []string + cssExtensionOrder []string // A special sorted import order for imports inside packages. // @@ -240,10 +240,10 @@ type resolverQuery struct { func NewResolver(call config.APICall, fs fs.FS, log logger.Log, caches *cache.CacheSet, options *config.Options) *Resolver { // Filter out non-CSS extensions for CSS "@import" imports - atImportExtensionOrder := make([]string, 0, len(options.ExtensionOrder)) + cssExtensionOrder := make([]string, 0, len(options.ExtensionOrder)) for _, ext := range options.ExtensionOrder { - if loader, ok := options.ExtensionToLoader[ext]; !ok || loader == config.LoaderCSS { - atImportExtensionOrder = append(atImportExtensionOrder, ext) + if loader, ok := options.ExtensionToLoader[ext]; !ok || loader.IsCSS() { + cssExtensionOrder = append(cssExtensionOrder, ext) } } @@ -287,7 +287,7 @@ func NewResolver(call config.APICall, fs fs.FS, log logger.Log, caches *cache.Ca options: *options, caches: caches, dirCache: make(map[string]*dirInfo), - atImportExtensionOrder: atImportExtensionOrder, + cssExtensionOrder: cssExtensionOrder, nodeModulesExtensionOrder: nodeModulesExtensionOrder, esmConditionsDefault: esmConditionsDefault, esmConditionsImport: esmConditionsImport, @@ -842,7 +842,7 @@ func (r resolverQuery) resolveWithoutSymlinks(sourceDir string, sourceDirInfo *d // Check both relative and package paths for CSS URL tokens, with relative // paths taking precedence over package paths to match Webpack behavior. isPackagePath := IsPackagePath(importPath) - checkRelative := !isPackagePath || r.kind == ast.ImportURL || r.kind == ast.ImportAt || r.kind == ast.ImportAtConditional + checkRelative := !isPackagePath || r.kind.IsFromCSS() checkPackage := isPackagePath if checkRelative { @@ -1707,9 +1707,9 @@ func getBool(json js_ast.Expr) (bool, bool) { func (r resolverQuery) loadAsFileOrDirectory(path string) (PathPair, bool, *fs.DifferentCase) { extensionOrder := r.options.ExtensionOrder - if r.kind == ast.ImportAt || r.kind == ast.ImportAtConditional { + if r.kind.MustResolveToCSS() { // Use a special import order for CSS "@import" imports - extensionOrder = r.atImportExtensionOrder + extensionOrder = r.cssExtensionOrder } else if helpers.IsInsideNodeModules(path) { // Use a special import order for imports inside "node_modules" extensionOrder = r.nodeModulesExtensionOrder @@ -2318,8 +2318,8 @@ func (r resolverQuery) finalizeImportsExportsResult( resolvedDirInfo := r.dirInfoCached(r.fs.Dir(absResolvedPath)) base := r.fs.Base(absResolvedPath) extensionOrder := r.options.ExtensionOrder - if r.kind == ast.ImportAt || r.kind == ast.ImportAtConditional { - extensionOrder = r.atImportExtensionOrder + if r.kind.MustResolveToCSS() { + extensionOrder = r.cssExtensionOrder } if resolvedDirInfo == nil { diff --git a/lib/shared/types.ts b/lib/shared/types.ts index 4c001443a76..872cb027a21 100644 --- a/lib/shared/types.ts +++ b/lib/shared/types.ts @@ -392,6 +392,7 @@ export type ImportKind = // CSS | 'import-rule' + | 'composes-from' | 'url-token' /** Documentation: https://esbuild.github.io/plugins/#on-resolve-results */ diff --git a/pkg/api/api.go b/pkg/api/api.go index e98eca178b8..155e1843655 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -674,6 +674,7 @@ const ( ResolveJSDynamicImport ResolveJSRequireResolve ResolveCSSImportRule + ResolveCSSComposesFrom ResolveCSSURLToken ) diff --git a/pkg/api/api_impl.go b/pkg/api/api_impl.go index 843b0cac674..53504eaa418 100644 --- a/pkg/api/api_impl.go +++ b/pkg/api/api_impl.go @@ -1889,6 +1889,8 @@ func importKindToResolveKind(kind ast.ImportKind) ResolveKind { return ResolveJSRequireResolve case ast.ImportAt, ast.ImportAtConditional: return ResolveCSSImportRule + case ast.ImportComposesFrom: + return ResolveCSSComposesFrom case ast.ImportURL: return ResolveCSSURLToken default: @@ -1910,6 +1912,8 @@ func resolveKindToImportKind(kind ResolveKind) ast.ImportKind { return ast.ImportRequireResolve case ResolveCSSImportRule: return ast.ImportAt + case ResolveCSSComposesFrom: + return ast.ImportComposesFrom case ResolveCSSURLToken: return ast.ImportURL default: diff --git a/scripts/plugin-tests.js b/scripts/plugin-tests.js index fb2325f946b..ca8bbe023a0 100644 --- a/scripts/plugin-tests.js +++ b/scripts/plugin-tests.js @@ -1958,6 +1958,32 @@ let pluginTests = { assert.strictEqual(resolveKind, 'import-rule') }, + async resolveKindComposesFrom({ esbuild }) { + let resolveKind = '' + try { + await esbuild.build({ + entryPoints: ['entry'], + bundle: true, + write: false, + logLevel: 'silent', + plugins: [{ + name: 'plugin', + setup(build) { + build.onResolve({ filter: /.*/ }, args => { + if (args.importer === '') return { path: args.path, namespace: 'ns' } + else resolveKind = args.kind + }) + build.onLoad({ filter: /.*/, namespace: 'ns' }, () => { + return { contents: `.foo { composes: bar from 'entry' }`, loader: 'local-css' } + }) + }, + }], + }) + } catch (e) { + } + assert.strictEqual(resolveKind, 'composes-from') + }, + async resolveKindURLToken({ esbuild }) { let resolveKind = '' try {