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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
hugofs: Make node_modules a "special case" mount
For this and similar mounts in a theme:

```toml
[[module.mounts]]
source = 'node_modules/bootstrap'
target = 'assets/vendor/bootstrap'
```

We first check the theme itself, then the project root.

For backwards compatibility, we also make any `../../node_modules/...` `source` paths into `node_modules/...` paths when defined in themes/modules.

Fixes #14089
  • Loading branch information
bep committed Oct 24, 2025
commit 90af7b609101e01ba47212831d3863b26e1aa3e4
40 changes: 40 additions & 0 deletions hugofs/hugofs_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
package hugofs_test

import (
"strings"
"testing"

qt "github.com/frankban/quicktest"
Expand Down Expand Up @@ -44,3 +45,42 @@ All.
b.Assert(err, qt.IsNotNil)
b.Assert(err.Error(), qt.Contains, "mount source must be a local path for modules/themes")
}

// Issue 14089.
func TestMountNodeMoudulesFromTheme(t *testing.T) {
filesTemplate := `
-- hugo.toml --
disableKinds = ["taxonomy", "term", "rss"]
theme = "mytheme"
-- node_modules/bootstrap/foo.txt --
foo project.
-- layouts/all.html --
{{ $foo := resources.Get "vendor/bootstrap/foo.txt" }}
Foo: {{ with $foo }}{{ .Content }}{{ else }}Fail{{ end }}
-- themes/mytheme/hugo.toml --
[[module.mounts]]
source = 'NODE_MODULES_SOURCE' # tries first in theme, then in project root
target = 'assets/vendor/bootstrap'

`
runFiles := func(files string) *hugolib.IntegrationTestBuilder {
return hugolib.Test(t, files, hugolib.TestOptOsFs())
}
files := strings.ReplaceAll(filesTemplate, "NODE_MODULES_SOURCE", "node_modules/bootstrap")
b := runFiles(files)
b.AssertFileContent("public/index.html", "Foo: foo project.")

// This is for backwards compatibility. ../../node_modules/bootstrap works exactly the same as node_modules/bootstrap.
files = strings.ReplaceAll(filesTemplate, "NODE_MODULES_SOURCE", "../../node_modules/bootstrap")
b = runFiles(files)
b.AssertFileContent("public/index.html", "Foo: foo project.")

files = strings.ReplaceAll(filesTemplate, "NODE_MODULES_SOURCE", "node_modules/bootstrap")
files += `
-- themes/mytheme/node_modules/bootstrap/foo.txt --
foo theme.
`

b = runFiles(files)
b.AssertFileContent("public/index.html", "Foo: foo theme.")
}
39 changes: 36 additions & 3 deletions modules/collect.go
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,19 @@ func (c *collector) mountCommonJSConfig(owner *moduleAdapter, mounts []Mount) ([
return mounts, nil
}

func (c *collector) nodeModulesRoot(s string) string {
s = filepath.ToSlash(s)
if strings.HasPrefix(s, "node_modules/") {
return s
}
if strings.HasPrefix(s, "../../node_modules/") {
// See #14083. This was a common construct to mount node_modules from the project root.
// This started failing in v0.152.0 when we tightened the validation.
return strings.TrimPrefix(s, "../../")
}
return ""
}

func (c *collector) normalizeMounts(owner *moduleAdapter, mounts []Mount) ([]Mount, error) {
var out []Mount
dir := owner.Dir()
Expand All @@ -706,6 +719,17 @@ func (c *collector) normalizeMounts(owner *moduleAdapter, mounts []Mount) ([]Mou
return nil, errors.New(errMsg + ": both source and target must be set")
}

// Special case for node_modules imports in themes/modules.
// See #14089.
var isModuleNodeModulesImport bool
if !owner.projectMod {
nodeModulesImportSource := c.nodeModulesRoot(mnt.Source)
if nodeModulesImportSource != "" {
isModuleNodeModulesImport = true
mnt.Source = nodeModulesImportSource
}
}

mnt.Source = filepath.Clean(mnt.Source)
mnt.Target = filepath.Clean(mnt.Target)
var sourceDir string
Expand Down Expand Up @@ -741,9 +765,18 @@ func (c *collector) normalizeMounts(owner *moduleAdapter, mounts []Mount) ([]Mou
}
f.Close()
} else {
// TODO(bep) commenting out for now, as this will create to much noise.
// c.logger.Warnf("module %q: mount source %q does not exist", owner.Path(), sourceDir)
continue
if isModuleNodeModulesImport {
// A module imported a path inside node_modules, but it didn't exist.
// Make this a special case and also try relative to the project root.
sourceDir = filepath.Join(c.ccfg.WorkingDir, mnt.Source)
_, err := c.fs.Stat(sourceDir)
if err != nil {
continue
}
mnt.Source = sourceDir
} else {
continue
}
}
}

Expand Down
Loading