diff options
author | Bjørn Erik Pedersen <[email protected]> | 2024-06-23 12:49:10 +0200 |
---|---|---|
committer | Bjørn Erik Pedersen <[email protected]> | 2024-06-25 15:48:02 +0200 |
commit | e1317dd32281dc5ce670e34165dc7780c8f5892b (patch) | |
tree | 986f45feec6d2590b859697f7498f7f9a3cdcc1e /resources | |
parent | eddcd2bac6bfd3cc0ac1a3b38bf8c4ae452ea23b (diff) | |
download | hugo-e1317dd32281dc5ce670e34165dc7780c8f5892b.tar.gz hugo-e1317dd32281dc5ce670e34165dc7780c8f5892b.zip |
Add css.TailwindCSS
Closes #12618
Closes #12620
Diffstat (limited to 'resources')
-rw-r--r-- | resources/resource_transformers/cssjs/inline_imports.go | 247 | ||||
-rw-r--r-- | resources/resource_transformers/cssjs/inline_imports_test.go (renamed from resources/resource_transformers/postcss/postcss_test.go) | 22 | ||||
-rw-r--r-- | resources/resource_transformers/cssjs/postcss.go (renamed from resources/resource_transformers/postcss/postcss.go) | 262 | ||||
-rw-r--r-- | resources/resource_transformers/cssjs/postcss_integration_test.go (renamed from resources/resource_transformers/postcss/postcss_integration_test.go) | 6 | ||||
-rw-r--r-- | resources/resource_transformers/cssjs/tailwindcss.go | 167 | ||||
-rw-r--r-- | resources/resource_transformers/cssjs/tailwindcss_integration_test.go | 72 | ||||
-rw-r--r-- | resources/transform.go | 18 |
7 files changed, 543 insertions, 251 deletions
diff --git a/resources/resource_transformers/cssjs/inline_imports.go b/resources/resource_transformers/cssjs/inline_imports.go new file mode 100644 index 000000000..98e3292cd --- /dev/null +++ b/resources/resource_transformers/cssjs/inline_imports.go @@ -0,0 +1,247 @@ +// Copyright 2024 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 cssjs + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "path" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/common/text" + "github.com/gohugoio/hugo/hugofs" + "github.com/gohugoio/hugo/identity" + "github.com/spf13/afero" +) + +const importIdentifier = "@import" + +var ( + cssSyntaxErrorRe = regexp.MustCompile(`> (\d+) \|`) + shouldImportRe = regexp.MustCompile(`^@import ["'](.*?)["'];?\s*(/\*.*\*/)?$`) +) + +type fileOffset struct { + Filename string + Offset int +} + +type importResolver struct { + r io.Reader + inPath string + opts InlineImports + + contentSeen map[string]bool + dependencyManager identity.Manager + linemap map[int]fileOffset + fs afero.Fs + logger loggers.Logger +} + +func newImportResolver(r io.Reader, inPath string, opts InlineImports, fs afero.Fs, logger loggers.Logger, dependencyManager identity.Manager) *importResolver { + return &importResolver{ + r: r, + dependencyManager: dependencyManager, + inPath: inPath, + fs: fs, logger: logger, + linemap: make(map[int]fileOffset), contentSeen: make(map[string]bool), + opts: opts, + } +} + +func (imp *importResolver) contentHash(filename string) ([]byte, string) { + b, err := afero.ReadFile(imp.fs, filename) + if err != nil { + return nil, "" + } + h := sha256.New() + h.Write(b) + return b, hex.EncodeToString(h.Sum(nil)) +} + +func (imp *importResolver) importRecursive( + lineNum int, + content string, + inPath string, +) (int, string, error) { + basePath := path.Dir(inPath) + + var replacements []string + lines := strings.Split(content, "\n") + + trackLine := func(i, offset int, line string) { + // TODO(bep) this is not very efficient. + imp.linemap[i+lineNum] = fileOffset{Filename: inPath, Offset: offset} + } + + i := 0 + for offset, line := range lines { + i++ + lineTrimmed := strings.TrimSpace(line) + column := strings.Index(line, lineTrimmed) + line = lineTrimmed + + if !imp.shouldImport(line) { + trackLine(i, offset, line) + } else { + path := strings.Trim(strings.TrimPrefix(line, importIdentifier), " \"';") + filename := filepath.Join(basePath, path) + imp.dependencyManager.AddIdentity(identity.CleanStringIdentity(filename)) + importContent, hash := imp.contentHash(filename) + + if importContent == nil { + if imp.opts.SkipInlineImportsNotFound { + trackLine(i, offset, line) + continue + } + pos := text.Position{ + Filename: inPath, + LineNumber: offset + 1, + ColumnNumber: column + 1, + } + return 0, "", herrors.NewFileErrorFromFileInPos(fmt.Errorf("failed to resolve CSS @import \"%s\"", filename), pos, imp.fs, nil) + } + + i-- + + if imp.contentSeen[hash] { + i++ + // Just replace the line with an empty string. + replacements = append(replacements, []string{line, ""}...) + trackLine(i, offset, "IMPORT") + continue + } + + imp.contentSeen[hash] = true + + // Handle recursive imports. + l, nested, err := imp.importRecursive(i+lineNum, string(importContent), filepath.ToSlash(filename)) + if err != nil { + return 0, "", err + } + + trackLine(i, offset, line) + + i += l + + importContent = []byte(nested) + + replacements = append(replacements, []string{line, string(importContent)}...) + } + } + + if len(replacements) > 0 { + repl := strings.NewReplacer(replacements...) + content = repl.Replace(content) + } + + return i, content, nil +} + +func (imp *importResolver) resolve() (io.Reader, error) { + content, err := io.ReadAll(imp.r) + if err != nil { + return nil, err + } + + contents := string(content) + + _, newContent, err := imp.importRecursive(0, contents, imp.inPath) + if err != nil { + return nil, err + } + + return strings.NewReader(newContent), nil +} + +// See https://www.w3schools.com/cssref/pr_import_rule.asp +// We currently only support simple file imports, no urls, no media queries. +// So this is OK: +// +// @import "navigation.css"; +// +// This is not: +// +// @import url("navigation.css"); +// @import "mobstyle.css" screen and (max-width: 768px); +func (imp *importResolver) shouldImport(s string) bool { + if !strings.HasPrefix(s, importIdentifier) { + return false + } + if strings.Contains(s, "url(") { + return false + } + + m := shouldImportRe.FindStringSubmatch(s) + if m == nil { + return false + } + + if len(m) != 3 { + return false + } + + if tailwindImportExclude(m[1]) { + return false + } + + return true +} + +func (imp *importResolver) toFileError(output string) error { + inErr := errors.New(output) + + match := cssSyntaxErrorRe.FindStringSubmatch(output) + if match == nil { + return inErr + } + + lineNum, err := strconv.Atoi(match[1]) + if err != nil { + return inErr + } + + file, ok := imp.linemap[lineNum] + if !ok { + return inErr + } + + fi, err := imp.fs.Stat(file.Filename) + if err != nil { + return inErr + } + + meta := fi.(hugofs.FileMetaInfo).Meta() + realFilename := meta.Filename + f, err := meta.Open() + if err != nil { + return inErr + } + defer f.Close() + + ferr := herrors.NewFileErrorFromName(inErr, realFilename) + pos := ferr.Position() + pos.LineNumber = file.Offset + 1 + return ferr.UpdatePosition(pos).UpdateContent(f, nil) + + // return herrors.NewFileErrorFromFile(inErr, file.Filename, realFilename, hugofs.Os, herrors.SimpleLineMatcher) +} diff --git a/resources/resource_transformers/postcss/postcss_test.go b/resources/resource_transformers/cssjs/inline_imports_test.go index 1edaaaaf5..9bcb7f9a3 100644 --- a/resources/resource_transformers/postcss/postcss_test.go +++ b/resources/resource_transformers/cssjs/inline_imports_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package postcss +package cssjs import ( "regexp" @@ -32,14 +32,14 @@ import ( // Issue 6166 func TestDecodeOptions(t *testing.T) { c := qt.New(t) - opts1, err := decodeOptions(map[string]any{ + opts1, err := decodePostCSSOptions(map[string]any{ "no-map": true, }) c.Assert(err, qt.IsNil) c.Assert(opts1.NoMap, qt.Equals, true) - opts2, err := decodeOptions(map[string]any{ + opts2, err := decodePostCSSOptions(map[string]any{ "noMap": true, }) @@ -67,6 +67,16 @@ func TestShouldImport(t *testing.T) { } } +func TestShouldImportExcludes(t *testing.T) { + c := qt.New(t) + var imp *importResolver + + c.Assert(imp.shouldImport(`@import "navigation.css";`), qt.Equals, true) + c.Assert(imp.shouldImport(`@import "tailwindcss";`), qt.Equals, false) + c.Assert(imp.shouldImport(`@import "tailwindcss.css";`), qt.Equals, true) + c.Assert(imp.shouldImport(`@import "tailwindcss/preflight";`), qt.Equals, false) +} + func TestImportResolver(t *testing.T) { c := qt.New(t) fs := afero.NewMemMapFs() @@ -95,7 +105,7 @@ LOCAL_STYLE imp := newImportResolver( mainStyles, "styles.css", - Options{}, + InlineImports{}, fs, loggers.NewDefault(), identity.NopManager, ) @@ -153,7 +163,7 @@ LOCAL_STYLE imp := newImportResolver( strings.NewReader(mainStyles), "styles.css", - Options{}, + InlineImports{}, fs, logger, identity.NopManager, ) diff --git a/resources/resource_transformers/postcss/postcss.go b/resources/resource_transformers/cssjs/postcss.go index 9015e120d..1a9e01142 100644 --- a/resources/resource_transformers/postcss/postcss.go +++ b/resources/resource_transformers/cssjs/postcss.go @@ -11,32 +11,23 @@ // See the License for the specific language governing permissions and // limitations under the License. -package postcss +// Package cssjs provides resource transformations backed by some popular JS based frameworks. +package cssjs import ( "bytes" - "crypto/sha256" - "encoding/hex" - "errors" "fmt" "io" - "path" "path/filepath" - "regexp" - "strconv" "strings" "github.com/gohugoio/hugo/common/collections" "github.com/gohugoio/hugo/common/hexec" "github.com/gohugoio/hugo/common/loggers" - "github.com/gohugoio/hugo/common/text" - "github.com/gohugoio/hugo/hugofs" - "github.com/gohugoio/hugo/identity" "github.com/gohugoio/hugo/common/hugo" "github.com/gohugoio/hugo/resources/internal" - "github.com/spf13/afero" "github.com/spf13/cast" "github.com/mitchellh/mapstructure" @@ -46,19 +37,12 @@ import ( "github.com/gohugoio/hugo/resources/resource" ) -const importIdentifier = "@import" - -var ( - cssSyntaxErrorRe = regexp.MustCompile(`> (\d+) \|`) - shouldImportRe = regexp.MustCompile(`^@import ["'].*["'];?\s*(/\*.*\*/)?$`) -) - -// New creates a new Client with the given specification. -func New(rs *resources.Spec) *Client { - return &Client{rs: rs} +// NewPostCSSClient creates a new PostCSSClient with the given specification. +func NewPostCSSClient(rs *resources.Spec) *PostCSSClient { + return &PostCSSClient{rs: rs} } -func decodeOptions(m map[string]any) (opts Options, err error) { +func decodePostCSSOptions(m map[string]any) (opts PostCSSOptions, err error) { if m == nil { return } @@ -74,23 +58,18 @@ func decodeOptions(m map[string]any) (opts Options, err error) { return } -// Client is the client used to do PostCSS transformations. -type Client struct { +// PostCSSClient is the client used to do PostCSS transformations. +type PostCSSClient struct { rs *resources.Spec } // Process transforms the given Resource with the PostCSS processor. -func (c *Client) Process(res resources.ResourceTransformer, options map[string]any) (resource.Resource, error) { +func (c *PostCSSClient) Process(res resources.ResourceTransformer, options map[string]any) (resource.Resource, error) { return res.Transform(&postcssTransformation{rs: c.rs, optionsm: options}) } -// Some of the options from https://github.com/postcss/postcss-cli -type Options struct { - // Set a custom path to look for a config file. - Config string - - NoMap bool // Disable the default inline sourcemaps - +type InlineImports struct { + // Service `mapstructure:",squash"` // Enable inlining of @import statements. // Does so recursively, but currently once only per file; // that is, it's not possible to import the same file in @@ -104,6 +83,16 @@ type Options struct { // Note that the inline importer does not process url location or imports with media queries, // so those will be left as-is even without enabling this option. SkipInlineImportsNotFound bool +} + +// Some of the options from https://github.com/postcss/postcss-cli +type PostCSSOptions struct { + // Set a custom path to look for a config file. + Config string + + NoMap bool // Disable the default inline sourcemaps + + InlineImports `mapstructure:",squash"` // Options for when not using a config file Use string // List of postcss plugins to use @@ -112,7 +101,7 @@ type Options struct { Syntax string // Custom postcss syntax } -func (opts Options) toArgs() []string { +func (opts PostCSSOptions) toArgs() []string { var args []string if opts.NoMap { args = append(args, "--no-map") @@ -156,13 +145,9 @@ func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationC var configFile string - var options Options - if t.optionsm != nil { - var err error - options, err = decodeOptions(t.optionsm) - if err != nil { - return err - } + options, err := decodePostCSSOptions(t.optionsm) + if err != nil { + return err } if options.Config != "" { @@ -219,11 +204,11 @@ func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationC imp := newImportResolver( ctx.From, ctx.InPath, - options, + options.InlineImports, t.rs.Assets.Fs, t.rs.Logger, ctx.DependencyManager, ) - if options.InlineImports { + if options.InlineImports.InlineImports { var err error src, err = imp.resolve() if err != nil { @@ -248,196 +233,3 @@ func (t *postcssTransformation) Transform(ctx *resources.ResourceTransformationC return nil } - -type fileOffset struct { - Filename string - Offset int -} - -type importResolver struct { - r io.Reader - inPath string - opts Options - - contentSeen map[string]bool - dependencyManager identity.Manager - linemap map[int]fileOffset - fs afero.Fs - logger loggers.Logger -} - -func newImportResolver(r io.Reader, inPath string, opts Options, fs afero.Fs, logger loggers.Logger, dependencyManager identity.Manager) *importResolver { - return &importResolver{ - r: r, - dependencyManager: dependencyManager, - inPath: inPath, - fs: fs, logger: logger, - linemap: make(map[int]fileOffset), contentSeen: make(map[string]bool), - opts: opts, - } -} - -func (imp *importResolver) contentHash(filename string) ([]byte, string) { - b, err := afero.ReadFile(imp.fs, filename) - if err != nil { - return nil, "" - } - h := sha256.New() - h.Write(b) - return b, hex.EncodeToString(h.Sum(nil)) -} - -func (imp *importResolver) importRecursive( - lineNum int, - content string, - inPath string, -) (int, string, error) { - basePath := path.Dir(inPath) - - var replacements []string - lines := strings.Split(content, "\n") - - trackLine := func(i, offset int, line string) { - // TODO(bep) this is not very efficient. - imp.linemap[i+lineNum] = fileOffset{Filename: inPath, Offset: offset} - } - - i := 0 - for offset, line := range lines { - i++ - lineTrimmed := strings.TrimSpace(line) - column := strings.Index(line, lineTrimmed) - line = lineTrimmed - - if !imp.shouldImport(line) { - trackLine(i, offset, line) - } else { - path := strings.Trim(strings.TrimPrefix(line, importIdentifier), " \"';") - filename := filepath.Join(basePath, path) - imp.dependencyManager.AddIdentity(identity.CleanStringIdentity(filename)) - importContent, hash := imp.contentHash(filename) - - if importContent == nil { - if imp.opts.SkipInlineImportsNotFound { - trackLine(i, offset, line) - continue - } - pos := text.Position{ - Filename: inPath, - LineNumber: offset + 1, - ColumnNumber: column + 1, - } - return 0, "", herrors.NewFileErrorFromFileInPos(fmt.Errorf("failed to resolve CSS @import \"%s\"", filename), pos, imp.fs, nil) - } - - i-- - - if imp.contentSeen[hash] { - i++ - // Just replace the line with an empty string. - replacements = append(replacements, []string{line, ""}...) - trackLine(i, offset, "IMPORT") - continue - } - - imp.contentSeen[hash] = true - - // Handle recursive imports. - l, nested, err := imp.importRecursive(i+lineNum, string(importContent), filepath.ToSlash(filename)) - if err != nil { - return 0, "", err - } - - trackLine(i, offset, line) - - i += l - - importContent = []byte(nested) - - replacements = append(replacements, []string{line, string(importContent)}...) - } - } - - if len(replacements) > 0 { - repl := strings.NewReplacer(replacements...) - content = repl.Replace(content) - } - - return i, content, nil -} - -func (imp *importResolver) resolve() (io.Reader, error) { - content, err := io.ReadAll(imp.r) - if err != nil { - return nil, err - } - - contents := string(content) - - _, newContent, err := imp.importRecursive(0, contents, imp.inPath) - if err != nil { - return nil, err - } - - return strings.NewReader(newContent), nil -} - -// See https://www.w3schools.com/cssref/pr_import_rule.asp -// We currently only support simple file imports, no urls, no media queries. -// So this is OK: -// -// @import "navigation.css"; -// -// This is not: -// -// @import url("navigation.css"); -// @import "mobstyle.css" screen and (max-width: 768px); -func (imp *importResolver) shouldImport(s string) bool { - if !strings.HasPrefix(s, importIdentifier) { - return false - } - if strings.Contains(s, "url(") { - return false - } - - return shouldImportRe.MatchString(s) -} - -func (imp *importResolver) toFileError(output string) error { - inErr := errors.New(output) - - match := cssSyntaxErrorRe.FindStringSubmatch(output) - if match == nil { - return inErr - } - - lineNum, err := strconv.Atoi(match[1]) - if err != nil { - return inErr - } - - file, ok := imp.linemap[lineNum] - if !ok { - return inErr - } - - fi, err := imp.fs.Stat(file.Filename) - if err != nil { - return inErr - } - - meta := fi.(hugofs.FileMetaInfo).Meta() - realFilename := meta.Filename - f, err := meta.Open() - if err != nil { - return inErr - } - defer f.Close() - - ferr := herrors.NewFileErrorFromName(inErr, realFilename) - pos := ferr.Position() - pos.LineNumber = file.Offset + 1 - return ferr.UpdatePosition(pos).UpdateContent(f, nil) - - // return herrors.NewFileErrorFromFile(inErr, file.Filename, realFilename, hugofs.Os, herrors.SimpleLineMatcher) -} diff --git a/resources/resource_transformers/postcss/postcss_integration_test.go b/resources/resource_transformers/cssjs/postcss_integration_test.go index 957e69403..e8f52326c 100644 --- a/resources/resource_transformers/postcss/postcss_integration_test.go +++ b/resources/resource_transformers/cssjs/postcss_integration_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Hugo Authors. All rights reserved. +// Copyright 2024 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. @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package postcss_test +package cssjs_test import ( "fmt" @@ -181,7 +181,7 @@ func TestTransformPostCSSNotInstalledError(t *testing.T) { }).BuildE() s.AssertIsFileError(err) - c.Assert(err.Error(), qt.Contains, `binary with name "npx" not found`) + c.Assert(err.Error(), qt.Contains, `binary with name "postcss" not found using npx`) } // #9895 diff --git a/resources/resource_transformers/cssjs/tailwindcss.go b/resources/resource_transformers/cssjs/tailwindcss.go new file mode 100644 index 000000000..f3195c596 --- /dev/null +++ b/resources/resource_transformers/cssjs/tailwindcss.go @@ -0,0 +1,167 @@ +// Copyright 2024 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 cssjs + +import ( + "bytes" + "io" + "regexp" + "strings" + + "github.com/gohugoio/hugo/common/herrors" + "github.com/gohugoio/hugo/common/hexec" + "github.com/gohugoio/hugo/common/hugo" + "github.com/gohugoio/hugo/common/loggers" + "github.com/gohugoio/hugo/resources" + "github.com/gohugoio/hugo/resources/internal" + "github.com/gohugoio/hugo/resources/resource" + "github.com/mitchellh/mapstructure" +) + +var ( + tailwindcssImportRe = regexp.MustCompile(`^tailwindcss/?`) + tailwindImportExclude = func(s string) bool { + return tailwindcssImportRe.MatchString(s) && !strings.Contains(s, ".") + } +) + +// NewTailwindCSSClient creates a new TailwindCSSClient with the given specification. +func NewTailwindCSSClient(rs *resources.Spec) *TailwindCSSClient { + return &TailwindCSSClient{rs: rs} +} + +// Client is the client used to do TailwindCSS transformations. +type TailwindCSSClient struct { + rs *resources.Spec +} + +// Process transforms the given Resource with the TailwindCSS processor. +func (c *TailwindCSSClient) Process(res resources.ResourceTransformer, options map[string]any) (resource.Resource, error) { + return res.Transform(&tailwindcssTransformation{rs: c.rs, optionsm: options}) +} + +type tailwindcssTransformation struct { + optionsm map[string]any + rs *resources.Spec +} + +func (t *tailwindcssTransformation) Key() internal.ResourceTransformationKey { + return internal.NewResourceTransformationKey("tailwindcss", t.optionsm) +} + +type TailwindCSSOptions struct { + Minify bool // Optimize and minify the output + Optimize bool // Optimize the output without minifying + InlineImports `mapstructure:",squash"` +} + +func (opts TailwindCSSOptions) toArgs() []any { + var args []any + if opts.Minify { + args = append(args, "--minify") + } + if opts.Optimize { + args = append(args, "--optimize") + } + return args +} + +func (t *tailwindcssTransformation) Transform(ctx *resources.ResourceTransformationCtx) error { + const binaryName = "tailwindcss" + + options, err := decodeTailwindCSSOptions(t.optionsm) + if err != nil { + return err + } + + infol := t.rs.Logger.InfoCommand(binaryName) + infow := loggers.LevelLoggerToWriter(infol) + + ex := t.rs.ExecHelper + + workingDir := t.rs.Cfg.BaseConfig().WorkingDir + + var cmdArgs []any = []any{ + "--input=-", // Read from stdin. + "--cwd", workingDir, + } + + cmdArgs = append(cmdArgs, options.toArgs()...) + + // TODO1 + // npm i tailwindcss @tailwindcss/cli + // npm i tailwindcss@next @tailwindcss/cli@next + // npx tailwindcss -h + + var errBuf bytes.Buffer + + stderr := io.MultiWriter(infow, &errBuf) + cmdArgs = append(cmdArgs, hexec.WithStderr(stderr)) + cmdArgs = append(cmdArgs, hexec.WithStdout(ctx.To)) + cmdArgs = append(cmdArgs, hexec.WithEnviron(hugo.GetExecEnviron(workingDir, t.rs.Cfg, t.rs.BaseFs.Assets.Fs))) + + cmd, err := ex.Npx(binaryName, cmdArgs...) + if err != nil { + if hexec.IsNotFound(err) { + // This may be on a CI server etc. Will fall back to pre-built assets. + return &herrors.FeatureNotAvailableError{Cause: err} + } + return err + } + + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + + src := ctx.From + + imp := newImportResolver( + ctx.From, + ctx.InPath, + options.InlineImports, + t.rs.Assets.Fs, t.rs.Logger, ctx.DependencyManager, + ) + + // TODO1 option { + src, err = imp.resolve() + if err != nil { + return err + } + + go func() { + defer stdin.Close() + io.Copy(stdin, src) + }() + + err = cmd.Run() + if err != nil { + if hexec.IsNotFound(err) { + return &herrors.FeatureNotAvailableError{ + Cause: err, + } + } + return imp.toFileError(errBuf.String()) + } + + return nil +} + +func decodeTailwindCSSOptions(m map[string]any) (opts TailwindCSSOptions, err error) { + if m == nil { + return + } + err = mapstructure.WeakDecode(m, &opts) + return +} diff --git a/resources/resource_transformers/cssjs/tailwindcss_integration_test.go b/resources/resource_transformers/cssjs/tailwindcss_integration_test.go new file mode 100644 index 000000000..ddd78b62f --- /dev/null +++ b/resources/resource_transformers/cssjs/tailwindcss_integration_test.go @@ -0,0 +1,72 @@ +// Copyright 2024 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 cssjs_test + +import ( + "testing" + + "github.com/bep/logg" + "github.com/gohugoio/hugo/htesting" + "github.com/gohugoio/hugo/hugolib" +) + +func TestTailwindV4Basic(t *testing.T) { + if !htesting.IsCI() { + t.Skip("Skip long running test when running locally") + } + + files := ` +-- hugo.toml -- +-- package.json -- +{ + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/bep/hugo-starter-tailwind-basic.git" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.0.0-alpha.16", + "tailwindcss": "^4.0.0-alpha.16" + }, + "name": "hugo-starter-tailwind-basic", + "version": "0.1.0" +} +-- assets/css/styles.css -- +@import "tailwindcss"; + +@theme { + --font-family-display: "Satoshi", "sans-serif"; + + --breakpoint-3xl: 1920px; + + --color-neon-pink: oklch(71.7% 0.25 360); + --color-neon-lime: oklch(91.5% 0.258 129); + --color-neon-cyan: oklch(91.3% 0.139 195.8); +} +-- layouts/index.html -- +{{ $css := resources.Get "css/styles.css" | css.TailwindCSS }} +CSS: {{ $css.Content | safeCSS }}| +` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + NeedsOsFS: true, + NeedsNpmInstall: true, + LogLevel: logg.LevelInfo, + }).Build() + + b.AssertFileContent("public/index.html", "/*! tailwindcss v4.0.0") +} diff --git a/resources/transform.go b/resources/transform.go index 9d8b5e915..b71c026b9 100644 --- a/resources/transform.go +++ b/resources/transform.go @@ -486,16 +486,20 @@ func (r *resourceAdapter) transform(key string, publish, setContent bool) (*reso if herrors.IsFeatureNotAvailableError(err) { var errMsg string - if tr.Key().Name == "postcss" { + switch strings.ToLower(tr.Key().Name) { + case "postcss": // This transformation is not available in this // Most likely because PostCSS is not installed. - errMsg = ". Check your PostCSS installation; install with \"npm install postcss-cli\". See https://gohugo.io/hugo-pipes/postcss/" - } else if tr.Key().Name == "tocss" { + errMsg = ". You need to install PostCSS. See https://gohugo.io/functions/css/postcss/" + case "tailwindcss": + errMsg = ". You need to install TailwindCSS CLI. See https://gohugo.io/functions/css/tailwindcss/" + case "tocss": errMsg = ". Check your Hugo installation; you need the extended version to build SCSS/SASS with transpiler set to 'libsass'." - } else if tr.Key().Name == "tocss-dart" { - errMsg = ". You need to install Dart Sass, see https://gohugo.io/functions/resources/tocss/#dart-sass" - } else if tr.Key().Name == "babel" { - errMsg = ". You need to install Babel, see https://gohugo.io/hugo-pipes/babel/" + case "tocss-dart": + errMsg = ". You need to install Dart Sass, see https://gohugo.io//functions/css/sass/#dart-sass" + case "babel": + errMsg = ". You need to install Babel, see https://gohugo.io/functions/js/babel/" + } return fmt.Errorf(msg+errMsg+": %w", err) |