Skip to content

Commit 96519f2

Browse files
committed
Add css.Build
Fixes #14609 Fixes #14613
1 parent c47ec23 commit 96519f2

File tree

10 files changed

+216
-23
lines changed

10 files changed

+216
-23
lines changed

internal/js/esbuild/batch.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ var (
7272
func NewBatcherClient(deps *deps.Deps) (js.BatcherClient, error) {
7373
c := &BatcherClient{
7474
d: deps,
75-
buildClient: NewBuildClient(deps.BaseFs.Assets, deps.ResourceSpec),
75+
buildClient: NewBuildClient(deps.BaseFs.Assets, deps.ResourceSpec, false),
7676
createClient: create.New(deps.ResourceSpec),
7777
batcherStore: hmaps.NewCache[string, js.Batcher](),
7878
bundlesStore: hmaps.NewCache[string, js.BatchPackage](),

internal/js/esbuild/build.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,19 @@ import (
3232
)
3333

3434
// NewBuildClient creates a new BuildClient.
35-
func NewBuildClient(fs *filesystems.SourceFilesystem, rs *resources.Spec) *BuildClient {
35+
func NewBuildClient(fs *filesystems.SourceFilesystem, rs *resources.Spec, cssMode bool) *BuildClient {
3636
return &BuildClient{
37-
rs: rs,
38-
sfs: fs,
37+
rs: rs,
38+
sfs: fs,
39+
CssMode: cssMode,
3940
}
4041
}
4142

4243
// BuildClient is a client for building JavaScript resources using esbuild.
4344
type BuildClient struct {
44-
rs *resources.Spec
45-
sfs *filesystems.SourceFilesystem
45+
rs *resources.Spec
46+
sfs *filesystems.SourceFilesystem
47+
CssMode bool
4648
}
4749

4850
// Build builds the given JavaScript resources using esbuild with the given options.

internal/js/esbuild/options.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ var (
4848
"es2024": api.ES2024,
4949
}
5050

51+
nameEngine = map[string]api.EngineName{
52+
"chrome": api.EngineChrome,
53+
"deno": api.EngineDeno,
54+
"edge": api.EngineEdge,
55+
"firefox": api.EngineFirefox,
56+
"hermes": api.EngineHermes,
57+
"ie": api.EngineIE,
58+
"ios": api.EngineIOS,
59+
"node": api.EngineNode,
60+
"opera": api.EngineOpera,
61+
"rhino": api.EngineRhino,
62+
"safari": api.EngineSafari,
63+
}
64+
5165
// source names: https://github.com/evanw/esbuild/blob/9eca46464ed5615cb36a3beb3f7a7b9a8ffbe7cf/internal/config/config.go#L208
5266
nameLoader = map[string]api.Loader{
5367
"none": api.LoaderNone,
@@ -117,6 +131,11 @@ type ExternalOptions struct {
117131
// Default is esnext.
118132
Target string
119133

134+
// Engines to target, e.g. "chrome58" or "node12".
135+
// TODO1 sensible default if not set. For css only.
136+
// See https://esbuild.github.io/api/#target
137+
Engines []string
138+
120139
// The output format.
121140
// One of: iife, cjs, esm
122141
// Default is to esm.
@@ -200,6 +219,7 @@ type InternalOptions struct {
200219

201220
Stdin bool // Set to true to pass in the entry point as a byte slice.
202221
Splitting bool
222+
IsCSS bool // Entry point is CSS.
203223
TsConfig string
204224
EntryPoints []string
205225
ImportOnResolveFunc func(string, api.OnResolveArgs) string
@@ -224,6 +244,22 @@ func (opts *Options) compile() (err error) {
224244
return
225245
}
226246

247+
var engines []api.Engine
248+
249+
OUTER:
250+
for _, value := range opts.Engines {
251+
for engine, name := range nameEngine {
252+
if strings.HasPrefix(value, engine) {
253+
version := value[len(engine):]
254+
if version == "" {
255+
return fmt.Errorf("invalid engine version: %q", value)
256+
}
257+
engines = append(engines, api.Engine{Name: name, Version: version})
258+
continue OUTER
259+
}
260+
}
261+
}
262+
227263
var loaders map[string]api.Loader
228264
if opts.Loaders != nil {
229265
loaders = make(map[string]api.Loader)
@@ -252,6 +288,8 @@ func (opts *Options) compile() (err error) {
252288
loader = api.LoaderTSX
253289
case media.Builtin.JSXType.SubType:
254290
loader = api.LoaderJSX
291+
case media.Builtin.CSSType.SubType:
292+
loader = api.LoaderCSS
255293
default:
256294
err = fmt.Errorf("unsupported Media Type: %q", opts.MediaType)
257295
return
@@ -343,6 +381,7 @@ func (opts *Options) compile() (err error) {
343381
AbsWorkingDir: opts.AbsWorkingDir,
344382

345383
Target: target,
384+
Engines: engines,
346385
Format: format,
347386
Platform: platform,
348387
Sourcemap: sourceMap,
@@ -394,6 +433,10 @@ func (o Options) loaderFromFilename(filename string) api.Loader {
394433
if found {
395434
return l
396435
}
436+
if o.IsCSS {
437+
// For CSS builds, handling, default to the file loader for unknown extensions.
438+
return api.LoaderFile
439+
}
397440
return api.LoaderJS
398441
}
399442

internal/js/esbuild/resolve.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,14 @@ var extensionToLoaderMap = map[string]api.Loader{
5858
".txt": api.LoaderText,
5959
}
6060

61+
var cssExtensionMap = map[string]bool{
62+
".css": true,
63+
}
64+
6165
// This is a common sub-set of ESBuild's default extensions.
62-
// We assume that imports of JSON, CSS etc. will be using their full
66+
// We assume that imports of JSON etc. will be using their full
6367
// name with extension.
64-
var commonExtensions = []string{".js", ".ts", ".tsx", ".jsx"}
68+
var commonExtensions = []string{".js", ".ts", ".tsx", ".jsx", ".css"}
6569

6670
// ResolveComponent resolves a component using the given resolver.
6771
func ResolveComponent[T any](impPath string, resolve func(string) (v T, found, isDir bool)) (v T, found bool) {
@@ -93,6 +97,7 @@ func ResolveComponent[T any](impPath string, resolve func(string) (v T, found, i
9397
}
9498

9599
base := filepath.Base(impPath)
100+
96101
if base == "index" {
97102
// try index.esm.js etc.
98103
v, found, _ = findFirst(impPath + ".esm")
@@ -159,6 +164,7 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana
159164

160165
resolveImport := func(args api.OnResolveArgs) (api.OnResolveResult, error) {
161166
impPath := args.Path
167+
162168
shimmed := false
163169
if opts.Shims != nil {
164170
override, found := opts.Shims[impPath]
@@ -182,9 +188,9 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana
182188
}
183189

184190
importer := args.Importer
185-
186191
isStdin := importer == stdinImporter
187192
var relDir string
193+
188194
if !isStdin {
189195
if after, ok := strings.CutPrefix(importer, PrefixHugoVirtual); ok {
190196
relDir = filepath.Dir(after)
@@ -225,6 +231,7 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana
225231
// in server mode, we may get stale entries on renames etc.,
226232
// but that shouldn't matter too much.
227233
rs.JSConfigBuilder.AddSourceRoot(m.SourceRoot)
234+
228235
return api.OnResolveResult{Path: m.Filename, Namespace: NsHugoImport}, nil
229236
}
230237

resources/resource_transformers/js/build.go

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ package js
1515

1616
import (
1717
"path"
18+
"path/filepath"
1819
"regexp"
1920

2021
"github.com/evanw/esbuild/pkg/api"
@@ -31,9 +32,9 @@ type Client struct {
3132
}
3233

3334
// New creates a new client context.
34-
func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) *Client {
35+
func New(fs *filesystems.SourceFilesystem, rs *resources.Spec, cssMode bool) *Client {
3536
return &Client{
36-
c: esbuild.NewBuildClient(fs, rs),
37+
c: esbuild.NewBuildClient(fs, rs, cssMode),
3738
}
3839
}
3940

@@ -56,7 +57,12 @@ func (c *Client) transform(opts esbuild.Options, transformCtx *resources.Resourc
5657
return result, err
5758
}
5859

59-
if opts.ExternalOptions.SourceMap == "linked" || opts.ExternalOptions.SourceMap == "external" {
60+
hasSourceMap := opts.ExternalOptions.SourceMap == "linked" || opts.ExternalOptions.SourceMap == "external"
61+
62+
var idxContentFiles int
63+
64+
if hasSourceMap {
65+
idxContentFiles++
6066
content := string(result.OutputFiles[1].Contents)
6167
if opts.ExternalOptions.SourceMap == "linked" {
6268
symPath := path.Base(transformCtx.OutPath) + ".map"
@@ -67,16 +73,25 @@ func (c *Client) transform(opts esbuild.Options, transformCtx *resources.Resourc
6773
if err = transformCtx.PublishSourceMap(string(result.OutputFiles[0].Contents)); err != nil {
6874
return result, err
6975
}
70-
_, err := transformCtx.To.Write([]byte(content))
71-
if err != nil {
72-
return result, err
73-
}
74-
} else {
75-
_, err := transformCtx.To.Write(result.OutputFiles[0].Contents)
76-
if err != nil {
76+
}
77+
78+
// Any files loaded with the file loader comes next.
79+
// TODO1 verify that we can rely on order here, else look in result.Metafile.
80+
outDir := path.Dir(transformCtx.OutPath)
81+
for i := idxContentFiles; i < len(result.OutputFiles)-1; i++ {
82+
file := result.OutputFiles[i]
83+
basePath := path.Base(filepath.ToSlash(file.Path))
84+
target := path.Join(outDir, basePath)
85+
if err = transformCtx.PublishTo(target, string(file.Contents)); err != nil {
7786
return result, err
7887
}
88+
}
7989

90+
// Write the main output.
91+
main := result.OutputFiles[len(result.OutputFiles)-1]
92+
if _, err = transformCtx.To.Write(main.Contents); err != nil {
93+
return result, err
8094
}
95+
8196
return result, nil
8297
}

resources/resource_transformers/js/transform.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,18 @@ type buildTransformation struct {
3030
}
3131

3232
func (t *buildTransformation) Key() internal.ResourceTransformationKey {
33-
return internal.NewResourceTransformationKey("jsbuild", t.optsm)
33+
key := "jsbuild"
34+
if t.c.c.CssMode {
35+
key = "cssbuild"
36+
}
37+
return internal.NewResourceTransformationKey(key, t.optsm)
3438
}
3539

3640
func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx) error {
3741
ctx.OutMediaType = media.Builtin.JavascriptType
42+
if ctx.InMediaType == media.Builtin.CSSType {
43+
ctx.OutMediaType = media.Builtin.CSSType
44+
}
3845

3946
var opts esbuild.Options
4047

@@ -49,7 +56,7 @@ func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx
4956
if opts.TargetPath != "" {
5057
ctx.OutPath = opts.TargetPath
5158
} else {
52-
ctx.ReplaceOutPathExtension(".js")
59+
ctx.ReplaceOutPathExtension(ctx.OutMediaType.FirstSuffix.FullSuffix)
5360
}
5461

5562
src, err := io.ReadAll(ctx.From)
@@ -61,6 +68,7 @@ func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx
6168
opts.Contents = string(src)
6269
opts.MediaType = ctx.InMediaType
6370
opts.Stdin = true
71+
opts.IsCSS = t.c.c.CssMode
6472

6573
_, err = t.c.transform(opts, ctx)
6674

resources/transform.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,11 @@ func (ctx *ResourceTransformationCtx) AddOutPathIdentifier(identifier string) {
148148
// with the ".map" extension added.
149149
func (ctx *ResourceTransformationCtx) PublishSourceMap(content string) error {
150150
target := ctx.OutPath + ".map"
151+
return ctx.PublishTo(target, content)
152+
}
153+
154+
// PublishTo writes the content to the target folder.
155+
func (ctx *ResourceTransformationCtx) PublishTo(target string, content string) error {
151156
f, err := ctx.OpenResourcePublisher(target)
152157
if err != nil {
153158
return err

tpl/css/build_integration_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright 2026 The Hugo Authors. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package css_test
15+
16+
import (
17+
"testing"
18+
19+
"github.com/gohugoio/hugo/hugolib"
20+
)
21+
22+
func TestCSSBuild(t *testing.T) {
23+
files := `
24+
-- hugo.toml --
25+
-- assets/a/pixel.png --
26+
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
27+
-- assets/css/main.css --
28+
@import "tailwindcss";
29+
@import url('https://example.org/foo.css');
30+
@import "./foo.css";
31+
@import "./bar.css" layer(mylayer);
32+
@import "./bar.css" layer;
33+
@import './baz.css' screen and (min-width: 800px);
34+
@import './qux.css' supports(display: grid);
35+
body {
36+
background-image: url("a/pixel.png");
37+
}
38+
.mask1 {
39+
mask-image: url(a/pixel.png);
40+
mask-repeat: no-repeat;
41+
}
42+
-- assets/css/foo.css --
43+
p { background: red; }
44+
-- assets/css/bar.css --
45+
div { background: blue; }
46+
-- assets/css/baz.css --
47+
span { background: green; }
48+
-- assets/css/qux.css --
49+
article { background: yellow; }
50+
-- layouts/home.html --
51+
{{ with resources.Get "css/main.css" }}
52+
{{ $engines := slice "chrome80" "firefox73" "safari13" "edge80" }}
53+
{{ with . | css.Build (dict "minify" true "engines" $engines "loaders" (dict ".png" "dataurl") "externals" (slice "tailwindcss")) }}
54+
<link rel="stylesheet" href="{{ .RelPermalink }}" />
55+
{{ end }}
56+
{{ end }}
57+
`
58+
59+
b := hugolib.Test(t, files, hugolib.TestOptOsFs())
60+
b.AssertFileContent("public/css/main.css", `webkit`)
61+
}
62+
63+
func TestCSSBuildLoaders(t *testing.T) {
64+
files := `
65+
-- hugo.toml --
66+
-- assets/a/pixel.png --
67+
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
68+
-- assets/css/main.css --
69+
body {
70+
background-image: url("a/pixel.png");
71+
}
72+
-- layouts/home.html --
73+
{{ with resources.Get "css/main.css" }}
74+
{{ with . | css.Build (dict "minify" true "loaders_" (dict ".png" "file")) }}
75+
<link rel="stylesheet" href="{{ .RelPermalink }}" />
76+
{{ end }}
77+
{{ end }}
78+
`
79+
80+
b := hugolib.Test(t, files, hugolib.TestOptOsFs())
81+
b.AssertFileContent("public/css/main.css", `./pixel-NJRUOINY.png`)
82+
b.AssertFileExists("public/css/pixel-NJRUOINY.png", true)
83+
}

0 commit comments

Comments
 (0)