Skip to content

Commit cfa0801

Browse files
committed
Fix NPX issue with TailwindCSS v4
This allows the `tailwindcss` CLI binary to live in the `PATH` for NPM-less projects. Fixes #13221
1 parent f024a50 commit cfa0801

File tree

9 files changed

+94
-25
lines changed

9 files changed

+94
-25
lines changed

common/hexec/exec.go

+85-17
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ import (
2626
"strings"
2727
"sync"
2828

29+
"github.com/bep/logg"
2930
"github.com/cli/safeexec"
31+
"github.com/gohugoio/hugo/common/loggers"
32+
"github.com/gohugoio/hugo/common/maps"
3033
"github.com/gohugoio/hugo/config"
3134
"github.com/gohugoio/hugo/config/security"
3235
)
@@ -86,7 +89,7 @@ var WithEnviron = func(env []string) func(c *commandeer) {
8689
}
8790

8891
// New creates a new Exec using the provided security config.
89-
func New(cfg security.Config, workingDir string) *Exec {
92+
func New(cfg security.Config, workingDir string, log loggers.Logger) *Exec {
9093
var baseEnviron []string
9194
for _, v := range os.Environ() {
9295
k, _ := config.SplitEnvVar(v)
@@ -96,9 +99,11 @@ func New(cfg security.Config, workingDir string) *Exec {
9699
}
97100

98101
return &Exec{
99-
sc: cfg,
100-
workingDir: workingDir,
101-
baseEnviron: baseEnviron,
102+
sc: cfg,
103+
workingDir: workingDir,
104+
infol: log.InfoCommand("exec"),
105+
baseEnviron: baseEnviron,
106+
newNPXRunnerCache: maps.NewCache[string, func(arg ...any) (Runner, error)](),
102107
}
103108
}
104109

@@ -124,12 +129,14 @@ func SafeCommand(name string, arg ...string) (*exec.Cmd, error) {
124129
type Exec struct {
125130
sc security.Config
126131
workingDir string
132+
infol logg.LevelLogger
127133

128134
// os.Environ filtered by the Exec.OsEnviron whitelist filter.
129135
baseEnviron []string
130136

131-
npxInit sync.Once
132-
npxAvailable bool
137+
newNPXRunnerCache *maps.Cache[string, func(arg ...any) (Runner, error)]
138+
npxInit sync.Once
139+
npxAvailable bool
133140
}
134141

135142
func (e *Exec) New(name string, arg ...any) (Runner, error) {
@@ -155,25 +162,86 @@ func (e *Exec) new(name string, fullyQualifiedName string, arg ...any) (Runner,
155162
return cm.command(arg...)
156163
}
157164

165+
type binaryLocation int
166+
167+
func (b binaryLocation) String() string {
168+
switch b {
169+
case binaryLocationNodeModules:
170+
return "node_modules/.bin"
171+
case binaryLocationNpx:
172+
return "npx"
173+
case binaryLocationPath:
174+
return "PATH"
175+
}
176+
return "unknown"
177+
}
178+
179+
const (
180+
binaryLocationNodeModules binaryLocation = iota + 1
181+
binaryLocationNpx
182+
binaryLocationPath
183+
)
184+
158185
// Npx will in order:
159186
// 1. Try fo find the binary in the WORKINGDIR/node_modules/.bin directory.
160187
// 2. If not found, and npx is available, run npx --no-install <name> <args>.
161188
// 3. Fall back to the PATH.
189+
// If name is "tailwindcss", we will try the PATH as the second option.
162190
func (e *Exec) Npx(name string, arg ...any) (Runner, error) {
163-
// npx is slow, so first try the common case.
164-
nodeBinFilename := filepath.Join(e.workingDir, nodeModulesBinPath, name)
165-
_, err := safeexec.LookPath(nodeBinFilename)
166-
if err == nil {
167-
return e.new(name, nodeBinFilename, arg...)
191+
if err := e.sc.CheckAllowedExec(name); err != nil {
192+
return nil, err
168193
}
169-
e.checkNpx()
170-
if e.npxAvailable {
171-
r, err := e.npx(name, arg...)
172-
if err == nil {
173-
return r, nil
194+
195+
newRunner, err := e.newNPXRunnerCache.GetOrCreate(name, func() (func(...any) (Runner, error), error) {
196+
type tryFunc func() func(...any) (Runner, error)
197+
tryFuncs := map[binaryLocation]tryFunc{
198+
binaryLocationNodeModules: func() func(...any) (Runner, error) {
199+
nodeBinFilename := filepath.Join(e.workingDir, nodeModulesBinPath, name)
200+
_, err := safeexec.LookPath(nodeBinFilename)
201+
if err != nil {
202+
return nil
203+
}
204+
return func(arg2 ...any) (Runner, error) {
205+
return e.new(name, nodeBinFilename, arg2...)
206+
}
207+
},
208+
binaryLocationNpx: func() func(...any) (Runner, error) {
209+
e.checkNpx()
210+
if !e.npxAvailable {
211+
return nil
212+
}
213+
return func(arg2 ...any) (Runner, error) {
214+
return e.npx(name, arg2...)
215+
}
216+
},
217+
binaryLocationPath: func() func(...any) (Runner, error) {
218+
if _, err := safeexec.LookPath(name); err != nil {
219+
return nil
220+
}
221+
return func(arg2 ...any) (Runner, error) {
222+
return e.New(name, arg2...)
223+
}
224+
},
225+
}
226+
227+
locations := []binaryLocation{binaryLocationNodeModules, binaryLocationNpx, binaryLocationPath}
228+
if name == "tailwindcss" {
229+
// See https://github.com/gohugoio/hugo/issues/13221#issuecomment-2574801253
230+
locations = []binaryLocation{binaryLocationNodeModules, binaryLocationPath, binaryLocationNpx}
174231
}
232+
for _, loc := range locations {
233+
if f := tryFuncs[loc](); f != nil {
234+
e.infol.Logf("resolve %q using %s", name, loc)
235+
return f, nil
236+
}
237+
}
238+
return nil, &NotFoundError{name: name, method: fmt.Sprintf("in %s", locations[len(locations)-1])}
239+
})
240+
if err != nil {
241+
return nil, err
175242
}
176-
return e.New(name, arg...)
243+
244+
return newRunner(arg...)
177245
}
178246

179247
const (

config/allconfig/load.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,7 @@ func (l *configLoader) loadModules(configs *Configs, ignoreModuleDoesNotExist bo
470470
ignoreVendor, _ = hglob.GetGlob(hglob.NormalizePath(s))
471471
}
472472

473-
ex := hexec.New(conf.Security, workingDir)
473+
ex := hexec.New(conf.Security, workingDir, l.Logger)
474474

475475
hook := func(m *modules.ModulesConfig) error {
476476
for _, tc := range m.AllModules {

deps/deps.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ func (d *Deps) Init() error {
188188
}
189189

190190
if d.ExecHelper == nil {
191-
d.ExecHelper = hexec.New(d.Conf.GetConfigSection("security").(security.Config), d.Conf.WorkingDir())
191+
d.ExecHelper = hexec.New(d.Conf.GetConfigSection("security").(security.Config), d.Conf.WorkingDir(), d.Log)
192192
}
193193

194194
if d.MemCache == nil {

hugolib/integrationtest_builder.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -729,7 +729,7 @@ func (s *IntegrationTestBuilder) initBuilder() error {
729729
sc := security.DefaultConfig
730730
sc.Exec.Allow, err = security.NewWhitelist("npm")
731731
s.Assert(err, qt.IsNil)
732-
ex := hexec.New(sc, s.Cfg.WorkingDir)
732+
ex := hexec.New(sc, s.Cfg.WorkingDir, loggers.NewDefault())
733733
command, err := ex.New("npm", "install")
734734
s.Assert(err, qt.IsNil)
735735
s.Assert(command.Run(), qt.IsNil)

hugolib/testhelpers_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -838,7 +838,7 @@ func (s *sitesBuilder) NpmInstall() hexec.Runner {
838838
var err error
839839
sc.Exec.Allow, err = security.NewWhitelist("npm")
840840
s.Assert(err, qt.IsNil)
841-
ex := hexec.New(sc, s.workingDir)
841+
ex := hexec.New(sc, s.workingDir, loggers.NewDefault())
842842
command, err := ex.New("npm", "install")
843843
s.Assert(err, qt.IsNil)
844844
return command

markup/asciidocext/convert_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ allow = ['asciidoctor']
313313
converter.ProviderConfig{
314314
Logger: loggers.NewDefault(),
315315
Conf: conf,
316-
Exec: hexec.New(securityConfig, ""),
316+
Exec: hexec.New(securityConfig, "", loggers.NewDefault()),
317317
},
318318
)
319319
c.Assert(err, qt.IsNil)

markup/pandoc/convert_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func TestConvert(t *testing.T) {
3434
var err error
3535
sc.Exec.Allow, err = security.NewWhitelist("pandoc")
3636
c.Assert(err, qt.IsNil)
37-
p, err := Provider.New(converter.ProviderConfig{Exec: hexec.New(sc, ""), Logger: loggers.NewDefault()})
37+
p, err := Provider.New(converter.ProviderConfig{Exec: hexec.New(sc, "", loggers.NewDefault()), Logger: loggers.NewDefault()})
3838
c.Assert(err, qt.IsNil)
3939
conv, err := p.New(converter.DocumentContext{})
4040
c.Assert(err, qt.IsNil)

markup/rst/convert_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func TestConvert(t *testing.T) {
3636
p, err := Provider.New(
3737
converter.ProviderConfig{
3838
Logger: loggers.NewDefault(),
39-
Exec: hexec.New(sc, ""),
39+
Exec: hexec.New(sc, "", loggers.NewDefault()),
4040
})
4141
c.Assert(err, qt.IsNil)
4242
conv, err := p.New(converter.DocumentContext{})

modules/client_test.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"testing"
2323

2424
"github.com/gohugoio/hugo/common/hexec"
25+
"github.com/gohugoio/hugo/common/loggers"
2526
"github.com/gohugoio/hugo/config/security"
2627
"github.com/gohugoio/hugo/hugofs/glob"
2728

@@ -61,7 +62,7 @@ github.com/gohugoio/hugoTestModules1_darwin/modh2_2@v1.4.0 github.com/gohugoio/h
6162
WorkingDir: workingDir,
6263
ThemesDir: themesDir,
6364
PublishDir: publishDir,
64-
Exec: hexec.New(security.DefaultConfig, ""),
65+
Exec: hexec.New(security.DefaultConfig, "", loggers.NewDefault()),
6566
}
6667

6768
withConfig(&ccfg)

0 commit comments

Comments
 (0)