Repository: gohugoio/hugo (language: go)

Issue:
Dart Sass transpiler error in Hugo v0.123.6
### What version of Hugo are you using (`hugo version`)?

<pre>
hugo v0.123.6-92684f9a26838a46d1a81e3c250fef5207bcb735+extended darwin/arm64 BuildDate=2024-02-28T18:29:40Z VendorInfo=brew
</pre>

### Does this issue reproduce with the latest release?

Yes

### Context

When upgrading Hugo from v0.122.0 to v0.123.6 I get a Dart Sass transpiler error. Below the error logs (specific to the `mod-bootstrap` repo):

```bash
ERROR TOCSS-DART: failed to transform "style.app.scss" (text/x-scss): "/Users/mark/Development/GitHub/gethinode/mod-bootstrap/assets/scss/modules/bootstrap/_mixins-dart.scss:9:8": Can't find stylesheet to import.
Total in 211 ms
Error: error building site: TOCSS-DART: failed to transform "style.app.scss" (text/x-scss): "/Users/mark/Development/GitHub/gethinode/mod-bootstrap/assets/scss/modules/bootstrap/_mixins-dart.scss:9:8": Can't find stylesheet to import.
```

The full environment:

```bash
hugo v0.123.6-92684f9a26838a46d1a81e3c250fef5207bcb735+extended darwin/arm64 BuildDate=2024-02-28T18:29:40Z VendorInfo=brew
GOOS="darwin"
GOARCH="arm64"
GOVERSION="go1.22.0"
github.com/sass/libsass="3.6.5"
github.com/webmproject/libwebp="v1.3.2"
github.com/sass/dart-sass/protocol="2.5.0"
github.com/sass/dart-sass/compiler="1.71.1"
github.com/sass/dart-sass/implementation="1.71.1"
```

The error does not occur when using `libsass`.

### To reproduce

```bash
git clone https://github.com/gethinode/mod-bootstrap.git
cd mod-bootstrap/exampleSite
hugo server -e dart
```

Switching to `libsass` solves the issue:

```bash
hugo server
```

### Notes

A similar issue was already solved, this issue might be another regression: https://github.com/gohugoio/hugo/issues/12072


Discussion / hints:

Fix patch:
diff --git a/hugofs/rootmapping_fs.go b/hugofs/rootmapping_fs.go
index a5bf9aadfe8..336c8b4e71a 100644
--- a/hugofs/rootmapping_fs.go
+++ b/hugofs/rootmapping_fs.go
@@ -21,6 +21,7 @@ import (
 	"path"
 	"path/filepath"
 	"strings"
+	"sync/atomic"
 
 	"github.com/gohugoio/hugo/common/herrors"
 	"github.com/gohugoio/hugo/common/paths"
@@ -43,6 +44,7 @@ var _ ReverseLookupProvder = (*RootMappingFs)(nil)
 func NewRootMappingFs(fs afero.Fs, rms ...RootMapping) (*RootMappingFs, error) {
 	rootMapToReal := radix.New()
 	realMapToRoot := radix.New()
+	id := fmt.Sprintf("rfs-%d", rootMappingFsCounter.Add(1))
 
 	addMapping := func(key string, rm RootMapping, to *radix.Tree) {
 		var mappings []RootMapping
@@ -76,6 +78,16 @@ func NewRootMappingFs(fs afero.Fs, rms ...RootMapping) (*RootMappingFs, error) {
 			rm.Meta = NewFileMeta()
 		}
 
+		if rm.FromBase == "" {
+			panic(" rm.FromBase is empty")
+		}
+
+		rm.Meta.Component = rm.FromBase
+		rm.Meta.Module = rm.Module
+		rm.Meta.ModuleOrdinal = rm.ModuleOrdinal
+		rm.Meta.IsProject = rm.IsProject
+		rm.Meta.BaseDir = rm.ToBase
+
 		if !fi.IsDir() {
 			// We do allow single file mounts.
 			// However, the file system logic will be much simpler with just directories.
@@ -122,19 +134,9 @@ func NewRootMappingFs(fs afero.Fs, rms ...RootMapping) (*RootMappingFs, error) {
 			}
 		}
 
-		if rm.FromBase == "" {
-			panic(" rm.FromBase is empty")
-		}
-
 		// Extract "blog" from "content/blog"
 		rm.path = strings.TrimPrefix(strings.TrimPrefix(rm.From, rm.FromBase), filepathSeparator)
-
 		rm.Meta.SourceRoot = fi.(MetaProvider).Meta().Filename
-		rm.Meta.BaseDir = rm.ToBase
-		rm.Meta.Module = rm.Module
-		rm.Meta.ModuleOrdinal = rm.ModuleOrdinal
-		rm.Meta.Component = rm.FromBase
-		rm.Meta.IsProject = rm.IsProject
 
 		meta := rm.Meta.Copy()
 
@@ -156,6 +158,7 @@ func NewRootMappingFs(fs afero.Fs, rms ...RootMapping) (*RootMappingFs, error) {
 	}
 
 	rfs := &RootMappingFs{
+		id:            id,
 		Fs:            fs,
 		rootMapToReal: rootMapToReal,
 		realMapToRoot: realMapToRoot,
@@ -227,11 +230,14 @@ var _ FilesystemUnwrapper = (*RootMappingFs)(nil)
 // is directories only, and they will be returned in Readdir and Readdirnames
 // in the order given.
 type RootMappingFs struct {
+	id string
 	afero.Fs
 	rootMapToReal *radix.Tree
 	realMapToRoot *radix.Tree
 }
 
+var rootMappingFsCounter atomic.Int32
+
 func (fs *RootMappingFs) Mounts(base string) ([]FileMetaInfo, error) {
 	base = filepathSeparator + fs.cleanName(base)
 	roots := fs.getRootsWithPrefix(base)
@@ -263,6 +269,10 @@ func (fs *RootMappingFs) Mounts(base string) ([]FileMetaInfo, error) {
 	return fss, nil
 }
 
+func (fs *RootMappingFs) Key() string {
+	return fs.id
+}
+
 func (fs *RootMappingFs) UnwrapFilesystem() afero.Fs {
 	return fs.Fs
 }
@@ -320,16 +330,16 @@ func (c ComponentPath) ComponentPathJoined() string {
 }
 
 type ReverseLookupProvder interface {
-	ReverseLookup(filename string, checkExists bool) ([]ComponentPath, error)
-	ReverseLookupComponent(component, filename string, checkExists bool) ([]ComponentPath, error)
+	ReverseLookup(filename string) ([]ComponentPath, error)
+	ReverseLookupComponent(component, filename string) ([]ComponentPath, error)
 }
 
 // func (fs *RootMappingFs) ReverseStat(filename string) ([]FileMetaInfo, error)
-func (fs *RootMappingFs) ReverseLookup(filename string, checkExists bool) ([]ComponentPath, error) {
-	return fs.ReverseLookupComponent("", filename, checkExists)
+func (fs *RootMappingFs) ReverseLookup(filename string) ([]ComponentPath, error) {
+	return fs.ReverseLookupComponent("", filename)
 }
 
-func (fs *RootMappingFs) ReverseLookupComponent(component, filename string, checkExists bool) ([]ComponentPath, error) {
+func (fs *RootMappingFs) ReverseLookupComponent(component, filename string) ([]ComponentPath, error) {
 	filename = fs.cleanName(filename)
 	key := filepathSeparator + filename
 
@@ -360,14 +370,6 @@ func (fs *RootMappingFs) ReverseLookupComponent(component, filename string, chec
 		} else {
 			// Now we know that this file _could_ be in this fs.
 			filename = filepathSeparator + filepath.Join(first.path, dir, name)
-
-			if checkExists {
-				// Confirm that it exists.
-				_, err := fs.Stat(first.FromBase + filename)
-				if err != nil {
-					continue
-				}
-			}
 		}
 
 		cps = append(cps, ComponentPath{
@@ -667,6 +669,7 @@ func (fs *RootMappingFs) doStat(name string) ([]FileMetaInfo, error) {
 		var fis []FileMetaInfo
 
 		for _, rm := range roots {
+
 			var fi FileMetaInfo
 			fi, err = fs.statRoot(rm, name)
 			if err == nil {
diff --git a/hugolib/filesystems/basefs.go b/hugolib/filesystems/basefs.go
index aa466e0eb2a..b3e3284d536 100644
--- a/hugolib/filesystems/basefs.go
+++ b/hugolib/filesystems/basefs.go
@@ -265,6 +265,9 @@ type SourceFilesystem struct {
 	// This is a virtual composite filesystem. It expects path relative to a context.
 	Fs afero.Fs
 
+	// The source filesystem (usually the OS filesystem).
+	SourceFs afero.Fs
+
 	// When syncing a source folder to the target (e.g. /public), this may
 	// be set to publish into a subfolder. This is used for static syncing
 	// in multihost mode.
@@ -320,10 +323,10 @@ func (s SourceFilesystems) IsContent(filename string) bool {
 }
 
 // ResolvePaths resolves the given filename to a list of paths in the filesystems.
-func (s *SourceFilesystems) ResolvePaths(filename string, checkExists bool) []hugofs.ComponentPath {
+func (s *SourceFilesystems) ResolvePaths(filename string) []hugofs.ComponentPath {
 	var cpss []hugofs.ComponentPath
 	for _, rfs := range s.RootFss {
-		cps, err := rfs.ReverseLookup(filename, checkExists)
+		cps, err := rfs.ReverseLookup(filename)
 		if err != nil {
 			panic(err)
 		}
@@ -362,7 +365,17 @@ func (d *SourceFilesystem) ReverseLookup(filename string, checkExists bool) ([]h
 	var cps []hugofs.ComponentPath
 	hugofs.WalkFilesystems(d.Fs, func(fs afero.Fs) bool {
 		if rfs, ok := fs.(hugofs.ReverseLookupProvder); ok {
-			if c, err := rfs.ReverseLookupComponent(d.Name, filename, checkExists); err == nil {
+			if c, err := rfs.ReverseLookupComponent(d.Name, filename); err == nil {
+				if checkExists {
+					n := 0
+					for _, cp := range c {
+						if _, err := d.Fs.Stat(filepath.FromSlash(cp.Path)); err == nil {
+							c[n] = cp
+							n++
+						}
+					}
+					c = c[:n]
+				}
 				cps = append(cps, c...)
 			}
 		}
@@ -379,11 +392,12 @@ func (d *SourceFilesystem) mounts() []hugofs.FileMetaInfo {
 			if err == nil {
 				m = append(m, mounts...)
 			}
-
 		}
 		return false
 	})
+
 	// Filter out any mounts not belonging to this filesystem.
+	// TODO(bep) I think this is superflous.
 	n := 0
 	for _, mm := range m {
 		if mm.Meta().Component == d.Name {
@@ -392,6 +406,7 @@ func (d *SourceFilesystem) mounts() []hugofs.FileMetaInfo {
 		}
 	}
 	m = m[:n]
+
 	return m
 }
 
@@ -428,10 +443,8 @@ func (d *SourceFilesystem) RealDirs(from string) []string {
 		if !m.IsDir() {
 			continue
 		}
-		meta := m.Meta()
-		_, err := d.Fs.Stat(from)
-		if err == nil {
-			dirname := filepath.Join(meta.Filename, from)
+		dirname := filepath.Join(m.Meta().Filename, from)
+		if _, err := d.SourceFs.Stat(dirname); err == nil {
 			dirnames = append(dirnames, dirname)
 		}
 	}
@@ -519,8 +532,9 @@ func newSourceFilesystemsBuilder(p *paths.Paths, logger loggers.Logger, b *BaseF
 
 func (b *sourceFilesystemsBuilder) newSourceFilesystem(name string, fs afero.Fs) *SourceFilesystem {
 	return &SourceFilesystem{
-		Name: name,
-		Fs:   fs,
+		Name:     name,
+		Fs:       fs,
+		SourceFs: b.sourceFs,
 	}
 }
 
diff --git a/hugolib/hugo_sites_build.go b/hugolib/hugo_sites_build.go
index 93acdbf6bfa..382b1eed02b 100644
--- a/hugolib/hugo_sites_build.go
+++ b/hugolib/hugo_sites_build.go
@@ -656,7 +656,7 @@ func (h *HugoSites) processPartial(ctx context.Context, l logg.LevelLogger, conf
 
 		isChangedDir := statErr == nil && fi.IsDir()
 
-		cpss := h.BaseFs.ResolvePaths(ev.Name, !removed)
+		cpss := h.BaseFs.ResolvePaths(ev.Name)
 		pss := make([]*paths.Path, len(cpss))
 		for i, cps := range cpss {
 			p := cps.Path


Test patch:
diff --git a/hugofs/rootmapping_fs_test.go b/hugofs/rootmapping_fs_test.go
index b1ef102d36e..83a95d648c2 100644
--- a/hugofs/rootmapping_fs_test.go
+++ b/hugofs/rootmapping_fs_test.go
@@ -276,20 +276,20 @@ func TestRootMappingFsMount(t *testing.T) {
 
 	// Test ReverseLookup.
 	// Single file mounts.
-	cps, err := rfs.ReverseLookup(filepath.FromSlash("singlefiles/no.txt"), true)
+	cps, err := rfs.ReverseLookup(filepath.FromSlash("singlefiles/no.txt"))
 	c.Assert(err, qt.IsNil)
 	c.Assert(cps, qt.DeepEquals, []ComponentPath{
 		{Component: "content", Path: "singles/p1.md", Lang: "no"},
 	})
 
-	cps, err = rfs.ReverseLookup(filepath.FromSlash("singlefiles/sv.txt"), true)
+	cps, err = rfs.ReverseLookup(filepath.FromSlash("singlefiles/sv.txt"))
 	c.Assert(err, qt.IsNil)
 	c.Assert(cps, qt.DeepEquals, []ComponentPath{
 		{Component: "content", Path: "singles/p1.md", Lang: "sv"},
 	})
 
 	// File inside directory mount.
-	cps, err = rfs.ReverseLookup(filepath.FromSlash("mynoblogcontent/test.txt"), true)
+	cps, err = rfs.ReverseLookup(filepath.FromSlash("mynoblogcontent/test.txt"))
 	c.Assert(err, qt.IsNil)
 	c.Assert(cps, qt.DeepEquals, []ComponentPath{
 		{Component: "content", Path: "blog/test.txt", Lang: "no"},
diff --git a/hugolib/integrationtest_builder.go b/hugolib/integrationtest_builder.go
index 642bac7abf1..be11c18d692 100644
--- a/hugolib/integrationtest_builder.go
+++ b/hugolib/integrationtest_builder.go
@@ -183,6 +183,13 @@ type lockingBuffer struct {
 	bytes.Buffer
 }
 
+func (b *lockingBuffer) ReadFrom(r io.Reader) (n int64, err error) {
+	b.Lock()
+	n, err = b.Buffer.ReadFrom(r)
+	b.Unlock()
+	return
+}
+
 func (b *lockingBuffer) Write(p []byte) (n int, err error) {
 	b.Lock()
 	n, err = b.Buffer.Write(p)
diff --git a/resources/resource_transformers/tocss/dartsass/dartsass_integration_test.go b/resources/resource_transformers/tocss/dartsass/dartsass_integration_test.go
index dd4c1e5cae3..4d48b3b6a6b 100644
--- a/resources/resource_transformers/tocss/dartsass/dartsass_integration_test.go
+++ b/resources/resource_transformers/tocss/dartsass/dartsass_integration_test.go
@@ -19,6 +19,7 @@ import (
 
 	"github.com/bep/logg"
 	qt "github.com/frankban/quicktest"
+	"github.com/gohugoio/hugo/htesting"
 	"github.com/gohugoio/hugo/hugolib"
 	"github.com/gohugoio/hugo/resources/resource_transformers/tocss/dartsass"
 )
@@ -525,3 +526,39 @@ T1: {{ $r.Content }}
 	b.AssertLogMatches(`Dart Sass: .*assets.*main.scss:13:0: number`)
 	b.AssertLogMatches(`Dart Sass: .*assets.*main.scss:14:0: number`)
 }
+
+// Note: This test is more or less duplicated in both of the SCSS packages (libsass and dartsass).
+func TestBootstrap(t *testing.T) {
+	t.Parallel()
+	if !dartsass.Supports() {
+		t.Skip()
+	}
+	if !htesting.IsCI() {
+		t.Skip("skip (slow) test in non-CI environment")
+	}
+
+	files := `
+-- hugo.toml --
+disableKinds = ["term", "taxonomy", "section", "page"]
+[module]
+[[module.imports]]
+path="github.com/gohugoio/hugo-mod-bootstrap-scss/v5"
+-- go.mod --
+module github.com/gohugoio/tests/testHugoModules
+-- assets/scss/main.scss --
+@import "bootstrap/bootstrap";
+-- layouts/index.html --
+{{ $cssOpts := (dict "transpiler" "dartsass" ) }}
+{{ $r := resources.Get "scss/main.scss" |  toCSS $cssOpts }}
+Styles: {{ $r.RelPermalink }}
+		`
+
+	b := hugolib.NewIntegrationTestBuilder(
+		hugolib.IntegrationTestConfig{
+			T:           t,
+			TxtarString: files,
+			NeedsOsFS:   true,
+		}).Build()
+
+	b.AssertFileContent("public/index.html", "Styles: /scss/main.css")
+}
diff --git a/resources/resource_transformers/tocss/scss/scss_integration_test.go b/resources/resource_transformers/tocss/scss/scss_integration_test.go
index 4d7d9d710ab..c193ca8af6b 100644
--- a/resources/resource_transformers/tocss/scss/scss_integration_test.go
+++ b/resources/resource_transformers/tocss/scss/scss_integration_test.go
@@ -20,6 +20,7 @@ import (
 
 	qt "github.com/frankban/quicktest"
 
+	"github.com/gohugoio/hugo/htesting"
 	"github.com/gohugoio/hugo/hugolib"
 	"github.com/gohugoio/hugo/resources/resource_transformers/tocss/scss"
 )
@@ -290,3 +291,39 @@ T1: {{ $r.Content }}
 
 	b.AssertFileContent("public/index.html", `T1: body body{background:url(images/hero.jpg) no-repeat center/cover;font-family:Hugo&#39;s New Roman}p{color:blue;font-size:var 24px}b{color:green}`)
 }
+
+// Note: This test is more or less duplicated in both of the SCSS packages (libsass and dartsass).
+func TestBootstrap(t *testing.T) {
+	t.Parallel()
+	if !scss.Supports() {
+		t.Skip()
+	}
+	if !htesting.IsCI() {
+		t.Skip("skip (slow) test in non-CI environment")
+	}
+
+	files := `
+-- hugo.toml --
+disableKinds = ["term", "taxonomy", "section", "page"]
+[module]
+[[module.imports]]
+path="github.com/gohugoio/hugo-mod-bootstrap-scss/v5"
+-- go.mod --
+module github.com/gohugoio/tests/testHugoModules
+-- assets/scss/main.scss --
+@import "bootstrap/bootstrap";
+-- layouts/index.html --
+{{ $cssOpts := (dict "transpiler" "libsass" ) }}
+{{ $r := resources.Get "scss/main.scss" |  toCSS $cssOpts }}
+Styles: {{ $r.RelPermalink }}
+		`
+
+	b := hugolib.NewIntegrationTestBuilder(
+		hugolib.IntegrationTestConfig{
+			T:           t,
+			TxtarString: files,
+			NeedsOsFS:   true,
+		}).Build()
+
+	b.AssertFileContent("public/index.html", "Styles: /scss/main.css")
+}