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 | |
parent | eddcd2bac6bfd3cc0ac1a3b38bf8c4ae452ea23b (diff) | |
download | hugo-e1317dd32281dc5ce670e34165dc7780c8f5892b.tar.gz hugo-e1317dd32281dc5ce670e34165dc7780c8f5892b.zip |
Add css.TailwindCSS
Closes #12618
Closes #12620
20 files changed, 644 insertions, 285 deletions
diff --git a/commands/hugobuilder.go b/commands/hugobuilder.go index 84608ca37..1a4a61264 100644 --- a/commands/hugobuilder.go +++ b/commands/hugobuilder.go @@ -920,7 +920,7 @@ func (c *hugoBuilder) handleEvents(watcher *watcher.Batcher, if len(otherChanges) > 0 { livereload.ForceRefresh() // Allow some time for the live reload script to get reconnected. - time.Sleep(100 * time.Millisecond) + time.Sleep(200 * time.Millisecond) } for _, ev := range cssChanges { diff --git a/common/hexec/exec.go b/common/hexec/exec.go index 49291354d..4f23d20f5 100644 --- a/common/hexec/exec.go +++ b/common/hexec/exec.go @@ -21,8 +21,10 @@ import ( "io" "os" "os/exec" + "path/filepath" "regexp" "strings" + "sync" "github.com/cli/safeexec" "github.com/gohugoio/hugo/config" @@ -84,7 +86,7 @@ var WithEnviron = func(env []string) func(c *commandeer) { } // New creates a new Exec using the provided security config. -func New(cfg security.Config) *Exec { +func New(cfg security.Config, workingDir string) *Exec { var baseEnviron []string for _, v := range os.Environ() { k, _ := config.SplitEnvVar(v) @@ -95,6 +97,7 @@ func New(cfg security.Config) *Exec { return &Exec{ sc: cfg, + workingDir: workingDir, baseEnviron: baseEnviron, } } @@ -119,15 +122,23 @@ func SafeCommand(name string, arg ...string) (*exec.Cmd, error) { // Exec enforces a security policy for commands run via os/exec. type Exec struct { - sc security.Config + sc security.Config + workingDir string // os.Environ filtered by the Exec.OsEnviron whitelist filter. baseEnviron []string + + npxInit sync.Once + npxAvailable bool +} + +func (e *Exec) New(name string, arg ...any) (Runner, error) { + return e.new(name, "", arg...) } // New will fail if name is not allowed according to the configured security policy. // Else a configured Runner will be returned ready to be Run. -func (e *Exec) New(name string, arg ...any) (Runner, error) { +func (e *Exec) new(name string, fullyQualifiedName string, arg ...any) (Runner, error) { if err := e.sc.CheckAllowedExec(name); err != nil { return nil, err } @@ -136,27 +147,51 @@ func (e *Exec) New(name string, arg ...any) (Runner, error) { copy(env, e.baseEnviron) cm := &commandeer{ - name: name, - env: env, + name: name, + fullyQualifiedName: fullyQualifiedName, + env: env, } return cm.command(arg...) } -// Npx will try to run npx, and if that fails, it will -// try to run the binary directly. +// Npx will in order: +// 1. Try fo find the binary in the WORKINGDIR/node_modules/.bin directory. +// 2. If not found, and npx is available, run npx --no-install <name> <args>. +// 3. Fall back to the PATH. func (e *Exec) Npx(name string, arg ...any) (Runner, error) { - r, err := e.npx(name, arg...) + // npx is slow, so first try the common case. + nodeBinFilename := filepath.Join(e.workingDir, nodeModulesBinPath, name) + _, err := safeexec.LookPath(nodeBinFilename) if err == nil { - return r, nil + return e.new(name, nodeBinFilename, arg...) + } + e.checkNpx() + if e.npxAvailable { + r, err := e.npx(name, arg...) + if err == nil { + return r, nil + } } return e.New(name, arg...) } +const ( + npxNoInstall = "--no-install" + npxBinary = "npx" + nodeModulesBinPath = "node_modules/.bin" +) + +func (e *Exec) checkNpx() { + e.npxInit.Do(func() { + e.npxAvailable = InPath(npxBinary) + }) +} + // npx is a convenience method to create a Runner running npx --no-install <name> <args. func (e *Exec) npx(name string, arg ...any) (Runner, error) { - arg = append(arg[:0], append([]any{"--no-install", name}, arg[0:]...)...) - return e.New("npx", arg...) + arg = append(arg[:0], append([]any{npxNoInstall, name}, arg[0:]...)...) + return e.New(npxBinary, arg...) } // Sec returns the security policies this Exec is configured with. @@ -165,11 +200,12 @@ func (e *Exec) Sec() security.Config { } type NotFoundError struct { - name string + name string + method string } func (e *NotFoundError) Error() string { - return fmt.Sprintf("binary with name %q not found", e.name) + return fmt.Sprintf("binary with name %q not found %s", e.name, e.method) } // Runner wraps a *os.Cmd. @@ -192,8 +228,14 @@ func (c *cmdWrapper) Run() error { if err == nil { return nil } + name := c.name + method := "in PATH" + if name == npxBinary { + name = c.c.Args[2] + method = "using npx" + } if notFoundRe.MatchString(c.outerr.String()) { - return &NotFoundError{name: c.name} + return &NotFoundError{name: name, method: method} } return fmt.Errorf("failed to execute binary %q with args %v: %s", c.name, c.c.Args[1:], c.outerr.String()) } @@ -209,8 +251,9 @@ type commandeer struct { dir string ctx context.Context - name string - env []string + name string + fullyQualifiedName string + env []string } func (c *commandeer) command(arg ...any) (*cmdWrapper, error) { @@ -230,10 +273,17 @@ func (c *commandeer) command(arg ...any) (*cmdWrapper, error) { } } - bin, err := safeexec.LookPath(c.name) - if err != nil { - return nil, &NotFoundError{ - name: c.name, + var bin string + if c.fullyQualifiedName != "" { + bin = c.fullyQualifiedName + } else { + var err error + bin, err = safeexec.LookPath(c.name) + if err != nil { + return nil, &NotFoundError{ + name: c.name, + method: "in PATH", + } } } diff --git a/config/allconfig/load.go b/config/allconfig/load.go index 117b8e89c..84419cb2e 100644 --- a/config/allconfig/load.go +++ b/config/allconfig/load.go @@ -467,7 +467,7 @@ func (l *configLoader) loadModules(configs *Configs) (modules.ModulesConfig, *mo ignoreVendor, _ = hglob.GetGlob(hglob.NormalizePath(s)) } - ex := hexec.New(conf.Security) + ex := hexec.New(conf.Security, workingDir) hook := func(m *modules.ModulesConfig) error { for _, tc := range m.AllModules { diff --git a/config/security/securityConfig.go b/config/security/securityConfig.go index be9e901f1..46a5d0a23 100644 --- a/config/security/securityConfig.go +++ b/config/security/securityConfig.go @@ -39,6 +39,7 @@ var DefaultConfig = Config{ "^go$", // for Go Modules "^npx$", // used by all Node tools (Babel, PostCSS). "^postcss$", + "^tailwindcss$", ), // These have been tested to work with Hugo's external programs // on Windows, Linux and MacOS. diff --git a/config/security/securityConfig_test.go b/config/security/securityConfig_test.go index 57e615a48..87a243012 100644 --- a/config/security/securityConfig_test.go +++ b/config/security/securityConfig_test.go @@ -135,7 +135,7 @@ func TestToTOML(t *testing.T) { got := DefaultConfig.ToTOML() c.Assert(got, qt.Equals, - "[security]\n enableInlineShortcodes = false\n\n [security.exec]\n allow = ['^(dart-)?sass(-embedded)?$', '^go$', '^npx$', '^postcss$']\n osEnv = ['(?i)^((HTTPS?|NO)_PROXY|PATH(EXT)?|APPDATA|TE?MP|TERM|GO\\w+|(XDG_CONFIG_)?HOME|USERPROFILE|SSH_AUTH_SOCK|DISPLAY|LANG|SYSTEMDRIVE)$']\n\n [security.funcs]\n getenv = ['^HUGO_', '^CI$']\n\n [security.http]\n methods = ['(?i)GET|POST']\n urls = ['.*']", + "[security]\n enableInlineShortcodes = false\n\n [security.exec]\n allow = ['^(dart-)?sass(-embedded)?$', '^go$', '^npx$', '^postcss$', '^tailwindcss$']\n osEnv = ['(?i)^((HTTPS?|NO)_PROXY|PATH(EXT)?|APPDATA|TE?MP|TERM|GO\\w+|(XDG_CONFIG_)?HOME|USERPROFILE|SSH_AUTH_SOCK|DISPLAY|LANG|SYSTEMDRIVE)$']\n\n [security.funcs]\n getenv = ['^HUGO_', '^CI$']\n\n [security.http]\n methods = ['(?i)GET|POST']\n urls = ['.*']", ) } diff --git a/deps/deps.go b/deps/deps.go index 4805af1aa..0d0f283c2 100644 --- a/deps/deps.go +++ b/deps/deps.go @@ -163,7 +163,7 @@ func (d *Deps) Init() error { } if d.ExecHelper == nil { - d.ExecHelper = hexec.New(d.Conf.GetConfigSection("security").(security.Config)) + d.ExecHelper = hexec.New(d.Conf.GetConfigSection("security").(security.Config), d.Conf.WorkingDir()) } if d.MemCache == nil { diff --git a/hugolib/integrationtest_builder.go b/hugolib/integrationtest_builder.go index 758fc4ec9..551b807db 100644 --- a/hugolib/integrationtest_builder.go +++ b/hugolib/integrationtest_builder.go @@ -659,7 +659,7 @@ func (s *IntegrationTestBuilder) initBuilder() error { sc := security.DefaultConfig sc.Exec.Allow, err = security.NewWhitelist("npm") s.Assert(err, qt.IsNil) - ex := hexec.New(sc) + ex := hexec.New(sc, s.Cfg.WorkingDir) command, err := ex.New("npm", "install") s.Assert(err, qt.IsNil) s.Assert(command.Run(), qt.IsNil) diff --git a/hugolib/testhelpers_test.go b/hugolib/testhelpers_test.go index dab693623..746f4c26e 100644 --- a/hugolib/testhelpers_test.go +++ b/hugolib/testhelpers_test.go @@ -834,7 +834,7 @@ func (s *sitesBuilder) NpmInstall() hexec.Runner { var err error sc.Exec.Allow, err = security.NewWhitelist("npm") s.Assert(err, qt.IsNil) - ex := hexec.New(sc) + ex := hexec.New(sc, s.workingDir) command, err := ex.New("npm", "install") s.Assert(err, qt.IsNil) return command diff --git a/markup/asciidocext/convert_test.go b/markup/asciidocext/convert_test.go index 18c38a621..b3f63b4d8 100644 --- a/markup/asciidocext/convert_test.go +++ b/markup/asciidocext/convert_test.go @@ -313,7 +313,7 @@ allow = ['asciidoctor'] converter.ProviderConfig{ Logger: loggers.NewDefault(), Conf: conf, - Exec: hexec.New(securityConfig), + Exec: hexec.New(securityConfig, ""), }, ) c.Assert(err, qt.IsNil) diff --git a/markup/pandoc/convert_test.go b/markup/pandoc/convert_test.go index dec30c410..8beedc115 100644 --- a/markup/pandoc/convert_test.go +++ b/markup/pandoc/convert_test.go @@ -34,7 +34,7 @@ func TestConvert(t *testing.T) { var err error sc.Exec.Allow, err = security.NewWhitelist("pandoc") c.Assert(err, qt.IsNil) - p, err := Provider.New(converter.ProviderConfig{Exec: hexec.New(sc), Logger: loggers.NewDefault()}) + p, err := Provider.New(converter.ProviderConfig{Exec: hexec.New(sc, ""), Logger: loggers.NewDefault()}) c.Assert(err, qt.IsNil) conv, err := p.New(converter.DocumentContext{}) c.Assert(err, qt.IsNil) diff --git a/markup/rst/convert_test.go b/markup/rst/convert_test.go index 1897e650f..182858263 100644 --- a/markup/rst/convert_test.go +++ b/markup/rst/convert_test.go @@ -36,7 +36,7 @@ func TestConvert(t *testing.T) { p, err := Provider.New( converter.ProviderConfig{ Logger: loggers.NewDefault(), - Exec: hexec.New(sc), + Exec: hexec.New(sc, ""), }) c.Assert(err, qt.IsNil) conv, err := p.New(converter.DocumentContext{}) diff --git a/modules/client_test.go b/modules/client_test.go index d727c4586..0ee7e0dbc 100644 --- a/modules/client_test.go +++ b/modules/client_test.go @@ -61,7 +61,7 @@ github.com/gohugoio/hugoTestModules1_darwin/[email protected] github.com/gohugoio/h WorkingDir: workingDir, ThemesDir: themesDir, PublishDir: publishDir, - Exec: hexec.New(security.DefaultConfig), + Exec: hexec.New(security.DefaultConfig, ""), } withConfig(&ccfg) 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) diff --git a/tpl/css/css.go b/tpl/css/css.go index 145cb3aad..48a526c17 100644 --- a/tpl/css/css.go +++ b/tpl/css/css.go @@ -13,7 +13,7 @@ import ( "github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/resources/resource" "github.com/gohugoio/hugo/resources/resource_transformers/babel" - "github.com/gohugoio/hugo/resources/resource_transformers/postcss" + "github.com/gohugoio/hugo/resources/resource_transformers/cssjs" "github.com/gohugoio/hugo/resources/resource_transformers/tocss/dartsass" "github.com/gohugoio/hugo/resources/resource_transformers/tocss/scss" "github.com/gohugoio/hugo/tpl/internal" @@ -27,7 +27,8 @@ const name = "css" type Namespace struct { d *deps.Deps scssClientLibSass *scss.Client - postcssClient *postcss.Client + postcssClient *cssjs.PostCSSClient + tailwindcssClient *cssjs.TailwindCSSClient babelClient *babel.Client // The Dart Client requires a os/exec process, so only @@ -63,7 +64,21 @@ func (ns *Namespace) PostCSS(args ...any) (resource.Resource, error) { return ns.postcssClient.Process(r, m) } -// Sass processes the given Resource with Sass. +// TailwindCSS processes the given Resource with tailwindcss. +func (ns *Namespace) TailwindCSS(args ...any) (resource.Resource, error) { + if len(args) > 2 { + return nil, errors.New("must not provide more arguments than resource object and options") + } + + r, m, err := resourcehelpers.ResolveArgs(args) + if err != nil { + return nil, err + } + + return ns.tailwindcssClient.Process(r, m) +} + +// Sass processes the given Resource with SASS. func (ns *Namespace) Sass(args ...any) (resource.Resource, error) { if len(args) > 2 { return nil, errors.New("must not provide more arguments than resource object and options") @@ -144,7 +159,8 @@ func init() { ctx := &Namespace{ d: d, scssClientLibSass: scssClient, - postcssClient: postcss.New(d.ResourceSpec), + postcssClient: cssjs.NewPostCSSClient(d.ResourceSpec), + tailwindcssClient: cssjs.NewTailwindCSSClient(d.ResourceSpec), babelClient: babel.New(d.ResourceSpec), } |