Add css.Build

Fixes #14609
Fixes #14613
This commit is contained in:
Bjørn Erik Pedersen
2026-03-09 11:16:36 +01:00
parent 1f578f160c
commit 3e3b849cc7
16 changed files with 600 additions and 72 deletions
+7
View File
@@ -106,6 +106,13 @@ func TestOptOsFs() TestOpt {
}
}
// TestOptWithNpmInstall will enable npm install in integration tests.
func TestOptWithNpmInstall() TestOpt {
return func(c *IntegrationTestConfig) {
c.NeedsNpmInstall = true
}
}
// TestOptWithNFDOnDarwin will normalize the Unicode filenames to NFD on Darwin.
func TestOptWithNFDOnDarwin() TestOpt {
return func(c *IntegrationTestConfig) {
+1 -2
View File
@@ -54,7 +54,6 @@ const (
NsBatch = "_hugo-js-batch"
propsKeyImportContext = "importContext"
propsResoure = "resource"
)
//go:embed batch-esm-runner.gotmpl
@@ -72,7 +71,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](),
+9 -7
View File
@@ -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.
@@ -80,7 +82,7 @@ func (c *BuildClient) Build(opts Options) (api.BuildResult, error) {
return api.BuildResult{}, fmt.Errorf("inject: absolute paths not supported, must be relative to /assets")
}
m := assetsResolver.resolveComponent(impPath)
m := assetsResolver.resolveComponent(impPath, false)
if m == nil {
return api.BuildResult{}, fmt.Errorf("inject: file %q not found", ext)
@@ -190,7 +192,7 @@ func (c *BuildClient) Build(opts Options) (api.BuildResult, error) {
}
}
if m := assetsResolver.resolveComponent(s); m != nil {
if m := assetsResolver.resolveComponent(s, false); m != nil {
return m.Filename
}
+90 -12
View File
@@ -17,6 +17,7 @@ import (
"encoding/json"
"fmt"
"path/filepath"
"sort"
"strings"
"github.com/gohugoio/hugo/common/hmaps"
@@ -48,6 +49,22 @@ var (
"es2024": api.ES2024,
}
engineName = 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,
}
engineKeysSorted []string
// source names: https://github.com/evanw/esbuild/blob/9eca46464ed5615cb36a3beb3f7a7b9a8ffbe7cf/internal/config/config.go#L208
nameLoader = map[string]api.Loader{
"none": api.LoaderNone,
@@ -70,6 +87,16 @@ var (
}
)
func init() {
for k := range engineName {
engineKeysSorted = append(engineKeysSorted, k)
}
// Sort by length descending to match longest prefix first.
sort.Slice(engineKeysSorted, func(i, j int) bool {
return len(engineKeysSorted[i]) > len(engineKeysSorted[j])
})
}
// DecodeExternalOptions decodes the given map into ExternalOptions.
func DecodeExternalOptions(m map[string]any) (ExternalOptions, error) {
opts := ExternalOptions{
@@ -84,7 +111,9 @@ func DecodeExternalOptions(m map[string]any) (ExternalOptions, error) {
opts.TargetPath = paths.ToSlashTrimLeading(opts.TargetPath)
}
opts.Target = strings.ToLower(opts.Target)
for i, t := range opts.Target {
opts.Target[i] = strings.ToLower(t)
}
opts.Format = strings.ToLower(opts.Format)
return opts, nil
@@ -112,10 +141,11 @@ type ExternalOptions struct {
SourcesContent bool
// The language target.
// One of: es2015, es2016, es2017, es2018, es2019, es2020 or esnext.
// Default is esnext.
Target string
// This sets the target environment for the generated JavaScript and/or CSS code.
// It can specify the JavaScript language version to target (es2015, es2016, es2017, es2018, es2019, es2020 or esnext; default is esnext),
// in addition to a set of environments to support, as slice of environment name followed by a version number. e.g. "chrome80", "firefox73", "safari13" or "edge80".
// See https://esbuild.github.io/api/#target
Target []string
// The output format.
// One of: iife, cjs, esm
@@ -127,6 +157,12 @@ type ExternalOptions struct {
// See https://esbuild.github.io/api/#platform
Platform string
// When you import a package in node, the main field in that package's package.json file
// determines which file is imported (along with a lot of other rules).
// The default main fields is controlled by ESBuild and depend on the current platform setting.
// See https://esbuild.github.io/api/#main-fields
MainFields []string
// External dependencies, e.g. "react".
Externals []string
@@ -200,6 +236,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
@@ -218,10 +255,37 @@ type Options struct {
}
func (opts *Options) compile() (err error) {
target, found := nameTarget[opts.Target]
if !found {
err = fmt.Errorf("invalid target: %q", opts.Target)
return
var target api.Target
for _, value := range opts.Target {
if v, found := nameTarget[value]; found {
if v > target {
target = v
}
}
}
var engines []api.Engine
OUTER:
for _, value := range opts.Target {
for _, engine := range engineKeysSorted {
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: engineName[engine], Version: version})
continue OUTER
}
}
}
if target == 0 && len(engines) == 0 && len(opts.Target) > 0 {
return fmt.Errorf("unsupported target: %v", opts.Target)
}
if target == 0 {
target = api.ESNext
}
var loaders map[string]api.Loader
@@ -252,6 +316,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
@@ -343,7 +409,9 @@ func (opts *Options) compile() (err error) {
AbsWorkingDir: opts.AbsWorkingDir,
Target: target,
Engines: engines,
Format: format,
MainFields: opts.MainFields,
Platform: platform,
Sourcemap: sourceMap,
SourcesContent: sourcesContent,
@@ -390,10 +458,20 @@ func (o Options) loaderFromFilename(filename string) api.Loader {
return l
}
}
l, found := extensionToLoaderMap[ext]
if found {
return l
if o.IsCSS {
l, found := extensionToLoaderMapCSS[ext]
if found {
return l
}
// For CSS builds, handling, default to the file loader for unknown extensions.
return api.LoaderFile
} else {
l, found := extensionToLoaderMapJS[ext]
if found {
return l
}
}
return api.LoaderJS
}
+15 -5
View File
@@ -47,7 +47,7 @@ func TestToBuildOptions(t *testing.T) {
opts = Options{
ExternalOptions: ExternalOptions{
Target: "es2018",
Target: []string{"es2018"},
Format: "cjs",
Minify: true,
AvoidTDZ: true,
@@ -75,7 +75,7 @@ func TestToBuildOptions(t *testing.T) {
opts = Options{
ExternalOptions: ExternalOptions{
Target: "es2018", Format: "cjs", Minify: true,
Target: []string{"es2018"}, Format: "cjs", Minify: true,
SourceMap: "inline",
},
InternalOptions: InternalOptions{
@@ -102,7 +102,7 @@ func TestToBuildOptions(t *testing.T) {
opts = Options{
ExternalOptions: ExternalOptions{
Target: "es2018", Format: "cjs", Minify: true,
Target: []string{"es2018"}, Format: "cjs", Minify: true,
SourceMap: "inline",
},
InternalOptions: InternalOptions{
@@ -129,7 +129,7 @@ func TestToBuildOptions(t *testing.T) {
opts = Options{
ExternalOptions: ExternalOptions{
Target: "es2018", Format: "cjs", Minify: true,
Target: []string{"es2018"}, Format: "cjs", Minify: true,
SourceMap: "external",
},
InternalOptions: InternalOptions{
@@ -223,7 +223,7 @@ func TestToBuildOptionsTarget(t *testing.T) {
c.Run(test.target, func(c *qt.C) {
opts := Options{
ExternalOptions: ExternalOptions{
Target: test.target,
Target: []string{test.target},
},
InternalOptions: InternalOptions{
MediaType: media.Builtin.JavascriptType,
@@ -260,3 +260,13 @@ func TestDecodeExternalOptions(t *testing.T) {
SourcesContent: api.SourcesContentInclude,
})
}
func TestDecodeExternalOptionsLegacyTargetString(t *testing.T) {
c := qt.New(t)
m := map[string]any{
"target": "es2018",
}
ext, err := DecodeExternalOptions(m)
c.Assert(err, qt.IsNil)
c.Assert(ext.Target, qt.DeepEquals, []string{"es2018"})
}
+52 -15
View File
@@ -46,16 +46,20 @@ const (
PrefixHugoMemory = "__hu_m"
)
var extensionToLoaderMap = map[string]api.Loader{
var extensionToLoaderMapJS = map[string]api.Loader{
".js": api.LoaderJS,
".mjs": api.LoaderJS,
".cjs": api.LoaderJS,
".jsx": api.LoaderJSX,
".ts": api.LoaderTS,
".tsx": api.LoaderTSX,
".css": api.LoaderCSS,
".json": api.LoaderJSON,
".txt": api.LoaderText,
".css": api.LoaderCSS,
}
var extensionToLoaderMapCSS = map[string]api.Loader{
".css": api.LoaderCSS,
}
// This is a common sub-set of ESBuild's default extensions.
@@ -93,6 +97,7 @@ func ResolveComponent[T any](impPath string, resolve func(string) (v T, found, i
}
base := filepath.Base(impPath)
if base == "index" {
// try index.esm.js etc.
v, found, _ = findFirst(impPath + ".esm")
@@ -140,7 +145,7 @@ type fsResolver struct {
resolved *hmaps.Cache[string, *hugofs.FileMeta]
}
func (r *fsResolver) resolveComponent(impPath string) *hugofs.FileMeta {
func (r *fsResolver) resolveComponent(impPath string, direct bool) *hugofs.FileMeta {
v, _ := r.resolved.GetOrCreate(impPath, func() (*hugofs.FileMeta, error) {
resolve := func(name string) (*hugofs.FileMeta, bool, bool) {
if fi, err := r.fs.Stat(name); err == nil {
@@ -148,6 +153,14 @@ func (r *fsResolver) resolveComponent(impPath string) *hugofs.FileMeta {
}
return nil, false, false
}
if direct {
// Only resolve the path as is, without trying to add extensions etc.
v, found, isDir := resolve(impPath)
if found && !isDir {
return v, nil
}
return nil, nil
}
v, _ := ResolveComponent(impPath, resolve)
return v, nil
})
@@ -159,6 +172,8 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana
resolveImport := func(args api.OnResolveArgs) (api.OnResolveResult, error) {
impPath := args.Path
isCSSToken := args.Kind == api.ResolveCSSImportRule || args.Kind == api.ResolveCSSURLToken
shimmed := false
if opts.Shims != nil {
override, found := opts.Shims[impPath]
@@ -182,9 +197,9 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana
}
importer := args.Importer
isStdin := importer == stdinImporter
var relDir string
if !isStdin {
if after, ok := strings.CutPrefix(importer, PrefixHugoVirtual); ok {
relDir = filepath.Dir(after)
@@ -208,23 +223,45 @@ func createBuildPlugins(rs *resources.Spec, assetsResolver *fsResolver, depsMana
relDir = opts.SourceDir
}
// Imports not starting with a "." is assumed to live relative to /assets.
// Hugo makes no assumptions about the directory structure below /assets.
if relDir != "" && strings.HasPrefix(impPath, ".") {
impPath = filepath.Join(relDir, impPath)
var pathsToTry []string
if relDir != "" {
// For JS imports not starting with a "." is assumed to live relative to /assets.
// Hugo makes no assumptions about the directory structure below /assets.
if strings.HasPrefix(impPath, ".") {
pathsToTry = append(pathsToTry, filepath.Join(relDir, impPath))
} else if isCSSToken && !strings.HasPrefix(impPath, "/") {
// Follow the logic of both ESBuild and WebKit for CSS @import and url() tokens.
// First try the relativ path, then the assets relative path.
// See https://github.com/evanw/esbuild/issues/469
pathsToTry = append(pathsToTry, filepath.Join(relDir, impPath), impPath)
} else {
// Try only the assets relative path.
pathsToTry = append(pathsToTry, impPath)
}
}
m := assetsResolver.resolveComponent(impPath)
var m *hugofs.FileMeta
for _, p := range pathsToTry {
m = assetsResolver.resolveComponent(p, isCSSToken)
if m != nil {
break
}
}
if m != nil {
depsManager.AddIdentity(m.PathInfo)
// Store the source root so we can create a jsconfig.json
// to help IntelliSense when the build is done.
// This should be a small number of elements, and when
// in server mode, we may get stale entries on renames etc.,
// but that shouldn't matter too much.
rs.JSConfigBuilder.AddSourceRoot(m.SourceRoot)
// jsconfig.json path mapping has no effect for CSS imports, so skip those.
if !isCSSToken {
// Store the source root so we can create a jsconfig.json
// to help IntelliSense when the build is done.
// This should be a small number of elements, and when
// 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
}
+1 -1
View File
@@ -71,7 +71,7 @@ func TestResolveComponentInAssets(t *testing.T) {
c.Assert(err, qt.IsNil)
resolver := newFSResolver(bfs.Assets.Fs)
got := resolver.resolveComponent(test.impPath)
got := resolver.resolveComponent(test.impPath, false)
gotPath := ""
expect := test.expect
@@ -218,7 +218,7 @@ func (t *babelTransformation) Transform(ctx *resources.ResourceTransformationCtx
if err != nil {
return err
}
if err = ctx.PublishSourceMap(string(sourceMap)); err != nil {
if err = ctx.PublishSourceMap(sourceMap); err != nil {
return err
}
targetPath := path.Base(ctx.OutPath) + ".map"
+53 -21
View File
@@ -14,8 +14,11 @@
package js
import (
"fmt"
"path"
"path/filepath"
"regexp"
"strings"
"github.com/evanw/esbuild/pkg/api"
"github.com/gohugoio/hugo/hugolib/filesystems"
@@ -31,9 +34,9 @@ type Client struct {
}
// 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),
}
}
@@ -56,27 +59,56 @@ func (c *Client) transform(opts esbuild.Options, transformCtx *resources.Resourc
return result, err
}
if opts.ExternalOptions.SourceMap == "linked" || opts.ExternalOptions.SourceMap == "external" {
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")
}
hasLinkedSourceMap := opts.ExternalOptions.SourceMap == "linked"
hasSourceMap := hasLinkedSourceMap || opts.ExternalOptions.SourceMap == "external"
if err = transformCtx.PublishSourceMap(string(result.OutputFiles[0].Contents)); err != nil {
return result, err
// Classify output files by path rather than relying on array ordering,
// which esbuild does not guarantee.
var mainOutput []byte
outDir := path.Dir(transformCtx.OutPath)
for _, file := range result.OutputFiles {
basePath := path.Base(filepath.ToSlash(file.Path))
if strings.HasSuffix(basePath, ".map") {
if hasSourceMap {
if err = transformCtx.PublishSourceMap(file.Contents); err != nil {
return result, err
}
}
} else if isStdinEntryOutput(basePath) {
mainOutput = file.Contents
} else {
// File-loader artifact; publish directly.
target := path.Join(outDir, basePath)
if err = transformCtx.PublishTo(target, file.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 {
return result, err
}
}
if mainOutput == nil {
return result, fmt.Errorf("esbuild: entry point output not found")
}
if hasLinkedSourceMap {
symPath := path.Base(transformCtx.OutPath) + ".map"
if opts.IsCSS {
re := regexp.MustCompile(`/\*# sourceMappingURL=.*\n?`)
mainOutput = re.ReplaceAll(mainOutput, []byte("/*# sourceMappingURL="+symPath+" */\n"))
} else {
re := regexp.MustCompile(`//# sourceMappingURL=.*\n?`)
mainOutput = re.ReplaceAll(mainOutput, []byte("//# sourceMappingURL="+symPath+"\n"))
}
}
if _, err = transformCtx.To.Write(mainOutput); err != nil {
return result, err
}
return result, nil
}
// isStdinEntryOutput reports whether basePath is the entry-point output
// generated by esbuild when using stdin mode (e.g. "stdin.js", "stdin.css").
func isStdinEntryOutput(basePath string) bool {
return strings.HasPrefix(basePath, "stdin.")
}
@@ -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
@@ -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)
@@ -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)
@@ -119,7 +119,7 @@ func (t *transform) Transform(ctx *resources.ResourceTransformationCtx) error {
}
if opts.EnableSourceMap && res.SourceMap != "" {
if err := ctx.PublishSourceMap(res.SourceMap); err != nil {
if err := ctx.PublishSourceMap([]byte(res.SourceMap)); err != nil {
return err
}
_, err = fmt.Fprintf(ctx.To, "\n\n/*# sourceMappingURL=%s */", path.Base(ctx.OutPath)+".map")
@@ -177,7 +177,7 @@ func (t *toCSSTransformation) Transform(ctx *resources.ResourceTransformationCtx
// is important enough to go this extra mile.
mapContent := strings.Replace(res.SourceMapContent, `stdin"`, fmt.Sprintf("%s\"", sourcePath), 1)
return ctx.PublishSourceMap(mapContent)
return ctx.PublishSourceMap([]byte(mapContent))
}
return nil
}
+7 -2
View File
@@ -146,14 +146,19 @@ func (ctx *ResourceTransformationCtx) AddOutPathIdentifier(identifier string) {
// PublishSourceMap writes the content to the target folder of the main resource
// with the ".map" extension added.
func (ctx *ResourceTransformationCtx) PublishSourceMap(content string) error {
func (ctx *ResourceTransformationCtx) PublishSourceMap(content []byte) 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 []byte) error {
f, err := ctx.OpenResourcePublisher(target)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write([]byte(content))
_, err = f.Write(content)
return err
}
+315
View File
@@ -0,0 +1,315 @@
// 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 (
"strings"
"testing"
"github.com/gohugoio/hugo/htesting"
"github.com/gohugoio/hugo/hugolib"
)
func TestCSSBuild(t *testing.T) {
t.Parallel()
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 'relnodot.css' supports(display: grid);
@import 'relnodotboth.css';
@import '/relnodotboth2.css';
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/relnodot.css --
article { background: yellow; }
-- assets/css/relnodotboth.css --
.relnodotbothrelative { background: green; }
-- assets/relnodotboth.css --
.relnodotbothroot { background: blue; }
-- assets/css/relnodotboth2.css --
.relnodotboth2relative { background: green; }
-- assets/relnodotboth2.css --
.relnodotboth2root { background: blue; }
-- layouts/home.html --
{{ with resources.Get "css/main.css" }}
{{ $opts := (dict "minify" true "target" (slice "chrome108" "firefox116" "safari16.4" "edge115" "ios16.4" "opera101") "loaders" (dict ".png" "dataurl") "externals" (slice "tailwindcss"))}}
{{ with . | css.Build $opts }}
<link rel="stylesheet" href="{{ .RelPermalink }}" />
{{ end }}
{{ end }}
`
b := hugolib.Test(t, files, hugolib.TestOptOsFs())
b.AssertFileContent("public/css/main.css",
`-webkit-mask-repeat`, // browser prefix.
"data:image/png;base64", // dataurl loader
`@import"tailwindcss";`, // external
`@import"https://example.org/foo.css";`, // external
"mylayer{div", // imported layer
"@media screen and (min-width:800px){span", // imported media query
"@supports (display: grid){article", // imported supports
"relnodotbothrelative", //@import 'relnodotboth.css'; file exists in both assets and project root.
"relnodotboth2root", //@import '/relnodotboth2.css'; file exists in both assets and project root.
)
}
func TestCSSBuildEdit(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
disableKinds = ["taxonomy", "term", "rss"]
-- assets/images/bar.svg --
barsvg
-- assets/images/foo.svg --
foosvg
-- assets/css/main.css --
@import "./foo.css";
@import "./bar.css" layer(mylayer);
.main { background: red; }
-- assets/css/foo.css --
.foo { background: green; }
-- assets/css/bar.css --
.bar { background-image: url("images/bar.svg"); }
-- layouts/all.html --
All. No CSS here.
-- layouts/home.html --
{{ with resources.Get "css/main.css" }}
{{ $opts := (dict "minify" true) }}
{{ with . | css.Build $opts }}
<link rel="stylesheet" href="{{ .RelPermalink }}" />
{{ end }}
{{ end }}
`
b := hugolib.TestRunning(t, files, hugolib.TestOptOsFs())
b.AssertFileContent("public/css/main.css", `.foo{background:green}@layer mylayer{.bar{background-image:url("./bar-Y35ORVQM.svg")}}`)
// Edit svg
b.EditFileReplaceAll("assets/images/bar.svg", "barsvg", "newbarsvg").Build()
b.AssertRenderCountPage(1)
b.AssertFileContent("public/css/main.css", `bar-LVHHRPN5.svg`) // new hash.
b.AssertFileContent("public/css/bar-LVHHRPN5.svg", "newbarsvg")
// Edit foo.css
b.EditFileReplaceAll("assets/css/foo.css", "green", "red").Build()
b.AssertRenderCountPage(1)
b.AssertFileContent("public/css/main.css", `.foo{background:red}`) // updated content.
// Edit bar.css
b.EditFileReplaceAll("assets/css/bar.css", "bar.svg", "foo.svg").Build()
b.AssertRenderCountPage(1)
b.AssertFileContent("public/css/main.css", `foo-52JTT5GU.svg`)
b.AssertFileContent("public/css/foo-52JTT5GU.svg", "foosvg")
// Edit main.css
b.EditFileReplaceAll("assets/css/main.css", "red", "blue").Build()
b.AssertRenderCountPage(1)
b.AssertFileContent("public/css/main.css", `.main{background:#00f}`) // updated content, blue gets minified to #00f.
}
func TestCSSBuildSourceMaps(t *testing.T) {
t.Parallel()
filesTemplate := `
-- hugo.toml --
-- assets/a/pixel.png --
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
-- assets/css/main.css --
@import "./foo.css";
@import "./bar.css" layer(mylayer);
@import "./bar.css" layer;
@import './baz.css' screen and (min-width: 800px);
.main {
background-color: red;
}
-- 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" }}
{{ $opts := (dict "minify" false "sourceMap" "SOURCE_MAP" "sourcesContent" SOURCES_CONTENT "loaders" (dict ".png" "dataurl"))}}
{{ with . | css.Build $opts }}
<link rel="stylesheet" href="{{ .RelPermalink }}" />
{{ end }}
{{ end }}
`
r := strings.NewReplacer(
"SOURCE_MAP", "linked",
"SOURCES_CONTENT", "true",
)
files := r.Replace(filesTemplate)
b := hugolib.Test(t, files, hugolib.TestOptOsFs())
b.AssertFileContent("public/css/main.css", "/*# sourceMappingURL=main.css.map */")
b.AssertFileContent("public/css/main.css.map",
`"sourcesContent":["p { background: red; }"`,
"AAAA;AAAI,cAAY;AAAK",
)
r = strings.NewReplacer(
"SOURCE_MAP", "external",
"SOURCES_CONTENT", "true",
)
files = r.Replace(filesTemplate)
b = hugolib.Test(t, files, hugolib.TestOptOsFs())
b.AssertFileContent("public/css/main.css", "! sourceMappingURL")
b.AssertFileContent("public/css/main.css.map",
`"sourcesContent":["p { background: red; }"`,
"AAAA;AAAI,cAAY;AAAK",
)
r = strings.NewReplacer(
"SOURCE_MAP", "external",
"SOURCES_CONTENT", "false",
)
files = r.Replace(filesTemplate)
b = hugolib.Test(t, files, hugolib.TestOptOsFs())
b.AssertFileContent("public/css/main.css.map",
`"sourcesContent":null,"`,
"AAAA;AAAI,cAAY;AAAK",
)
r = strings.NewReplacer(
"SOURCE_MAP", "inline",
"SOURCES_CONTENT", "false",
)
files = r.Replace(filesTemplate)
b = hugolib.Test(t, files, hugolib.TestOptOsFs())
b.AssertFileExists("public/css/main.css.map", false)
b.AssertFileContent("public/css/main.css", "sourceMappingURL=data:application/json;base64,")
}
func TestCSSBuildMultihost(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
disableKinds = ["taxonomy", "term"]
defaultContentLanguage = "en"
defaultContentLanguageInSubDir = true
[languages]
[languages.en]
baseURL = "https://example.en"
weight = 1
[languages.fr]
baseURL = "https://example.fr"
weight = 2
-- assets/a/pixel.png --
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
-- assets/css/main.css --
body {
background-image: url("a/pixel.png");
color: red;
}
-- layouts/home.html --
{{ with resources.Get "css/main.css" }}
{{ with . | css.Build (dict "minify" true) }}
CSS: {{ .RelPermalink }}|{{ .Content }}
{{ end }}
{{ end }}
`
b := hugolib.Test(t, files, hugolib.TestOptOsFs())
for _, lang := range []string{"en", "fr"} {
b.AssertFileContent("public/"+lang+"/css/main.css", `./pixel-NJRUOINY.png`)
b.AssertFileExists("public/"+lang+"/css/pixel-NJRUOINY.png", true)
}
}
func TestCSSBuildLoadersDefault(t *testing.T) {
t.Parallel()
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)
}
func TestCSSBuildBootstrapFromNPM(t *testing.T) {
htesting.SkipSlowTestUnlessCI(t)
t.Parallel()
files := `
-- hugo.toml --
disableKinds = ["taxonomy", "term"]
-- assets/css/main.css --
@import "bootstrap";
-- package.json --
{
"devDependencies": {
"bootstrap": "5.3.8"
}
}
-- layouts/home.html --
{{ with resources.Get "css/main.css" }}
{{ with . | css.Build (dict "minify" true "mainFields" (slice "style")) }}
CSS size: {{ .Content | len }}|{{ .RelPermalink }}
{{ end }}
{{ end }}
`
b := hugolib.Test(t, files, hugolib.TestOptOsFs(), hugolib.TestOptWithNpmInstall())
b.AssertFileContent("public/css/main.css", `--bs-indigo: #6610f2;`)
}
+35
View File
@@ -15,6 +15,7 @@ import (
"github.com/gohugoio/hugo/resources/resource"
"github.com/gohugoio/hugo/resources/resource_transformers/babel"
"github.com/gohugoio/hugo/resources/resource_transformers/cssjs"
jstransform "github.com/gohugoio/hugo/resources/resource_transformers/js"
"github.com/gohugoio/hugo/resources/resource_transformers/tocss/dartsass"
"github.com/gohugoio/hugo/resources/resource_transformers/tocss/sass"
"github.com/gohugoio/hugo/resources/resource_transformers/tocss/scss"
@@ -32,6 +33,7 @@ type Namespace struct {
postcssClient *cssjs.PostCSSClient
tailwindcssClient *cssjs.TailwindCSSClient
babelClient *babel.Client
jsTransformClient *jstransform.Client
// The Dart Client requires a os/exec process, so only
// create it if we really need it.
@@ -147,6 +149,33 @@ func (ns *Namespace) Sass(args ...any) (resource.Resource, error) {
return client.ToCSS(r, m)
}
// Build processes the given CSS Resource with ESBuild.
// Note that this method is identical to the one in the js Namespace.
func (ns *Namespace) Build(args ...any) (resource.Resource, error) {
var (
r resources.ResourceTransformer
m map[string]any
targetPath string
err error
ok bool
)
r, targetPath, ok = resourcehelpers.ResolveIfFirstArgIsString(args)
if !ok {
r, m, err = resourcehelpers.ResolveArgs(args)
if err != nil {
return nil, err
}
}
if targetPath != "" {
m = map[string]any{"targetPath": targetPath}
}
return ns.jsTransformClient.Process(r, m)
}
func init() {
f := func(d *deps.Deps) *internal.TemplateFuncsNamespace {
scssClient, err := scss.New(d.BaseFs.Assets, d.ResourceSpec)
@@ -159,6 +188,7 @@ func init() {
postcssClient: cssjs.NewPostCSSClient(d.ResourceSpec),
tailwindcssClient: cssjs.NewTailwindCSSClient(d.ResourceSpec),
babelClient: babel.New(d.ResourceSpec),
jsTransformClient: jstransform.New(d.BaseFs.Assets, d.ResourceSpec, true),
}
ns := &internal.TemplateFuncsNamespace{
@@ -181,6 +211,11 @@ func init() {
[][2]string{},
)
ns.AddMethodMapping(ctx.Build,
nil,
[][2]string{},
)
ns.AddMethodMapping(ctx.TailwindCSS,
nil,
[][2]string{},
+2 -2
View File
@@ -36,7 +36,7 @@ func New(d *deps.Deps) (*Namespace, error) {
return &Namespace{
d: d,
jsTransformClient: jstransform.New(d.BaseFs.Assets, d.ResourceSpec),
jsTransformClient: jstransform.New(d.BaseFs.Assets, d.ResourceSpec, false),
createClient: create.New(d.ResourceSpec),
babelClient: babel.New(d.ResourceSpec),
}, nil
@@ -51,7 +51,7 @@ type Namespace struct {
babelClient *babel.Client
}
// Build processes the given Resource with ESBuild.
// Build processes the given JavaScript Resource with ESBuild.
func (ns *Namespace) Build(args ...any) (resource.Resource, error) {
var (
r resources.ResourceTransformer