Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/js/esbuild/batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ var (
func NewBatcherClient(deps *deps.Deps) (js.BatcherClient, error) {
c := &BatcherClient{
d: deps,
buildClient: NewBuildClient(deps.BaseFs.Assets, deps.ResourceSpec),
buildClient: NewBuildClient(deps.BaseFs.Assets, deps.ResourceSpec, false),
createClient: create.New(deps.ResourceSpec),
batcherStore: hmaps.NewCache[string, js.Batcher](),
bundlesStore: hmaps.NewCache[string, js.BatchPackage](),
Expand Down
12 changes: 7 additions & 5 deletions internal/js/esbuild/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,19 @@ import (
)

// NewBuildClient creates a new BuildClient.
func NewBuildClient(fs *filesystems.SourceFilesystem, rs *resources.Spec) *BuildClient {
func NewBuildClient(fs *filesystems.SourceFilesystem, rs *resources.Spec, cssMode bool) *BuildClient {
return &BuildClient{
rs: rs,
sfs: fs,
rs: rs,
sfs: fs,
CssMode: cssMode,
}
}

// BuildClient is a client for building JavaScript resources using esbuild.
type BuildClient struct {
rs *resources.Spec
sfs *filesystems.SourceFilesystem
rs *resources.Spec
sfs *filesystems.SourceFilesystem
CssMode bool
}

// Build builds the given JavaScript resources using esbuild with the given options.
Expand Down
43 changes: 43 additions & 0 deletions internal/js/esbuild/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@ var (
"es2024": api.ES2024,
}

nameEngine = map[string]api.EngineName{
"chrome": api.EngineChrome,
"deno": api.EngineDeno,
"edge": api.EngineEdge,
"firefox": api.EngineFirefox,
"hermes": api.EngineHermes,
"ie": api.EngineIE,
"ios": api.EngineIOS,
"node": api.EngineNode,
"opera": api.EngineOpera,
"rhino": api.EngineRhino,
"safari": api.EngineSafari,
}

// source names: https://github.com/evanw/esbuild/blob/9eca46464ed5615cb36a3beb3f7a7b9a8ffbe7cf/internal/config/config.go#L208
nameLoader = map[string]api.Loader{
"none": api.LoaderNone,
Expand Down Expand Up @@ -117,6 +131,11 @@ type ExternalOptions struct {
// Default is esnext.
Target string

// Engines to target, e.g. "chrome58" or "node12".
// TODO1 sensible default if not set. For css only.
// See https://esbuild.github.io/api/#target
Engines []string

// The output format.
// One of: iife, cjs, esm
// Default is to esm.
Expand Down Expand Up @@ -200,6 +219,7 @@ type InternalOptions struct {

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

var engines []api.Engine

OUTER:
for _, value := range opts.Engines {
for engine, name := range nameEngine {
if strings.HasPrefix(value, engine) {
version := value[len(engine):]
if version == "" {
return fmt.Errorf("invalid engine version: %q", value)
}
engines = append(engines, api.Engine{Name: name, Version: version})
continue OUTER
}
}
}

var loaders map[string]api.Loader
if opts.Loaders != nil {
loaders = make(map[string]api.Loader)
Expand Down Expand Up @@ -252,6 +288,8 @@ func (opts *Options) compile() (err error) {
loader = api.LoaderTSX
case media.Builtin.JSXType.SubType:
loader = api.LoaderJSX
case media.Builtin.CSSType.SubType:
loader = api.LoaderCSS
default:
err = fmt.Errorf("unsupported Media Type: %q", opts.MediaType)
return
Expand Down Expand Up @@ -343,6 +381,7 @@ func (opts *Options) compile() (err error) {
AbsWorkingDir: opts.AbsWorkingDir,

Target: target,
Engines: engines,
Format: format,
Platform: platform,
Sourcemap: sourceMap,
Expand Down Expand Up @@ -394,6 +433,10 @@ func (o Options) loaderFromFilename(filename string) api.Loader {
if found {
return l
}
if o.IsCSS {
// For CSS builds, handling, default to the file loader for unknown extensions.
return api.LoaderFile
}
return api.LoaderJS
}

Expand Down
13 changes: 10 additions & 3 deletions internal/js/esbuild/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,14 @@
".txt": api.LoaderText,
}

var cssExtensionMap = map[string]bool{

Check failure on line 61 in internal/js/esbuild/resolve.go

View workflow job for this annotation

GitHub Actions / test (1.25.x, ubuntu-latest)

var cssExtensionMap is unused (U1000)
".css": true,
}

// This is a common sub-set of ESBuild's default extensions.
// We assume that imports of JSON, CSS etc. will be using their full
// We assume that imports of JSON etc. will be using their full
// name with extension.
var commonExtensions = []string{".js", ".ts", ".tsx", ".jsx"}
var commonExtensions = []string{".js", ".ts", ".tsx", ".jsx", ".css"}

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

base := filepath.Base(impPath)

if base == "index" {
// try index.esm.js etc.
v, found, _ = findFirst(impPath + ".esm")
Expand Down Expand Up @@ -159,6 +164,7 @@

resolveImport := func(args api.OnResolveArgs) (api.OnResolveResult, error) {
impPath := args.Path

shimmed := false
if opts.Shims != nil {
override, found := opts.Shims[impPath]
Expand All @@ -182,9 +188,9 @@
}

importer := args.Importer

isStdin := importer == stdinImporter
var relDir string

if !isStdin {
if after, ok := strings.CutPrefix(importer, PrefixHugoVirtual); ok {
relDir = filepath.Dir(after)
Expand Down Expand Up @@ -225,6 +231,7 @@
// in server mode, we may get stale entries on renames etc.,
// but that shouldn't matter too much.
rs.JSConfigBuilder.AddSourceRoot(m.SourceRoot)

return api.OnResolveResult{Path: m.Filename, Namespace: NsHugoImport}, nil
}

Expand Down
35 changes: 25 additions & 10 deletions resources/resource_transformers/js/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import (
"path"
"path/filepath"
"regexp"

"github.com/evanw/esbuild/pkg/api"
Expand All @@ -31,9 +32,9 @@
}

// New creates a new client context.
func New(fs *filesystems.SourceFilesystem, rs *resources.Spec) *Client {
func New(fs *filesystems.SourceFilesystem, rs *resources.Spec, cssMode bool) *Client {
return &Client{
c: esbuild.NewBuildClient(fs, rs),
c: esbuild.NewBuildClient(fs, rs, cssMode),
}
}

Expand All @@ -56,27 +57,41 @@
return result, err
}

if opts.ExternalOptions.SourceMap == "linked" || opts.ExternalOptions.SourceMap == "external" {
hasSourceMap := opts.ExternalOptions.SourceMap == "linked" || opts.ExternalOptions.SourceMap == "external"

var idxContentFiles int

if hasSourceMap {
idxContentFiles++
content := string(result.OutputFiles[1].Contents)
if opts.ExternalOptions.SourceMap == "linked" {
symPath := path.Base(transformCtx.OutPath) + ".map"
re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`)
content = re.ReplaceAllString(content, "//# sourceMappingURL="+symPath+"\n")

Check failure on line 70 in resources/resource_transformers/js/build.go

View workflow job for this annotation

GitHub Actions / test (1.25.x, ubuntu-latest)

this value of content is never used (SA4006)
}

if err = transformCtx.PublishSourceMap(string(result.OutputFiles[0].Contents)); err != nil {
return result, err
}
_, err := transformCtx.To.Write([]byte(content))
if err != nil {
return result, err
}
} else {
_, err := transformCtx.To.Write(result.OutputFiles[0].Contents)
if err != nil {
}

// Any files loaded with the file loader comes next.
// TODO1 verify that we can rely on order here, else look in result.Metafile.
outDir := path.Dir(transformCtx.OutPath)
for i := idxContentFiles; i < len(result.OutputFiles)-1; i++ {
file := result.OutputFiles[i]
basePath := path.Base(filepath.ToSlash(file.Path))
target := path.Join(outDir, basePath)
if err = transformCtx.PublishTo(target, string(file.Contents)); err != nil {
return result, err
}
}

// Write the main output.
main := result.OutputFiles[len(result.OutputFiles)-1]
if _, err = transformCtx.To.Write(main.Contents); err != nil {
return result, err
}

return result, nil
}
12 changes: 10 additions & 2 deletions resources/resource_transformers/js/transform.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,18 @@ type buildTransformation struct {
}

func (t *buildTransformation) Key() internal.ResourceTransformationKey {
return internal.NewResourceTransformationKey("jsbuild", t.optsm)
key := "jsbuild"
if t.c.c.CssMode {
key = "cssbuild"
}
return internal.NewResourceTransformationKey(key, t.optsm)
}

func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx) error {
ctx.OutMediaType = media.Builtin.JavascriptType
if ctx.InMediaType == media.Builtin.CSSType {
ctx.OutMediaType = media.Builtin.CSSType
}

var opts esbuild.Options

Expand All @@ -49,7 +56,7 @@ func (t *buildTransformation) Transform(ctx *resources.ResourceTransformationCtx
if opts.TargetPath != "" {
ctx.OutPath = opts.TargetPath
} else {
ctx.ReplaceOutPathExtension(".js")
ctx.ReplaceOutPathExtension(ctx.OutMediaType.FirstSuffix.FullSuffix)
}

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

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

Expand Down
5 changes: 5 additions & 0 deletions resources/transform.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ func (ctx *ResourceTransformationCtx) AddOutPathIdentifier(identifier string) {
// with the ".map" extension added.
func (ctx *ResourceTransformationCtx) PublishSourceMap(content string) error {
target := ctx.OutPath + ".map"
return ctx.PublishTo(target, content)
}

// PublishTo writes the content to the target folder.
func (ctx *ResourceTransformationCtx) PublishTo(target string, content string) error {
f, err := ctx.OpenResourcePublisher(target)
if err != nil {
return err
Expand Down
83 changes: 83 additions & 0 deletions tpl/css/build_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright 2026 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package css_test

import (
"testing"

"github.com/gohugoio/hugo/hugolib"
)

func TestCSSBuild(t *testing.T) {
files := `
-- hugo.toml --
-- assets/a/pixel.png --
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
-- assets/css/main.css --
@import "tailwindcss";
@import url('https://example.org/foo.css');
@import "./foo.css";
@import "./bar.css" layer(mylayer);
@import "./bar.css" layer;
@import './baz.css' screen and (min-width: 800px);
@import './qux.css' supports(display: grid);
body {
background-image: url("a/pixel.png");
}
.mask1 {
mask-image: url(a/pixel.png);
mask-repeat: no-repeat;
}
-- assets/css/foo.css --
p { background: red; }
-- assets/css/bar.css --
div { background: blue; }
-- assets/css/baz.css --
span { background: green; }
-- assets/css/qux.css --
article { background: yellow; }
-- layouts/home.html --
{{ with resources.Get "css/main.css" }}
{{ $engines := slice "chrome80" "firefox73" "safari13" "edge80" }}
{{ with . | css.Build (dict "minify" true "engines" $engines "loaders" (dict ".png" "dataurl") "externals" (slice "tailwindcss")) }}
<link rel="stylesheet" href="{{ .RelPermalink }}" />
{{ end }}
{{ end }}
`

b := hugolib.Test(t, files, hugolib.TestOptOsFs())
b.AssertFileContent("public/css/main.css", `webkit`)
}

func TestCSSBuildLoadersDefault(t *testing.T) {
files := `
-- hugo.toml --
-- assets/a/pixel.png --
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
-- assets/css/main.css --
body {
background-image: url("a/pixel.png");
}
-- layouts/home.html --
{{ with resources.Get "css/main.css" }}
{{ with . | css.Build (dict "minify" true)) }}
<link rel="stylesheet" href="{{ .RelPermalink }}" />
{{ end }}
{{ end }}
`

b := hugolib.Test(t, files, hugolib.TestOptOsFs())
b.AssertFileContent("public/css/main.css", `./pixel-NJRUOINY.png`)
b.AssertFileExists("public/css/pixel-NJRUOINY.png", true)
}
Loading
Loading