Repository: gohugoio/hugo (language: go)

Issue:
Replace CopyOnWriteFs => OverlayFs
<!-- Describe this new feature. Think about if it really belongs in the Hugo core module; you may want to discuss it on https://discourse.gohugo.io/ first.  -->


Discussion / hints:

Fix patch:
diff --git a/go.mod b/go.mod
index b7202f686bd..060bcc1ba9b 100644
--- a/go.mod
+++ b/go.mod
@@ -12,7 +12,7 @@ require (
 	github.com/bep/godartsass v0.14.0
 	github.com/bep/golibsass v1.0.0
 	github.com/bep/gowebp v0.1.0
-	github.com/bep/overlayfs v0.1.0
+	github.com/bep/overlayfs v0.4.0
 	github.com/bep/tmc v0.5.1
 	github.com/clbanning/mxj/v2 v2.5.5
 	github.com/cli/safeexec v1.0.0
diff --git a/go.sum b/go.sum
index 59f3b1365d0..cbc6aa9b898 100644
--- a/go.sum
+++ b/go.sum
@@ -188,6 +188,12 @@ github.com/bep/gowebp v0.1.0 h1:4/iQpfnxHyXs3x/aTxMMdOpLEQQhFmF6G7EieWPTQyo=
 github.com/bep/gowebp v0.1.0/go.mod h1:ZhFodwdiFp8ehGJpF4LdPl6unxZm9lLFjxD3z2h2AgI=
 github.com/bep/overlayfs v0.1.0 h1:1hOCrvS4E5Hf0qwxM7m+9oitqClD9mRjQ1d4pECsVcU=
 github.com/bep/overlayfs v0.1.0/go.mod h1:NFjSmn3kCqG7KX2Lmz8qT8VhPPCwZap3UNogXawoQHM=
+github.com/bep/overlayfs v0.2.0 h1:JSJbbXLi0FRHtadJCmUNvaFWEAZhpDbX1nLNiKviECM=
+github.com/bep/overlayfs v0.2.0/go.mod h1:NFjSmn3kCqG7KX2Lmz8qT8VhPPCwZap3UNogXawoQHM=
+github.com/bep/overlayfs v0.3.0 h1:Vufu7kg4ehDJcjOaLTSiZI0F6tSF0Aqt0AWRi44DzSg=
+github.com/bep/overlayfs v0.3.0/go.mod h1:NFjSmn3kCqG7KX2Lmz8qT8VhPPCwZap3UNogXawoQHM=
+github.com/bep/overlayfs v0.4.0 h1:J/G5YltfU2BxO2KV/VcFzJo94jpRMjtthRNEZ+7V7uA=
+github.com/bep/overlayfs v0.4.0/go.mod h1:NFjSmn3kCqG7KX2Lmz8qT8VhPPCwZap3UNogXawoQHM=
 github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI=
 github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0=
 github.com/bep/workers v1.0.0 h1:U+H8YmEaBCEaFZBst7GcRVEoqeRC9dzH2dWOwGmOchg=
diff --git a/hugofs/language_composite_fs.go b/hugofs/language_composite_fs.go
deleted file mode 100644
index 9b4bc4cfd39..00000000000
--- a/hugofs/language_composite_fs.go
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright 2018 The Hugo Authors. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package hugofs
-
-import (
-	"os"
-
-	"github.com/spf13/afero"
-)
-
-var (
-	_ afero.Fs             = (*languageCompositeFs)(nil)
-	_ afero.Lstater        = (*languageCompositeFs)(nil)
-	_ FilesystemsUnwrapper = (*languageCompositeFs)(nil)
-)
-
-type languageCompositeFs struct {
-	base    afero.Fs
-	overlay afero.Fs
-	*afero.CopyOnWriteFs
-}
-
-// NewLanguageCompositeFs creates a composite and language aware filesystem.
-// This is a hybrid filesystem. To get a specific file in Open, Stat etc., use the full filename
-// to the target filesystem. This information is available in Readdir, Stat etc. via the
-// special LanguageFileInfo FileInfo implementation.
-func NewLanguageCompositeFs(base, overlay afero.Fs) afero.Fs {
-	return &languageCompositeFs{base, overlay, afero.NewCopyOnWriteFs(base, overlay).(*afero.CopyOnWriteFs)}
-}
-
-func (fs *languageCompositeFs) UnwrapFilesystems() []afero.Fs {
-	return []afero.Fs{fs.base, fs.overlay}
-}
-
-// Open takes the full path to the file in the target filesystem. If it is a directory, it gets merged
-// using the language as a weight.
-func (fs *languageCompositeFs) Open(name string) (afero.File, error) {
-	f, err := fs.CopyOnWriteFs.Open(name)
-	if err != nil {
-		return nil, err
-	}
-
-	fu, ok := f.(*afero.UnionFile)
-	if ok {
-		// This is a directory: Merge it.
-		fu.Merger = LanguageDirsMerger
-	}
-	return f, nil
-}
-
-// LanguageDirsMerger implements the afero.DirsMerger interface, which is used
-// to merge two directories.
-var LanguageDirsMerger = func(lofi, bofi []os.FileInfo) ([]os.FileInfo, error) {
-	for _, fi1 := range bofi {
-		fim1 := fi1.(FileMetaInfo)
-		var found bool
-		for _, fi2 := range lofi {
-			fim2 := fi2.(FileMetaInfo)
-			if fi1.Name() == fi2.Name() && fim1.Meta().Lang == fim2.Meta().Lang {
-				found = true
-				break
-			}
-		}
-		if !found {
-			lofi = append(lofi, fi1)
-		}
-	}
-
-	return lofi, nil
-}
diff --git a/hugofs/language_merge.go b/hugofs/language_merge.go
new file mode 100644
index 00000000000..a2fa411a9c0
--- /dev/null
+++ b/hugofs/language_merge.go
@@ -0,0 +1,39 @@
+// Copyright 2022 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package hugofs
+
+import (
+	"os"
+)
+
+// LanguageDirsMerger implements the overlayfs.DirsMerger func, which is used
+// to merge two directories.
+var LanguageDirsMerger = func(lofi, bofi []os.FileInfo) []os.FileInfo {
+	for _, fi1 := range bofi {
+		fim1 := fi1.(FileMetaInfo)
+		var found bool
+		for _, fi2 := range lofi {
+			fim2 := fi2.(FileMetaInfo)
+			if fi1.Name() == fi2.Name() && fim1.Meta().Lang == fim2.Meta().Lang {
+				found = true
+				break
+			}
+		}
+		if !found {
+			lofi = append(lofi, fi1)
+		}
+	}
+
+	return lofi
+}
diff --git a/hugofs/noop_fs.go b/hugofs/noop_fs.go
index 12b4e937e35..8e4abbc6b27 100644
--- a/hugofs/noop_fs.go
+++ b/hugofs/noop_fs.go
@@ -38,11 +38,11 @@ func (fs noOpFs) Create(name string) (afero.File, error) {
 }
 
 func (fs noOpFs) Mkdir(name string, perm os.FileMode) error {
-	return errNoOp
+	return nil
 }
 
 func (fs noOpFs) MkdirAll(path string, perm os.FileMode) error {
-	return errNoOp
+	return nil
 }
 
 func (fs noOpFs) Open(name string) (afero.File, error) {
@@ -54,11 +54,11 @@ func (fs noOpFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File,
 }
 
 func (fs noOpFs) Remove(name string) error {
-	return errNoOp
+	return nil
 }
 
 func (fs noOpFs) RemoveAll(path string) error {
-	return errNoOp
+	return nil
 }
 
 func (fs noOpFs) Rename(oldname string, newname string) error {
diff --git a/hugolib/content_factory.go b/hugolib/content_factory.go
index bf16a982190..bea98894da6 100644
--- a/hugolib/content_factory.go
+++ b/hugolib/content_factory.go
@@ -112,6 +112,7 @@ func (f ContentFactory) SectionFromFilename(filename string) (string, error) {
 func (f ContentFactory) CreateContentPlaceHolder(filename string) (string, error) {
 	filename = filepath.Clean(filename)
 	_, abs, err := f.h.AbsProjectContentDir(filename)
+
 	if err != nil {
 		return "", err
 	}
diff --git a/hugolib/filesystems/basefs.go b/hugolib/filesystems/basefs.go
index 693dd857588..d02f8c624c5 100644
--- a/hugolib/filesystems/basefs.go
+++ b/hugolib/filesystems/basefs.go
@@ -24,6 +24,7 @@ import (
 	"strings"
 	"sync"
 
+	"github.com/bep/overlayfs"
 	"github.com/gohugoio/hugo/htesting"
 	"github.com/gohugoio/hugo/hugofs/glob"
 
@@ -145,12 +146,14 @@ func (b *BaseFs) AbsProjectContentDir(filename string) (string, string, error) {
 		if !meta.IsProject {
 			continue
 		}
+
 		if isAbs {
 			if strings.HasPrefix(filename, meta.Filename) {
 				return strings.TrimPrefix(filename, meta.Filename), filename, nil
 			}
 		} else {
-			contentDir := strings.TrimPrefix(strings.TrimPrefix(meta.Filename, meta.BaseDir), filePathSeparator)
+			contentDir := strings.TrimPrefix(strings.TrimPrefix(meta.Filename, meta.BaseDir), filePathSeparator) + filePathSeparator
+
 			if strings.HasPrefix(filename, contentDir) {
 				relFilename := strings.TrimPrefix(filename, contentDir)
 				absFilename := filepath.Join(meta.Filename, relFilename)
@@ -163,14 +166,14 @@ func (b *BaseFs) AbsProjectContentDir(filename string) (string, string, error) {
 	if !isAbs {
 		// A filename on the form "posts/mypage.md", put it inside
 		// the first content folder, usually <workDir>/content.
-		// Pick the last project dir (which is probably the most important one).
-		contentDirs := b.SourceFilesystems.Content.Dirs
-		for i := len(contentDirs) - 1; i >= 0; i-- {
-			meta := contentDirs[i].Meta()
+		// Pick the first project dir (which is probably the most important one).
+		for _, dir := range b.SourceFilesystems.Content.Dirs {
+			meta := dir.Meta()
 			if meta.IsProject {
 				return filename, filepath.Join(meta.Filename, filename), nil
 			}
 		}
+
 	}
 
 	return "", "", errors.Errorf("could not determine content directory for %q", filename)
@@ -260,10 +263,16 @@ type SourceFilesystem struct {
 // The order is content, static and then assets.
 // TODO(bep) check usage
 func (s SourceFilesystems) ContentStaticAssetFs(lang string) afero.Fs {
-	staticFs := s.StaticFs(lang)
+	return overlayfs.New(
+		overlayfs.Options{
+			Fss: []afero.Fs{
+				s.Content.Fs,
+				s.StaticFs(lang),
+				s.Assets.Fs,
+			},
+		},
+	)
 
-	base := afero.NewCopyOnWriteFs(s.Assets.Fs, staticFs)
-	return afero.NewCopyOnWriteFs(base, s.Content.Fs)
 }
 
 // StaticFs returns the static filesystem for the given language.
@@ -491,7 +500,6 @@ func (b *sourceFilesystemsBuilder) newSourceFilesystem(name string, fs afero.Fs,
 
 func (b *sourceFilesystemsBuilder) Build() (*SourceFilesystems, error) {
 	if b.theBigFs == nil {
-
 		theBigFs, err := b.createMainOverlayFs(b.p)
 		if err != nil {
 			return nil, errors.Wrap(err, "create main fs")
@@ -510,8 +518,6 @@ func (b *sourceFilesystemsBuilder) Build() (*SourceFilesystems, error) {
 		return b.newSourceFilesystem(componentID, afero.NewBasePathFs(b.theBigFs.overlayMounts, componentID), dirs)
 	}
 
-	b.theBigFs.finalizeDirs()
-
 	b.result.Archetypes = createView(files.ComponentFolderArchetypes)
 	b.result.Layouts = createView(files.ComponentFolderLayouts)
 	b.result.Assets = createView(files.ComponentFolderAssets)
@@ -566,9 +572,12 @@ func (b *sourceFilesystemsBuilder) Build() (*SourceFilesystems, error) {
 }
 
 func (b *sourceFilesystemsBuilder) createMainOverlayFs(p *paths.Paths) (*filesystemsCollector, error) {
-	var staticFsMap map[string]afero.Fs
+	var staticFsMap map[string]*overlayfs.OverlayFs
 	if b.p.Cfg.GetBool("multihost") {
-		staticFsMap = make(map[string]afero.Fs)
+		staticFsMap = make(map[string]*overlayfs.OverlayFs)
+		for _, l := range b.p.Languages {
+			staticFsMap[l.Lang] = overlayfs.New(overlayfs.Options{})
+		}
 	}
 
 	collector := &filesystemsCollector{
@@ -576,35 +585,33 @@ func (b *sourceFilesystemsBuilder) createMainOverlayFs(p *paths.Paths) (*filesys
 		sourceModules:     hugofs.NewNoSymlinkFs(b.sourceFs, b.logger, false),
 		overlayDirs:       make(map[string][]hugofs.FileMetaInfo),
 		staticPerLanguage: staticFsMap,
+
+		overlayMounts:        overlayfs.New(overlayfs.Options{}),
+		overlayMountsContent: overlayfs.New(overlayfs.Options{DirsMerger: hugofs.LanguageDirsMerger}),
+		overlayMountsStatic:  overlayfs.New(overlayfs.Options{DirsMerger: hugofs.LanguageDirsMerger}),
+		overlayFull:          overlayfs.New(overlayfs.Options{}),
+		overlayResources:     overlayfs.New(overlayfs.Options{FirstWritable: true}),
 	}
 
 	mods := p.AllModules
 
-	if len(mods) == 0 {
-		return collector, nil
-	}
-
-	modsReversed := make([]mountsDescriptor, len(mods))
+	mounts := make([]mountsDescriptor, len(mods))
 
-	// The theme components are ordered from left to right.
-	// We need to revert it to get the
-	// overlay logic below working as expected, with the project on top.
-	j := 0
-	for i := len(mods) - 1; i >= 0; i-- {
+	for i := 0; i < len(mods); i++ {
 		mod := mods[i]
 		dir := mod.Dir()
 
 		isMainProject := mod.Owner() == nil
-		modsReversed[j] = mountsDescriptor{
+		mounts[i] = mountsDescriptor{
 			Module:        mod,
 			dir:           dir,
 			isMainProject: isMainProject,
-			ordinal:       j,
+			ordinal:       i,
 		}
-		j++
+
 	}
 
-	err := b.createOverlayFs(collector, modsReversed)
+	err := b.createOverlayFs(collector, mounts)
 
 	return collector, err
 }
@@ -617,137 +624,143 @@ func (b *sourceFilesystemsBuilder) isStaticMount(mnt modules.Mount) bool {
 	return strings.HasPrefix(mnt.Target, files.ComponentFolderStatic)
 }
 
-func (b *sourceFilesystemsBuilder) createModFs(
+func (b *sourceFilesystemsBuilder) createOverlayFs(
 	collector *filesystemsCollector,
-	md mountsDescriptor) error {
-	var (
-		fromTo        []hugofs.RootMapping
-		fromToContent []hugofs.RootMapping
-		fromToStatic  []hugofs.RootMapping
-	)
+	mounts []mountsDescriptor) error {
 
-	absPathify := func(path string) (string, string) {
-		if filepath.IsAbs(path) {
-			return "", path
+	if len(mounts) == 0 {
+		appendNopIfEmpty := func(ofs *overlayfs.OverlayFs) *overlayfs.OverlayFs {
+			if ofs.NumFilesystems() > 0 {
+				return ofs
+			}
+			return ofs.Append(hugofs.NoOpFs)
 		}
-		return md.dir, hpaths.AbsPathify(md.dir, path)
-	}
+		collector.overlayMounts = appendNopIfEmpty(collector.overlayMounts)
+		collector.overlayMountsContent = appendNopIfEmpty(collector.overlayMountsContent)
+		collector.overlayMountsStatic = appendNopIfEmpty(collector.overlayMountsStatic)
+		collector.overlayFull = appendNopIfEmpty(collector.overlayFull)
+		collector.overlayResources = appendNopIfEmpty(collector.overlayResources)
 
-	for i, mount := range md.Mounts() {
-
-		// Add more weight to early mounts.
-		// When two mounts contain the same filename,
-		// the first entry wins.
-		mountWeight := (10 + md.ordinal) * (len(md.Mounts()) - i)
+		return nil
+	}
 
-		inclusionFilter, err := glob.NewFilenameFilter(
-			types.ToStringSlicePreserveString(mount.IncludeFiles),
-			types.ToStringSlicePreserveString(mount.ExcludeFiles),
+	for _, md := range mounts {
+		var (
+			fromTo        []hugofs.RootMapping
+			fromToContent []hugofs.RootMapping
+			fromToStatic  []hugofs.RootMapping
 		)
-		if err != nil {
-			return err
-		}
 
-		base, filename := absPathify(mount.Source)
-
-		rm := hugofs.RootMapping{
-			From:      mount.Target,
-			To:        filename,
-			ToBasedir: base,
-			Module:    md.Module.Path(),
-			IsProject: md.isMainProject,
-			Meta: &hugofs.FileMeta{
-				Watch:           md.Watch(),
-				Weight:          mountWeight,
-				Classifier:      files.ContentClassContent,
-				InclusionFilter: inclusionFilter,
-			},
+		absPathify := func(path string) (string, string) {
+			if filepath.IsAbs(path) {
+				return "", path
+			}
+			return md.dir, hpaths.AbsPathify(md.dir, path)
 		}
 
-		isContentMount := b.isContentMount(mount)
+		for i, mount := range md.Mounts() {
 
-		lang := mount.Lang
-		if lang == "" && isContentMount {
-			lang = b.p.DefaultContentLanguage
-		}
+			// Add more weight to early mounts.
+			// When two mounts contain the same filename,
+			// the first entry wins.
+			mountWeight := (10 + md.ordinal) * (len(md.Mounts()) - i)
 
-		rm.Meta.Lang = lang
+			inclusionFilter, err := glob.NewFilenameFilter(
+				types.ToStringSlicePreserveString(mount.IncludeFiles),
+				types.ToStringSlicePreserveString(mount.ExcludeFiles),
+			)
+			if err != nil {
+				return err
+			}
 
-		if isContentMount {
-			fromToContent = append(fromToContent, rm)
-		} else if b.isStaticMount(mount) {
-			fromToStatic = append(fromToStatic, rm)
-		} else {
-			fromTo = append(fromTo, rm)
+			base, filename := absPathify(mount.Source)
+
+			rm := hugofs.RootMapping{
+				From:      mount.Target,
+				To:        filename,
+				ToBasedir: base,
+				Module:    md.Module.Path(),
+				IsProject: md.isMainProject,
+				Meta: &hugofs.FileMeta{
+					Watch:           md.Watch(),
+					Weight:          mountWeight,
+					Classifier:      files.ContentClassContent,
+					InclusionFilter: inclusionFilter,
+				},
+			}
+
+			isContentMount := b.isContentMount(mount)
+
+			lang := mount.Lang
+			if lang == "" && isContentMount {
+				lang = b.p.DefaultContentLanguage
+			}
+
+			rm.Meta.Lang = lang
+
+			if isContentMount {
+				fromToContent = append(fromToContent, rm)
+			} else if b.isStaticMount(mount) {
+				fromToStatic = append(fromToStatic, rm)
+			} else {
+				fromTo = append(fromTo, rm)
+			}
 		}
-	}
 
-	modBase := collector.sourceProject
-	if !md.isMainProject {
-		modBase = collector.sourceModules
-	}
-	sourceStatic := hugofs.NewNoSymlinkFs(modBase, b.logger, true)
+		modBase := collector.sourceProject
+		if !md.isMainProject {
+			modBase = collector.sourceModules
+		}
+		sourceStatic := hugofs.NewNoSymlinkFs(modBase, b.logger, true)
 
-	rmfs, err := hugofs.NewRootMappingFs(modBase, fromTo...)
-	if err != nil {
-		return err
-	}
-	rmfsContent, err := hugofs.NewRootMappingFs(modBase, fromToContent...)
-	if err != nil {
-		return err
-	}
-	rmfsStatic, err := hugofs.NewRootMappingFs(sourceStatic, fromToStatic...)
-	if err != nil {
-		return err
-	}
+		rmfs, err := hugofs.NewRootMappingFs(modBase, fromTo...)
+		if err != nil {
+			return err
+		}
+		rmfsContent, err := hugofs.NewRootMappingFs(modBase, fromToContent...)
+		if err != nil {
+			return err
+		}
+		rmfsStatic, err := hugofs.NewRootMappingFs(sourceStatic, fromToStatic...)
+		if err != nil {
+			return err
+		}
 
-	// We need to keep the ordered list of directories for watching and
-	// some special merge operations (data, i18n).
-	collector.addDirs(rmfs)
-	collector.addDirs(rmfsContent)
-	collector.addDirs(rmfsStatic)
+		// We need to keep the ordered list of directories for watching and
+		// some special merge operations (data, i18n).
+		collector.addDirs(rmfs)
+		collector.addDirs(rmfsContent)
+		collector.addDirs(rmfsStatic)
 
-	if collector.staticPerLanguage != nil {
-		for _, l := range b.p.Languages {
-			lang := l.Lang
+		if collector.staticPerLanguage != nil {
+			for _, l := range b.p.Languages {
+				lang := l.Lang
 
-			lfs := rmfsStatic.Filter(func(rm hugofs.RootMapping) bool {
-				rlang := rm.Meta.Lang
-				return rlang == "" || rlang == lang
-			})
+				lfs := rmfsStatic.Filter(func(rm hugofs.RootMapping) bool {
+					rlang := rm.Meta.Lang
+					return rlang == "" || rlang == lang
+				})
 
-			bfs := afero.NewBasePathFs(lfs, files.ComponentFolderStatic)
+				bfs := afero.NewBasePathFs(lfs, files.ComponentFolderStatic)
+				collector.staticPerLanguage[lang] = collector.staticPerLanguage[lang].Append(bfs)
 
-			sfs, found := collector.staticPerLanguage[lang]
-			if found {
-				collector.staticPerLanguage[lang] = afero.NewCopyOnWriteFs(sfs, bfs)
-			} else {
-				collector.staticPerLanguage[lang] = bfs
 			}
 		}
-	}
 
-	getResourcesDir := func() string {
-		if md.isMainProject {
-			return b.p.AbsResourcesDir
+		getResourcesDir := func() string {
+			if md.isMainProject {
+				return b.p.AbsResourcesDir
+			}
+			_, filename := absPathify(files.FolderResources)
+			return filename
 		}
-		_, filename := absPathify(files.FolderResources)
-		return filename
-	}
 
-	if collector.overlayMounts == nil {
-		collector.overlayMounts = rmfs
-		collector.overlayMountsContent = rmfsContent
-		collector.overlayMountsStatic = rmfsStatic
-		collector.overlayFull = afero.NewBasePathFs(modBase, md.dir)
-		collector.overlayResources = afero.NewBasePathFs(modBase, getResourcesDir())
-	} else {
+		collector.overlayMounts = collector.overlayMounts.Append(rmfs)
+		collector.overlayMountsContent = collector.overlayMountsContent.Append(rmfsContent)
+		collector.overlayMountsStatic = collector.overlayMountsStatic.Append(rmfsStatic)
+		collector.overlayFull = collector.overlayFull.Append(afero.NewBasePathFs(modBase, md.dir))
+		collector.overlayResources = collector.overlayResources.Append(afero.NewBasePathFs(modBase, getResourcesDir()))
 
-		collector.overlayMounts = afero.NewCopyOnWriteFs(collector.overlayMounts, rmfs)
-		collector.overlayMountsContent = hugofs.NewLanguageCompositeFs(collector.overlayMountsContent, rmfsContent)
-		collector.overlayMountsStatic = hugofs.NewLanguageCompositeFs(collector.overlayMountsStatic, rmfsStatic)
-		collector.overlayFull = afero.NewCopyOnWriteFs(collector.overlayFull, afero.NewBasePathFs(modBase, md.dir))
-		collector.overlayResources = afero.NewCopyOnWriteFs(collector.overlayResources, afero.NewBasePathFs(modBase, getResourcesDir()))
 	}
 
 	return nil
@@ -777,18 +790,18 @@ type filesystemsCollector struct {
 	sourceProject afero.Fs // Source for project folders
 	sourceModules afero.Fs // Source for modules/themes
 
-	overlayMounts        afero.Fs
-	overlayMountsContent afero.Fs
-	overlayMountsStatic  afero.Fs
-	overlayFull          afero.Fs
-	overlayResources     afero.Fs
+	overlayMounts        *overlayfs.OverlayFs
+	overlayMountsContent *overlayfs.OverlayFs
+	overlayMountsStatic  *overlayfs.OverlayFs
+	overlayFull          *overlayfs.OverlayFs
+	overlayResources     *overlayfs.OverlayFs
 
 	// Maps component type (layouts, static, content etc.) an ordered list of
 	// directories representing the overlay filesystems above.
 	overlayDirs map[string][]hugofs.FileMetaInfo
 
 	// Set if in multihost mode
-	staticPerLanguage map[string]afero.Fs
+	staticPerLanguage map[string]*overlayfs.OverlayFs
 
 	finalizerInit sync.Once
 }
@@ -807,15 +820,6 @@ func (c *filesystemsCollector) addDir(rfs *hugofs.RootMappingFs, componentFolder
 	}
 }
 
-func (c *filesystemsCollector) finalizeDirs() {
-	c.finalizerInit.Do(func() {
-		// Order the directories from top to bottom (project, theme a, theme ...).
-		for _, dirs := range c.overlayDirs {
-			c.reverseFis(dirs)
-		}
-	})
-}
-
 func (c *filesystemsCollector) reverseFis(fis []hugofs.FileMetaInfo) {
 	for i := len(fis)/2 - 1; i >= 0; i-- {
 		opp := len(fis) - 1 - i
@@ -829,20 +833,3 @@ type mountsDescriptor struct {
 	isMainProject bool
 	ordinal       int
 }
-
-func (b *sourceFilesystemsBuilder) createOverlayFs(collector *filesystemsCollector, mounts []mountsDescriptor) error {
-	if len(mounts) == 0 {
-		return nil
-	}
-
-	err := b.createModFs(collector, mounts[0])
-	if err != nil {
-		return err
-	}
-
-	if len(mounts) == 1 {
-		return nil
-	}
-
-	return b.createOverlayFs(collector, mounts[1:])
-}
diff --git a/hugolib/paths/paths.go b/hugolib/paths/paths.go
index f6e7b1a76de..9d5716e161b 100644
--- a/hugolib/paths/paths.go
+++ b/hugolib/paths/paths.go
@@ -53,9 +53,6 @@ type Paths struct {
 	// pagination path handling
 	PaginatePath string
 
-	// TODO1 check usage
-	PublishDir string
-
 	// When in multihost mode, this returns a list of base paths below PublishDir
 	// for each language.
 	MultihostTargetBasePaths []string
@@ -185,9 +182,6 @@ func New(fs *hugofs.Fs, cfg config.Provider) (*Paths, error) {
 		p.ModulesClient = cfg.Get("modulesClient").(*modules.Client)
 	}
 
-	// TODO(bep) remove this, eventually
-	p.PublishDir = absPublishDir
-
 	return p, nil
 }
 
diff --git a/tpl/os/os.go b/tpl/os/os.go
index 4fa47095227..e7fd0593901 100644
--- a/tpl/os/os.go
+++ b/tpl/os/os.go
@@ -21,6 +21,7 @@ import (
 	_os "os"
 	"path/filepath"
 
+	"github.com/bep/overlayfs"
 	"github.com/gohugoio/hugo/deps"
 	"github.com/spf13/afero"
 	"github.com/spf13/cast"
@@ -32,7 +33,12 @@ func New(d *deps.Deps) *Namespace {
 
 	// The docshelper script does not have or need all the dependencies set up.
 	if d.PathSpec != nil {
-		readFileFs = afero.NewReadOnlyFs(afero.NewCopyOnWriteFs(d.PathSpec.BaseFs.Content.Fs, d.PathSpec.BaseFs.Work))
+		readFileFs = overlayfs.New(overlayfs.Options{
+			Fss: []afero.Fs{
+				d.PathSpec.BaseFs.Work,
+				d.PathSpec.BaseFs.Content.Fs,
+			},
+		})
 		// See #9599
 		workFs = d.PathSpec.BaseFs.WorkDir
 	}


Test patch:
diff --git a/create/content_test.go b/create/content_test.go
index b99a816b777..80a66609367 100644
--- a/create/content_test.go
+++ b/create/content_test.go
@@ -82,7 +82,6 @@ func TestNewContentFromFile(t *testing.T) {
 			cfg, fs := newTestCfg(c, mm)
 			h, err := hugolib.NewHugoSites(deps.DepsCfg{Cfg: cfg, Fs: fs})
 			c.Assert(err, qt.IsNil)
-
 			err = create.NewContent(h, cas.kind, cas.path)
 
 			if b, ok := cas.expected.(bool); ok && !b {
@@ -98,6 +97,7 @@ func TestNewContentFromFile(t *testing.T) {
 			if !strings.HasPrefix(fname, "content") {
 				fname = filepath.Join("content", fname)
 			}
+
 			content := readFileFromFs(c, fs.Source, fname)
 
 			for _, v := range cas.expected.([]string) {
diff --git a/hugolib/datafiles_test.go b/hugolib/datafiles_test.go
index 6cbe7bbc6d1..a6bcae9446f 100644
--- a/hugolib/datafiles_test.go
+++ b/hugolib/datafiles_test.go
@@ -27,6 +27,38 @@ import (
 	qt "github.com/frankban/quicktest"
 )
 
+func TestDataFromTheme(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- config.toml --
+[module]
+[[module.imports]]
+path = "mytheme"
+-- data/a.toml --
+d1 = "d1main"
+d2 = "d2main"
+-- themes/mytheme/data/a.toml --
+d1 = "d1theme"
+d2 = "d2theme"
+d3 = "d3theme"
+-- layouts/index.html --
+d1: {{ site.Data.a.d1  }}|d2: {{ site.Data.a.d2 }}|d3: {{ site.Data.a.d3  }}
+
+`
+
+	b := NewIntegrationTestBuilder(
+		IntegrationTestConfig{
+			T:           t,
+			TxtarString: files,
+		},
+	).Build()
+
+	b.AssertFileContent("public/index.html", `
+d1: d1main|d2: d2main|d3: d3theme
+	`)
+}
+
 func TestDataDir(t *testing.T) {
 	t.Parallel()
 	equivDataDirs := make([]dataDir, 3)
diff --git a/hugolib/hugo_modules_test.go b/hugolib/hugo_modules_test.go
index 3582864957a..aca3f157c50 100644
--- a/hugolib/hugo_modules_test.go
+++ b/hugolib/hugo_modules_test.go
@@ -777,6 +777,8 @@ weight = 2
 				}
 			}
 
+			c.Logf("Checking %d:%d %q", i, j, id)
+
 			statCheck(componentFs, fmt.Sprintf("realsym%s", id), true)
 			statCheck(componentFs, fmt.Sprintf("real/datasym%s.toml", id), false)
 
diff --git a/langs/i18n/integration_test.go b/langs/i18n/integration_test.go
new file mode 100644
index 00000000000..5599859ee69
--- /dev/null
+++ b/langs/i18n/integration_test.go
@@ -0,0 +1,57 @@
+// Copyright 2022 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package i18n_test
+
+import (
+	"testing"
+
+	"github.com/gohugoio/hugo/hugolib"
+)
+
+func TestI18nFromTheme(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- config.toml --
+[module]
+[[module.imports]]
+path = "mytheme"
+-- i18n/en.toml --
+[l1]
+other = 'l1main'
+[l2]
+other = 'l2main'
+-- themes/mytheme/i18n/en.toml --
+[l1]
+other = 'l1theme'
+[l2]
+other = 'l2theme'
+[l3]
+other = 'l3theme'
+-- layouts/index.html --
+l1: {{ i18n "l1"  }}|l2: {{ i18n "l2"  }}|l3: {{ i18n "l3"  }}
+
+`
+
+	b := hugolib.NewIntegrationTestBuilder(
+		hugolib.IntegrationTestConfig{
+			T:           t,
+			TxtarString: files,
+		},
+	).Build()
+
+	b.AssertFileContent("public/index.html", `
+l1: l1main|l2: l2main|l3: l3theme
+	`)
+}