Skip to content

Commit 8b2ecf5

Browse files
committed
Add css.Build
Fixes #14609
1 parent c47ec23 commit 8b2ecf5

File tree

9 files changed

+154
-14
lines changed

9 files changed

+154
-14
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.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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 esbuild_test
15+
16+
import (
17+
"testing"
18+
19+
"github.com/gohugoio/hugo/hugolib"
20+
)
21+
22+
func TestBundleCSS(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+
}

internal/js/esbuild/options.go

Lines changed: 38 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.
@@ -224,6 +243,22 @@ func (opts *Options) compile() (err error) {
224243
return
225244
}
226245

246+
var engines []api.Engine
247+
248+
OUTER:
249+
for _, value := range opts.Engines {
250+
for engine, name := range nameEngine {
251+
if strings.HasPrefix(value, engine) {
252+
version := value[len(engine):]
253+
if version == "" {
254+
return fmt.Errorf("invalid engine version: %q", value)
255+
}
256+
engines = append(engines, api.Engine{Name: name, Version: version})
257+
continue OUTER
258+
}
259+
}
260+
}
261+
227262
var loaders map[string]api.Loader
228263
if opts.Loaders != nil {
229264
loaders = make(map[string]api.Loader)
@@ -252,6 +287,8 @@ func (opts *Options) compile() (err error) {
252287
loader = api.LoaderTSX
253288
case media.Builtin.JSXType.SubType:
254289
loader = api.LoaderJSX
290+
case media.Builtin.CSSType.SubType:
291+
loader = api.LoaderCSS
255292
default:
256293
err = fmt.Errorf("unsupported Media Type: %q", opts.MediaType)
257294
return
@@ -343,6 +380,7 @@ func (opts *Options) compile() (err error) {
343380
AbsWorkingDir: opts.AbsWorkingDir,
344381

345382
Target: target,
383+
Engines: engines,
346384
Format: format,
347385
Platform: platform,
348386
Sourcemap: sourceMap,

internal/js/esbuild/resolve.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ var extensionToLoaderMap = map[string]api.Loader{
6161
// This is a common sub-set of ESBuild's default extensions.
6262
// We assume that imports of JSON, CSS etc. will be using their full
6363
// name with extension.
64-
var commonExtensions = []string{".js", ".ts", ".tsx", ".jsx"}
64+
var commonExtensions = []string{".js", ".ts", ".tsx", ".jsx", ".css"}
6565

6666
// ResolveComponent resolves a component using the given resolver.
6767
func ResolveComponent[T any](impPath string, resolve func(string) (v T, found, isDir bool)) (v T, found bool) {
@@ -93,6 +93,7 @@ func ResolveComponent[T any](impPath string, resolve func(string) (v T, found, i
9393
}
9494

9595
base := filepath.Base(impPath)
96+
9697
if base == "index" {
9798
// try index.esm.js etc.
9899
v, found, _ = findFirst(impPath + ".esm")
@@ -159,6 +160,7 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana
159160

160161
resolveImport := func(args api.OnResolveArgs) (api.OnResolveResult, error) {
161162
impPath := args.Path
163+
162164
shimmed := false
163165
if opts.Shims != nil {
164166
override, found := opts.Shims[impPath]
@@ -182,9 +184,9 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana
182184
}
183185

184186
importer := args.Importer
185-
186187
isStdin := importer == stdinImporter
187188
var relDir string
189+
188190
if !isStdin {
189191
if after, ok := strings.CutPrefix(importer, PrefixHugoVirtual); ok {
190192
relDir = filepath.Dir(after)

resources/resource_transformers/js/build.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ type Client struct {
3131
}
3232

3333
// New creates a new client context.
34-
func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) *Client {
34+
func New(fs *filesystems.SourceFilesystem, rs *resources.Spec, cssMode bool) *Client {
3535
return &Client{
36-
c: esbuild.NewBuildClient(fs, rs),
36+
c: esbuild.NewBuildClient(fs, rs, cssMode),
3737
}
3838
}
3939

resources/resource_transformers/js/transform.go

Lines changed: 9 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)

tpl/css/css.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/gohugoio/hugo/resources/resource"
1616
"github.com/gohugoio/hugo/resources/resource_transformers/babel"
1717
"github.com/gohugoio/hugo/resources/resource_transformers/cssjs"
18+
jstransform "github.com/gohugoio/hugo/resources/resource_transformers/js"
1819
"github.com/gohugoio/hugo/resources/resource_transformers/tocss/dartsass"
1920
"github.com/gohugoio/hugo/resources/resource_transformers/tocss/sass"
2021
"github.com/gohugoio/hugo/resources/resource_transformers/tocss/scss"
@@ -32,6 +33,7 @@ type Namespace struct {
3233
postcssClient *cssjs.PostCSSClient
3334
tailwindcssClient *cssjs.TailwindCSSClient
3435
babelClient *babel.Client
36+
jsTransformClient *jstransform.Client
3537

3638
// The Dart Client requires a os/exec process, so only
3739
// create it if we really need it.
@@ -147,6 +149,33 @@ func (ns *Namespace) Sass(args ...any) (resource.Resource, error) {
147149
return client.ToCSS(r, m)
148150
}
149151

152+
// Build processes the given CSS Resource with ESBuild.
153+
// Note that this method is identical to the one in the js Namespace.
154+
func (ns *Namespace) Build(args ...any) (resource.Resource, error) {
155+
var (
156+
r resources.ResourceTransformer
157+
m map[string]any
158+
targetPath string
159+
err error
160+
ok bool
161+
)
162+
163+
r, targetPath, ok = resourcehelpers.ResolveIfFirstArgIsString(args)
164+
165+
if !ok {
166+
r, m, err = resourcehelpers.ResolveArgs(args)
167+
if err != nil {
168+
return nil, err
169+
}
170+
}
171+
172+
if targetPath != "" {
173+
m = map[string]any{"targetPath": targetPath}
174+
}
175+
176+
return ns.jsTransformClient.Process(r, m)
177+
}
178+
150179
func init() {
151180
f := func(d *deps.Deps) *internal.TemplateFuncsNamespace {
152181
scssClient, err := scss.New(d.BaseFs.Assets, d.ResourceSpec)
@@ -159,6 +188,7 @@ func init() {
159188
postcssClient: cssjs.NewPostCSSClient(d.ResourceSpec),
160189
tailwindcssClient: cssjs.NewTailwindCSSClient(d.ResourceSpec),
161190
babelClient: babel.New(d.ResourceSpec),
191+
jsTransformClient: jstransform.New(d.BaseFs.Assets, d.ResourceSpec, true),
162192
}
163193

164194
ns := &internal.TemplateFuncsNamespace{

tpl/js/js.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func New(d *deps.Deps) (*Namespace, error) {
3636

3737
return &Namespace{
3838
d: d,
39-
jsTransformClient: jstransform.New(d.BaseFs.Assets, d.ResourceSpec),
39+
jsTransformClient: jstransform.New(d.BaseFs.Assets, d.ResourceSpec, false),
4040
createClient: create.New(d.ResourceSpec),
4141
babelClient: babel.New(d.ResourceSpec),
4242
}, nil
@@ -51,7 +51,7 @@ type Namespace struct {
5151
babelClient *babel.Client
5252
}
5353

54-
// Build processes the given Resource with ESBuild.
54+
// Build processes the given JavaScript Resource with ESBuild.
5555
func (ns *Namespace) Build(args ...any) (resource.Resource, error) {
5656
var (
5757
r resources.ResourceTransformer

0 commit comments

Comments
 (0)