diff --git a/hugofs/hugofs_integration_test.go b/hugofs/hugofs_integration_test.go index 1be1384f0..e3fcfdcb0 100644 --- a/hugofs/hugofs_integration_test.go +++ b/hugofs/hugofs_integration_test.go @@ -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.") +} diff --git a/modules/collect.go b/modules/collect.go index 5f3a39e02..560c61d6a 100644 --- a/modules/collect.go +++ b/modules/collect.go @@ -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 + } } }