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
This commit is contained in:
Bjørn Erik Pedersen
2025-10-24 10:36:32 +02:00
parent 08a0679a89
commit 809ebe01fa
2 changed files with 76 additions and 3 deletions
+40
View File
@@ -14,6 +14,7 @@
package hugofs_test
import (
"strings"
"testing"
qt "github.com/frankban/quicktest"
@@ -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.")
}
+36 -3
View File
@@ -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()
@@ -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
@@ -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
}
}
}