Skip to content

Commit

Permalink
css: a basic implementation of local composes
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Aug 6, 2023
1 parent 4e6dcbb commit a470f0a
Show file tree
Hide file tree
Showing 11 changed files with 336 additions and 18 deletions.
28 changes: 27 additions & 1 deletion internal/bundler/bundler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2075,9 +2075,35 @@ func (s *scanner) processScannedFiles(entryPointMeta []graph.EntryPoint) []scann
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: js_ast.Expr{Loc: loc, Data: &js_ast.ENameOfSymbol{Ref: ref}},
ValueOrNil: value,
})
}
}
Expand Down
47 changes: 47 additions & 0 deletions internal/bundler_tests/bundler_css_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,53 @@ func TestImportCSSFromJSNthIndexLocal(t *testing.T) {
})
}

func TestImportCSSFromJSComposes(t *testing.T) {
css_suite.expectBundled(t, bundled{
files: map[string]string{
"/entry.js": `
import styles from "./styles.css"
console.log(styles)
`,
"/styles.css": `
.local0 {
composes: local1;
:global {
composes: GLOBAL1 GLOBAL2;
}
}
.local0 {
composes: GLOBAL2 GLOBAL3 from global;
composes: local1 local2;
background: green;
}
.local0 :global {
composes: GLOBAL4;
}
.local3 {
border: 1px solid black;
composes: local4;
}
.local4 {
opacity: 0.5;
}
.local1 {
color: red;
composes: local3;
}
`,
},
entryPaths: []string{"/entry.js"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/out",
ExtensionToLoader: map[string]config.Loader{
".js": config.LoaderJS,
".css": config.LoaderLocalCSS,
},
},
})
}

func TestImportCSSFromJSWriteToStdout(t *testing.T) {
css_suite.expectBundled(t, bundled{
files: map[string]string{
Expand Down
34 changes: 34 additions & 0 deletions internal/bundler_tests/snapshots/snapshots_css.txt
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,40 @@ 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"
};

// entry.js
console.log(styles_default);

---------- /out/entry.css ----------
/* styles.css */
.styles_local0 {
}
.styles_local0 {
background: green;
}
.styles_local0 {
}
.styles_local3 {
border: 1px solid black;
}
.styles_local4 {
opacity: 0.5;
}
.styles_local1 {
color: red;
}

================================================================================
TestImportCSSFromJSLocalAtContainer
---------- /out/entry.js ----------
Expand Down
17 changes: 17 additions & 0 deletions internal/css_ast/css_ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,23 @@ type AST struct {
SourceMapComment logger.Span
ApproximateLineCount int32
DefineLocs map[ast.Ref]logger.Loc
Composes map[ast.Ref]*Composes
}

type Composes struct {
// Note that each of these can be either local or global. Local examples:
//
// .foo { composes: bar }
// .bar { color: red }
//
// Global examples:
//
// .foo { composes: bar from global }
// .foo :global { composes: bar }
// .foo { :global { composes: bar } }
// :global .bar { color: red }
//
Names []ast.LocRef
}

// We create a lot of tokens, so make sure this layout is memory-efficient.
Expand Down
2 changes: 2 additions & 0 deletions internal/css_ast/css_decl_table.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ const (
DColumnSpan
DColumnWidth
DColumns
DComposes
DContainer
DContainerName
DContainerType
Expand Down Expand Up @@ -446,6 +447,7 @@ var KnownDeclarations = map[string]D{
"column-span": DColumnSpan,
"column-width": DColumnWidth,
"columns": DColumns,
"composes": DComposes,
"container": DContainer,
"container-name": DContainerName,
"container-type": DContainerType,
Expand Down
27 changes: 26 additions & 1 deletion internal/css_parser/css_decls.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,13 @@ func compactTokenQuad(a css_ast.Token, b css_ast.Token, c css_ast.Token, d css_a
return tokens
}

func (p *parser) processDeclarations(rules []css_ast.Rule) (rewrittenRules []css_ast.Rule) {
func (p *parser) processDeclarations(rules []css_ast.Rule, composesContext *composesContext) (rewrittenRules []css_ast.Rule) {
margin := boxTracker{key: css_ast.DMargin, keyText: "margin", allowAuto: true}
padding := boxTracker{key: css_ast.DPadding, keyText: "padding", allowAuto: false}
inset := boxTracker{key: css_ast.DInset, keyText: "inset", allowAuto: true}
borderRadius := borderRadiusTracker{}
rewrittenRules = make([]css_ast.Rule, 0, len(rules))
didWarnAboutComposes := false
var declarationKeys map[string]struct{}

// Don't automatically generate the "inset" property if it's not supported
Expand All @@ -101,6 +102,30 @@ func (p *parser) processDeclarations(rules []css_ast.Rule) (rewrittenRules []css
}

switch decl.Key {
case css_ast.DComposes:
// Only process "composes" directives if we're in "local-css" or
// "global-css" mode. In these cases, "composes" directives will always
// be removed (because they are being processed) even if they contain
// errors. Otherwise we leave "composes" directives there untouched and
// don't check them for errors.
if p.options.symbolMode != symbolModeDisabled {
if composesContext == nil {
if !didWarnAboutComposes {
didWarnAboutComposes = true
p.log.AddID(logger.MsgID_CSS_CSSSyntaxError, logger.Warning, &p.tracker, decl.KeyRange, "\"composes\" is not valid here")
}
} else if composesContext.problemRange.Len > 0 {
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:")})
}
} else {
p.handleComposesPragma(*composesContext, decl.Value)
}
rewrittenRules = rewrittenRules[:len(rewrittenRules)-1]
}

case css_ast.DBackgroundColor,
css_ast.DBorderBlockEndColor,
css_ast.DBorderBlockStartColor,
Expand Down
90 changes: 90 additions & 0 deletions internal/css_parser/css_decls_composes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package css_parser

import (
"fmt"

"github.com/evanw/esbuild/internal/ast"
"github.com/evanw/esbuild/internal/css_ast"
"github.com/evanw/esbuild/internal/css_lexer"
"github.com/evanw/esbuild/internal/logger"
)

type composesContext struct {
parentRefs []ast.Ref
problemRange logger.Range
}

func (p *parser) handleComposesPragma(context composesContext, tokens []css_ast.Token) {
type nameWithLoc struct {
loc logger.Loc
text string
}
var names []nameWithLoc
fromGlobal := false

if p.composes == nil {
p.composes = make(map[ast.Ref]*css_ast.Composes)
}

for i, t := range tokens {
if t.Kind == css_lexer.TIdent {
// Check for a "from" clause at the end
if t.Text == "from" && i+2 == len(tokens) {
last := tokens[i+1]

// 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")
return
}

// An identifier must be "global"
if last.Kind == css_lexer.TIdent {
if last.Text == "global" {
fromGlobal = true
break
}

p.log.AddID(logger.MsgID_CSS_CSSSyntaxError, logger.Warning, &p.tracker, css_lexer.RangeOfIdentifier(p.source, last.Loc),
fmt.Sprintf("\"composes\" declaration uses invalid location %q", last.Text))
p.prevError = t.Loc
return
}
}

names = append(names, nameWithLoc{t.Loc, t.Text})
continue
}

// Any unexpected tokens are a syntax error
var text string
switch t.Kind {
case css_lexer.TURL, css_lexer.TBadURL, css_lexer.TString, css_lexer.TUnterminatedString:
text = fmt.Sprintf("Unexpected %s", t.Kind.String())
default:
text = fmt.Sprintf("Unexpected %q", t.Text)
}
p.log.AddID(logger.MsgID_CSS_CSSSyntaxError, logger.Warning, &p.tracker, logger.Range{Loc: t.Loc}, text)
p.prevError = t.Loc
return
}

// If we get here, all of these names are not references to another file
old := p.makeLocalSymbols
if fromGlobal {
p.makeLocalSymbols = false
}
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.Names = append(composes.Names, p.symbolForName(name.loc, name.text))
}
}
p.makeLocalSymbols = old
}
Loading

0 comments on commit a470f0a

Please sign in to comment.