shithub: hugo

Download patch

ref: e3ed4a83b8e92ce9bf070f7b41780798b006e848
parent: 6636cf1bea77d20ef2a72a45fae59ac402fb133b
author: Bjørn Erik Pedersen <[email protected]>
date: Tue Oct 23 18:21:21 EDT 2018

hugolib: Rename some page_* files

To make it easier to see/work with the source files that is about the `Page` struct.

--- a/hugolib/page_bundler.go
+++ /dev/null
@@ -1,209 +1,0 @@
-// Copyright 2017-present 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 hugolib
-
-import (
-	"context"
-	"fmt"
-	"math"
-	"runtime"
-
-	_errors "github.com/pkg/errors"
-
-	"golang.org/x/sync/errgroup"
-)
-
-type siteContentProcessor struct {
-	site *Site
-
-	handleContent contentHandler
-
-	ctx context.Context
-
-	// The input file bundles.
-	fileBundlesChan chan *bundleDir
-
-	// The input file singles.
-	fileSinglesChan chan *fileInfo
-
-	// These assets should be just copied to destination.
-	fileAssetsChan chan []pathLangFile
-
-	numWorkers int
-
-	// The output Pages
-	pagesChan chan *Page
-
-	// Used for partial rebuilds (aka. live reload)
-	// Will signal replacement of pages in the site collection.
-	partialBuild bool
-}
-
-func (s *siteContentProcessor) processBundle(b *bundleDir) {
-	select {
-	case s.fileBundlesChan <- b:
-	case <-s.ctx.Done():
-	}
-}
-
-func (s *siteContentProcessor) processSingle(fi *fileInfo) {
-	select {
-	case s.fileSinglesChan <- fi:
-	case <-s.ctx.Done():
-	}
-}
-
-func (s *siteContentProcessor) processAssets(assets []pathLangFile) {
-	select {
-	case s.fileAssetsChan <- assets:
-	case <-s.ctx.Done():
-	}
-}
-
-func newSiteContentProcessor(ctx context.Context, partialBuild bool, s *Site) *siteContentProcessor {
-	numWorkers := 12
-	if n := runtime.NumCPU() * 3; n > numWorkers {
-		numWorkers = n
-	}
-
-	numWorkers = int(math.Ceil(float64(numWorkers) / float64(len(s.owner.Sites))))
-
-	return &siteContentProcessor{
-		ctx:             ctx,
-		partialBuild:    partialBuild,
-		site:            s,
-		handleContent:   newHandlerChain(s),
-		fileBundlesChan: make(chan *bundleDir, numWorkers),
-		fileSinglesChan: make(chan *fileInfo, numWorkers),
-		fileAssetsChan:  make(chan []pathLangFile, numWorkers),
-		numWorkers:      numWorkers,
-		pagesChan:       make(chan *Page, numWorkers),
-	}
-}
-
-func (s *siteContentProcessor) closeInput() {
-	close(s.fileSinglesChan)
-	close(s.fileBundlesChan)
-	close(s.fileAssetsChan)
-}
-
-func (s *siteContentProcessor) process(ctx context.Context) error {
-	g1, ctx := errgroup.WithContext(ctx)
-	g2, ctx := errgroup.WithContext(ctx)
-
-	// There can be only one of these per site.
-	g1.Go(func() error {
-		for p := range s.pagesChan {
-			if p.s != s.site {
-				panic(fmt.Sprintf("invalid page site: %v vs %v", p.s, s))
-			}
-
-			if s.partialBuild {
-				p.forceRender = true
-				s.site.replacePage(p)
-			} else {
-				s.site.addPage(p)
-			}
-		}
-		return nil
-	})
-
-	for i := 0; i < s.numWorkers; i++ {
-		g2.Go(func() error {
-			for {
-				select {
-				case f, ok := <-s.fileSinglesChan:
-					if !ok {
-						return nil
-					}
-					err := s.readAndConvertContentFile(f)
-					if err != nil {
-						return err
-					}
-				case <-ctx.Done():
-					return ctx.Err()
-				}
-			}
-		})
-
-		g2.Go(func() error {
-			for {
-				select {
-				case files, ok := <-s.fileAssetsChan:
-					if !ok {
-						return nil
-					}
-					for _, file := range files {
-						f, err := s.site.BaseFs.Content.Fs.Open(file.Filename())
-						if err != nil {
-							return _errors.Wrap(err, "failed to open assets file")
-						}
-						err = s.site.publish(&s.site.PathSpec.ProcessingStats.Files, file.Path(), f)
-						f.Close()
-						if err != nil {
-							return err
-						}
-					}
-
-				case <-ctx.Done():
-					return ctx.Err()
-				}
-			}
-		})
-
-		g2.Go(func() error {
-			for {
-				select {
-				case bundle, ok := <-s.fileBundlesChan:
-					if !ok {
-						return nil
-					}
-					err := s.readAndConvertContentBundle(bundle)
-					if err != nil {
-						return err
-					}
-				case <-ctx.Done():
-					return ctx.Err()
-				}
-			}
-		})
-	}
-
-	err := g2.Wait()
-
-	close(s.pagesChan)
-
-	if err != nil {
-		return err
-	}
-
-	if err := g1.Wait(); err != nil {
-		return err
-	}
-
-	s.site.rawAllPages.sort()
-
-	return nil
-
-}
-
-func (s *siteContentProcessor) readAndConvertContentFile(file *fileInfo) error {
-	ctx := &handlerContext{source: file, pages: s.pagesChan}
-	return s.handleContent(ctx).err
-}
-
-func (s *siteContentProcessor) readAndConvertContentBundle(bundle *bundleDir) error {
-	ctx := &handlerContext{bundle: bundle, pages: s.pagesChan}
-	return s.handleContent(ctx).err
-}
--- a/hugolib/page_bundler_capture.go
+++ /dev/null
@@ -1,775 +1,0 @@
-// Copyright 2017-present 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 hugolib
-
-import (
-	"errors"
-	"fmt"
-	"os"
-	"path"
-	"path/filepath"
-	"runtime"
-
-	"github.com/gohugoio/hugo/common/loggers"
-	_errors "github.com/pkg/errors"
-
-	"sort"
-	"strings"
-	"sync"
-
-	"github.com/spf13/afero"
-
-	"github.com/gohugoio/hugo/hugofs"
-
-	"github.com/gohugoio/hugo/helpers"
-
-	"golang.org/x/sync/errgroup"
-
-	"github.com/gohugoio/hugo/source"
-)
-
-var errSkipCyclicDir = errors.New("skip potential cyclic dir")
-
-type capturer struct {
-	// To prevent symbolic link cycles: Visit same folder only once.
-	seen   map[string]bool
-	seenMu sync.Mutex
-
-	handler captureResultHandler
-
-	sourceSpec *source.SourceSpec
-	fs         afero.Fs
-	logger     *loggers.Logger
-
-	// Filenames limits the content to process to a list of filenames/directories.
-	// This is used for partial building in server mode.
-	filenames []string
-
-	// Used to determine how to handle content changes in server mode.
-	contentChanges *contentChangeMap
-
-	// Semaphore used to throttle the concurrent sub directory handling.
-	sem chan bool
-}
-
-func newCapturer(
-	logger *loggers.Logger,
-	sourceSpec *source.SourceSpec,
-	handler captureResultHandler,
-	contentChanges *contentChangeMap,
-	filenames ...string) *capturer {
-
-	numWorkers := 4
-	if n := runtime.NumCPU(); n > numWorkers {
-		numWorkers = n
-	}
-
-	// TODO(bep) the "index" vs "_index" check/strings should be moved in one place.
-	isBundleHeader := func(filename string) bool {
-		base := filepath.Base(filename)
-		name := helpers.Filename(base)
-		return IsContentFile(base) && (name == "index" || name == "_index")
-	}
-
-	// Make sure that any bundle header files are processed before the others. This makes
-	// sure that any bundle head is processed before its resources.
-	sort.Slice(filenames, func(i, j int) bool {
-		a, b := filenames[i], filenames[j]
-		ac, bc := isBundleHeader(a), isBundleHeader(b)
-
-		if ac {
-			return true
-		}
-
-		if bc {
-			return false
-		}
-
-		return a < b
-	})
-
-	c := &capturer{
-		sem:            make(chan bool, numWorkers),
-		handler:        handler,
-		sourceSpec:     sourceSpec,
-		fs:             sourceSpec.SourceFs,
-		logger:         logger,
-		contentChanges: contentChanges,
-		seen:           make(map[string]bool),
-		filenames:      filenames}
-
-	return c
-}
-
-// Captured files and bundles ready to be processed will be passed on to
-// these channels.
-type captureResultHandler interface {
-	handleSingles(fis ...*fileInfo)
-	handleCopyFiles(fis ...pathLangFile)
-	captureBundlesHandler
-}
-
-type captureBundlesHandler interface {
-	handleBundles(b *bundleDirs)
-}
-
-type captureResultHandlerChain struct {
-	handlers []captureBundlesHandler
-}
-
-func (c *captureResultHandlerChain) handleSingles(fis ...*fileInfo) {
-	for _, h := range c.handlers {
-		if hh, ok := h.(captureResultHandler); ok {
-			hh.handleSingles(fis...)
-		}
-	}
-}
-func (c *captureResultHandlerChain) handleBundles(b *bundleDirs) {
-	for _, h := range c.handlers {
-		h.handleBundles(b)
-	}
-}
-
-func (c *captureResultHandlerChain) handleCopyFiles(files ...pathLangFile) {
-	for _, h := range c.handlers {
-		if hh, ok := h.(captureResultHandler); ok {
-			hh.handleCopyFiles(files...)
-		}
-	}
-}
-
-func (c *capturer) capturePartial(filenames ...string) error {
-	handled := make(map[string]bool)
-
-	for _, filename := range filenames {
-		dir, resolvedFilename, tp := c.contentChanges.resolveAndRemove(filename)
-		if handled[resolvedFilename] {
-			continue
-		}
-
-		handled[resolvedFilename] = true
-
-		switch tp {
-		case bundleLeaf:
-			if err := c.handleDir(resolvedFilename); err != nil {
-				// Directory may have been deleted.
-				if !os.IsNotExist(err) {
-					return err
-				}
-			}
-		case bundleBranch:
-			if err := c.handleBranchDir(resolvedFilename); err != nil {
-				// Directory may have been deleted.
-				if !os.IsNotExist(err) {
-					return err
-				}
-			}
-		default:
-			fi, err := c.resolveRealPath(resolvedFilename)
-			if os.IsNotExist(err) {
-				// File has been deleted.
-				continue
-			}
-
-			// Just in case the owning dir is a new symlink -- this will
-			// create the proper mapping for it.
-			c.resolveRealPath(dir)
-
-			f, active := c.newFileInfo(fi, tp)
-			if active {
-				c.copyOrHandleSingle(f)
-			}
-		}
-	}
-
-	return nil
-}
-
-func (c *capturer) capture() error {
-	if len(c.filenames) > 0 {
-		return c.capturePartial(c.filenames...)
-	}
-
-	err := c.handleDir(helpers.FilePathSeparator)
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-func (c *capturer) handleNestedDir(dirname string) error {
-	select {
-	case c.sem <- true:
-		var g errgroup.Group
-
-		g.Go(func() error {
-			defer func() {
-				<-c.sem
-			}()
-			return c.handleDir(dirname)
-		})
-		return g.Wait()
-	default:
-		// For deeply nested file trees, waiting for a semaphore wil deadlock.
-		return c.handleDir(dirname)
-	}
-}
-
-// This handles a bundle branch and its resources only. This is used
-// in server mode on changes. If this dir does not (anymore) represent a bundle
-// branch, the handling is upgraded to the full handleDir method.
-func (c *capturer) handleBranchDir(dirname string) error {
-	files, err := c.readDir(dirname)
-	if err != nil {
-
-		return err
-	}
-
-	var (
-		dirType bundleDirType
-	)
-
-	for _, fi := range files {
-		if !fi.IsDir() {
-			tp, _ := classifyBundledFile(fi.RealName())
-			if dirType == bundleNot {
-				dirType = tp
-			}
-
-			if dirType == bundleLeaf {
-				return c.handleDir(dirname)
-			}
-		}
-	}
-
-	if dirType != bundleBranch {
-		return c.handleDir(dirname)
-	}
-
-	dirs := newBundleDirs(bundleBranch, c)
-
-	var secondPass []*fileInfo
-
-	// Handle potential bundle headers first.
-	for _, fi := range files {
-		if fi.IsDir() {
-			continue
-		}
-
-		tp, isContent := classifyBundledFile(fi.RealName())
-
-		f, active := c.newFileInfo(fi, tp)
-
-		if !active {
-			continue
-		}
-
-		if !f.isOwner() {
-			if !isContent {
-				// This is a partial update -- we only care about the files that
-				// is in this bundle.
-				secondPass = append(secondPass, f)
-			}
-			continue
-		}
-		dirs.addBundleHeader(f)
-	}
-
-	for _, f := range secondPass {
-		dirs.addBundleFiles(f)
-	}
-
-	c.handler.handleBundles(dirs)
-
-	return nil
-
-}
-
-func (c *capturer) handleDir(dirname string) error {
-
-	files, err := c.readDir(dirname)
-	if err != nil {
-		return err
-	}
-
-	type dirState int
-
-	const (
-		dirStateDefault dirState = iota
-
-		dirStateAssetsOnly
-		dirStateSinglesOnly
-	)
-
-	var (
-		fileBundleTypes = make([]bundleDirType, len(files))
-
-		// Start with the assumption that this dir contains only non-content assets (images etc.)
-		// If that is still true after we had a first look at the list of files, we
-		// can just copy the files to destination. We will still have to look at the
-		// sub-folders for potential bundles.
-		state = dirStateAssetsOnly
-
-		// Start with the assumption that this dir is not a bundle.
-		// A directory is a bundle if it contains a index content file,
-		// e.g. index.md (a leaf bundle) or a _index.md (a branch bundle).
-		bundleType = bundleNot
-	)
-
-	/* First check for any content files.
-	- If there are none, then this is a assets folder only (images etc.)
-	and we can just plainly copy them to
-	destination.
-	- If this is a section with no image etc. or similar, we can just handle it
-	as it was a single content file.
-	*/
-	var hasNonContent, isBranch bool
-
-	for i, fi := range files {
-		if !fi.IsDir() {
-			tp, isContent := classifyBundledFile(fi.RealName())
-
-			fileBundleTypes[i] = tp
-			if !isBranch {
-				isBranch = tp == bundleBranch
-			}
-
-			if isContent {
-				// This is not a assets-only folder.
-				state = dirStateDefault
-			} else {
-				hasNonContent = true
-			}
-		}
-	}
-
-	if isBranch && !hasNonContent {
-		// This is a section or similar with no need for any bundle handling.
-		state = dirStateSinglesOnly
-	}
-
-	if state > dirStateDefault {
-		return c.handleNonBundle(dirname, files, state == dirStateSinglesOnly)
-	}
-
-	var fileInfos = make([]*fileInfo, 0, len(files))
-
-	for i, fi := range files {
-
-		currentType := bundleNot
-
-		if !fi.IsDir() {
-			currentType = fileBundleTypes[i]
-			if bundleType == bundleNot && currentType != bundleNot {
-				bundleType = currentType
-			}
-		}
-
-		if bundleType == bundleNot && currentType != bundleNot {
-			bundleType = currentType
-		}
-
-		f, active := c.newFileInfo(fi, currentType)
-
-		if !active {
-			continue
-		}
-
-		fileInfos = append(fileInfos, f)
-	}
-
-	var todo []*fileInfo
-
-	if bundleType != bundleLeaf {
-		for _, fi := range fileInfos {
-			if fi.FileInfo().IsDir() {
-				// Handle potential nested bundles.
-				if err := c.handleNestedDir(fi.Path()); err != nil {
-					return err
-				}
-			} else if bundleType == bundleNot || (!fi.isOwner() && fi.isContentFile()) {
-				// Not in a bundle.
-				c.copyOrHandleSingle(fi)
-			} else {
-				// This is a section folder or similar with non-content files in it.
-				todo = append(todo, fi)
-			}
-		}
-	} else {
-		todo = fileInfos
-	}
-
-	if len(todo) == 0 {
-		return nil
-	}
-
-	dirs, err := c.createBundleDirs(todo, bundleType)
-	if err != nil {
-		return err
-	}
-
-	// Send the bundle to the next step in the processor chain.
-	c.handler.handleBundles(dirs)
-
-	return nil
-}
-
-func (c *capturer) handleNonBundle(
-	dirname string,
-	fileInfos pathLangFileFis,
-	singlesOnly bool) error {
-
-	for _, fi := range fileInfos {
-		if fi.IsDir() {
-			if err := c.handleNestedDir(fi.Filename()); err != nil {
-				return err
-			}
-		} else {
-			if singlesOnly {
-				f, active := c.newFileInfo(fi, bundleNot)
-				if !active {
-					continue
-				}
-				c.handler.handleSingles(f)
-			} else {
-				c.handler.handleCopyFiles(fi)
-			}
-		}
-	}
-
-	return nil
-}
-
-func (c *capturer) copyOrHandleSingle(fi *fileInfo) {
-	if fi.isContentFile() {
-		c.handler.handleSingles(fi)
-	} else {
-		// These do not currently need any further processing.
-		c.handler.handleCopyFiles(fi)
-	}
-}
-
-func (c *capturer) createBundleDirs(fileInfos []*fileInfo, bundleType bundleDirType) (*bundleDirs, error) {
-	dirs := newBundleDirs(bundleType, c)
-
-	for _, fi := range fileInfos {
-		if fi.FileInfo().IsDir() {
-			var collector func(fis ...*fileInfo)
-
-			if bundleType == bundleBranch {
-				// All files in the current directory are part of this bundle.
-				// Trying to include sub folders in these bundles are filled with ambiguity.
-				collector = func(fis ...*fileInfo) {
-					for _, fi := range fis {
-						c.copyOrHandleSingle(fi)
-					}
-				}
-			} else {
-				// All nested files and directories are part of this bundle.
-				collector = func(fis ...*fileInfo) {
-					fileInfos = append(fileInfos, fis...)
-				}
-			}
-			err := c.collectFiles(fi.Path(), collector)
-			if err != nil {
-				return nil, err
-			}
-
-		} else if fi.isOwner() {
-			// There can be more than one language, so:
-			// 1. Content files must be attached to its language's bundle.
-			// 2. Other files must be attached to all languages.
-			// 3. Every content file needs a bundle header.
-			dirs.addBundleHeader(fi)
-		}
-	}
-
-	for _, fi := range fileInfos {
-		if fi.FileInfo().IsDir() || fi.isOwner() {
-			continue
-		}
-
-		if fi.isContentFile() {
-			if bundleType != bundleBranch {
-				dirs.addBundleContentFile(fi)
-			}
-		} else {
-			dirs.addBundleFiles(fi)
-		}
-	}
-
-	return dirs, nil
-}
-
-func (c *capturer) collectFiles(dirname string, handleFiles func(fis ...*fileInfo)) error {
-
-	filesInDir, err := c.readDir(dirname)
-	if err != nil {
-		return err
-	}
-
-	for _, fi := range filesInDir {
-		if fi.IsDir() {
-			err := c.collectFiles(fi.Filename(), handleFiles)
-			if err != nil {
-				return err
-			}
-		} else {
-			f, active := c.newFileInfo(fi, bundleNot)
-			if active {
-				handleFiles(f)
-			}
-		}
-	}
-
-	return nil
-}
-
-func (c *capturer) readDir(dirname string) (pathLangFileFis, error) {
-	if c.sourceSpec.IgnoreFile(dirname) {
-		return nil, nil
-	}
-
-	dir, err := c.fs.Open(dirname)
-	if err != nil {
-		return nil, err
-	}
-	defer dir.Close()
-	fis, err := dir.Readdir(-1)
-	if err != nil {
-		return nil, err
-	}
-
-	pfis := make(pathLangFileFis, 0, len(fis))
-
-	for _, fi := range fis {
-		fip := fi.(pathLangFileFi)
-
-		if !c.sourceSpec.IgnoreFile(fip.Filename()) {
-
-			err := c.resolveRealPathIn(fip)
-
-			if err != nil {
-				// It may have been deleted in the meantime.
-				if err == errSkipCyclicDir || os.IsNotExist(err) {
-					continue
-				}
-				return nil, err
-			}
-
-			pfis = append(pfis, fip)
-		}
-	}
-
-	return pfis, nil
-}
-
-func (c *capturer) newFileInfo(fi pathLangFileFi, tp bundleDirType) (*fileInfo, bool) {
-	f := newFileInfo(c.sourceSpec, "", "", fi, tp)
-	return f, !f.disabled
-}
-
-type pathLangFile interface {
-	hugofs.LanguageAnnouncer
-	hugofs.FilePather
-}
-
-type pathLangFileFi interface {
-	os.FileInfo
-	pathLangFile
-}
-
-type pathLangFileFis []pathLangFileFi
-
-type bundleDirs struct {
-	tp bundleDirType
-	// Maps languages to bundles.
-	bundles map[string]*bundleDir
-
-	// Keeps track of language overrides for non-content files, e.g. logo.en.png.
-	langOverrides map[string]bool
-
-	c *capturer
-}
-
-func newBundleDirs(tp bundleDirType, c *capturer) *bundleDirs {
-	return &bundleDirs{tp: tp, bundles: make(map[string]*bundleDir), langOverrides: make(map[string]bool), c: c}
-}
-
-type bundleDir struct {
-	tp bundleDirType
-	fi *fileInfo
-
-	resources map[string]*fileInfo
-}
-
-func (b bundleDir) clone() *bundleDir {
-	b.resources = make(map[string]*fileInfo)
-	fic := *b.fi
-	b.fi = &fic
-	return &b
-}
-
-func newBundleDir(fi *fileInfo, bundleType bundleDirType) *bundleDir {
-	return &bundleDir{fi: fi, tp: bundleType, resources: make(map[string]*fileInfo)}
-}
-
-func (b *bundleDirs) addBundleContentFile(fi *fileInfo) {
-	dir, found := b.bundles[fi.Lang()]
-	if !found {
-		// Every bundled content file needs a bundle header.
-		// If one does not exist in its language, we pick the default
-		// language version, or a random one if that doesn't exist, either.
-		tl := b.c.sourceSpec.DefaultContentLanguage
-		ldir, found := b.bundles[tl]
-		if !found {
-			// Just pick one.
-			for _, v := range b.bundles {
-				ldir = v
-				break
-			}
-		}
-
-		if ldir == nil {
-			panic(fmt.Sprintf("bundle not found for file %q", fi.Filename()))
-		}
-
-		dir = ldir.clone()
-		dir.fi.overriddenLang = fi.Lang()
-		b.bundles[fi.Lang()] = dir
-	}
-
-	dir.resources[fi.Path()] = fi
-}
-
-func (b *bundleDirs) addBundleFiles(fi *fileInfo) {
-	dir := filepath.ToSlash(fi.Dir())
-	p := dir + fi.TranslationBaseName() + "." + fi.Ext()
-	for lang, bdir := range b.bundles {
-		key := path.Join(lang, p)
-
-		// Given mypage.de.md (German translation) and mypage.md we pick the most
-		// specific for that language.
-		if fi.Lang() == lang || !b.langOverrides[key] {
-			bdir.resources[key] = fi
-		}
-		b.langOverrides[key] = true
-	}
-}
-
-func (b *bundleDirs) addBundleHeader(fi *fileInfo) {
-	b.bundles[fi.Lang()] = newBundleDir(fi, b.tp)
-}
-
-func (c *capturer) isSeen(dirname string) bool {
-	c.seenMu.Lock()
-	defer c.seenMu.Unlock()
-	seen := c.seen[dirname]
-	c.seen[dirname] = true
-	if seen {
-		c.logger.WARN.Printf("Content dir %q already processed; skipped to avoid infinite recursion.", dirname)
-		return true
-
-	}
-	return false
-}
-
-func (c *capturer) resolveRealPath(path string) (pathLangFileFi, error) {
-	fileInfo, err := c.lstatIfPossible(path)
-	if err != nil {
-		return nil, err
-	}
-	return fileInfo, c.resolveRealPathIn(fileInfo)
-}
-
-func (c *capturer) resolveRealPathIn(fileInfo pathLangFileFi) error {
-
-	basePath := fileInfo.BaseDir()
-	path := fileInfo.Filename()
-
-	realPath := path
-
-	if fileInfo.Mode()&os.ModeSymlink == os.ModeSymlink {
-		link, err := filepath.EvalSymlinks(path)
-		if err != nil {
-			return _errors.Wrapf(err, "Cannot read symbolic link %q, error was:", path)
-		}
-
-		// This is a file on the outside of any base fs, so we have to use the os package.
-		sfi, err := os.Stat(link)
-		if err != nil {
-			return _errors.Wrapf(err, "Cannot stat  %q, error was:", link)
-		}
-
-		// TODO(bep) improve all of this.
-		if a, ok := fileInfo.(*hugofs.LanguageFileInfo); ok {
-			a.FileInfo = sfi
-		}
-
-		realPath = link
-
-		if realPath != path && sfi.IsDir() && c.isSeen(realPath) {
-			// Avoid cyclic symlinks.
-			// Note that this may prevent some uses that isn't cyclic and also
-			// potential useful, but this implementation is both robust and simple:
-			// We stop at the first directory that we have seen before, e.g.
-			// /content/blog will only be processed once.
-			return errSkipCyclicDir
-		}
-
-		if c.contentChanges != nil {
-			// Keep track of symbolic links in watch mode.
-			var from, to string
-			if sfi.IsDir() {
-				from = realPath
-				to = path
-
-				if !strings.HasSuffix(to, helpers.FilePathSeparator) {
-					to = to + helpers.FilePathSeparator
-				}
-				if !strings.HasSuffix(from, helpers.FilePathSeparator) {
-					from = from + helpers.FilePathSeparator
-				}
-
-				if !strings.HasSuffix(basePath, helpers.FilePathSeparator) {
-					basePath = basePath + helpers.FilePathSeparator
-				}
-
-				if strings.HasPrefix(from, basePath) {
-					// With symbolic links inside /content we need to keep
-					// a reference to both. This may be confusing with --navigateToChanged
-					// but the user has chosen this him or herself.
-					c.contentChanges.addSymbolicLinkMapping(from, from)
-				}
-
-			} else {
-				from = realPath
-				to = path
-			}
-
-			c.contentChanges.addSymbolicLinkMapping(from, to)
-		}
-	}
-
-	return nil
-}
-
-func (c *capturer) lstatIfPossible(path string) (pathLangFileFi, error) {
-	fi, err := helpers.LstatIfPossible(c.fs, path)
-	if err != nil {
-		return nil, err
-	}
-	return fi.(pathLangFileFi), nil
-}
--- a/hugolib/page_bundler_capture_test.go
+++ /dev/null
@@ -1,274 +1,0 @@
-// Copyright 2017-present 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 hugolib
-
-import (
-	"fmt"
-	"os"
-	"path"
-	"path/filepath"
-	"sort"
-
-	"github.com/gohugoio/hugo/common/loggers"
-
-	"runtime"
-	"strings"
-	"sync"
-	"testing"
-
-	"github.com/gohugoio/hugo/helpers"
-	"github.com/gohugoio/hugo/source"
-	"github.com/stretchr/testify/require"
-)
-
-type storeFilenames struct {
-	sync.Mutex
-	filenames []string
-	copyNames []string
-	dirKeys   []string
-}
-
-func (s *storeFilenames) handleSingles(fis ...*fileInfo) {
-	s.Lock()
-	defer s.Unlock()
-	for _, fi := range fis {
-		s.filenames = append(s.filenames, filepath.ToSlash(fi.Filename()))
-	}
-}
-
-func (s *storeFilenames) handleBundles(d *bundleDirs) {
-	s.Lock()
-	defer s.Unlock()
-	var keys []string
-	for _, b := range d.bundles {
-		res := make([]string, len(b.resources))
-		i := 0
-		for _, r := range b.resources {
-			res[i] = path.Join(r.Lang(), filepath.ToSlash(r.Filename()))
-			i++
-		}
-		sort.Strings(res)
-		keys = append(keys, path.Join("__bundle", b.fi.Lang(), filepath.ToSlash(b.fi.Filename()), "resources", strings.Join(res, "|")))
-	}
-	s.dirKeys = append(s.dirKeys, keys...)
-}
-
-func (s *storeFilenames) handleCopyFiles(files ...pathLangFile) {
-	s.Lock()
-	defer s.Unlock()
-	for _, file := range files {
-		s.copyNames = append(s.copyNames, filepath.ToSlash(file.Filename()))
-	}
-}
-
-func (s *storeFilenames) sortedStr() string {
-	s.Lock()
-	defer s.Unlock()
-	sort.Strings(s.filenames)
-	sort.Strings(s.dirKeys)
-	sort.Strings(s.copyNames)
-	return "\nF:\n" + strings.Join(s.filenames, "\n") + "\nD:\n" + strings.Join(s.dirKeys, "\n") +
-		"\nC:\n" + strings.Join(s.copyNames, "\n") + "\n"
-}
-
-func TestPageBundlerCaptureSymlinks(t *testing.T) {
-	if runtime.GOOS == "windows" && os.Getenv("CI") == "" {
-		t.Skip("Skip TestPageBundlerCaptureSymlinks as os.Symlink needs administrator rights on Windows")
-	}
-
-	assert := require.New(t)
-	ps, clean, workDir := newTestBundleSymbolicSources(t)
-	sourceSpec := source.NewSourceSpec(ps, ps.BaseFs.Content.Fs)
-	defer clean()
-
-	fileStore := &storeFilenames{}
-	logger := loggers.NewErrorLogger()
-	c := newCapturer(logger, sourceSpec, fileStore, nil)
-
-	assert.NoError(c.capture())
-
-	expected := `
-F:
-/base/a/page_s.md
-/base/a/regular.md
-/base/symbolic1/s1.md
-/base/symbolic1/s2.md
-/base/symbolic3/circus/a/page_s.md
-/base/symbolic3/circus/a/regular.md
-D:
-__bundle/en/base/symbolic2/a1/index.md/resources/en/base/symbolic2/a1/logo.png|en/base/symbolic2/a1/page.md
-C:
-/base/symbolic3/s1.png
-/base/symbolic3/s2.png
-`
-
-	got := strings.Replace(fileStore.sortedStr(), filepath.ToSlash(workDir), "", -1)
-	got = strings.Replace(got, "//", "/", -1)
-
-	if expected != got {
-		diff := helpers.DiffStringSlices(strings.Fields(expected), strings.Fields(got))
-		t.Log(got)
-		t.Fatalf("Failed:\n%s", diff)
-	}
-}
-
-func TestPageBundlerCaptureBasic(t *testing.T) {
-	t.Parallel()
-
-	assert := require.New(t)
-	fs, cfg := newTestBundleSources(t)
-	assert.NoError(loadDefaultSettingsFor(cfg))
-	assert.NoError(loadLanguageSettings(cfg, nil))
-	ps, err := helpers.NewPathSpec(fs, cfg)
-	assert.NoError(err)
-
-	sourceSpec := source.NewSourceSpec(ps, ps.BaseFs.Content.Fs)
-
-	fileStore := &storeFilenames{}
-
-	c := newCapturer(loggers.NewErrorLogger(), sourceSpec, fileStore, nil)
-
-	assert.NoError(c.capture())
-
-	expected := `
-F:
-/work/base/_1.md
-/work/base/a/1.md
-/work/base/a/2.md
-/work/base/assets/pages/mypage.md
-D:
-__bundle/en/work/base/_index.md/resources/en/work/base/_1.png
-__bundle/en/work/base/a/b/index.md/resources/en/work/base/a/b/ab1.md
-__bundle/en/work/base/b/my-bundle/index.md/resources/en/work/base/b/my-bundle/1.md|en/work/base/b/my-bundle/2.md|en/work/base/b/my-bundle/c/logo.png|en/work/base/b/my-bundle/custom-mime.bep|en/work/base/b/my-bundle/sunset1.jpg|en/work/base/b/my-bundle/sunset2.jpg
-__bundle/en/work/base/c/bundle/index.md/resources/en/work/base/c/bundle/logo-은행.png
-__bundle/en/work/base/root/index.md/resources/en/work/base/root/1.md|en/work/base/root/c/logo.png
-C:
-/work/base/assets/pic1.png
-/work/base/assets/pic2.png
-/work/base/images/hugo-logo.png
-`
-
-	got := fileStore.sortedStr()
-
-	if expected != got {
-		diff := helpers.DiffStringSlices(strings.Fields(expected), strings.Fields(got))
-		t.Log(got)
-		t.Fatalf("Failed:\n%s", diff)
-	}
-}
-
-func TestPageBundlerCaptureMultilingual(t *testing.T) {
-	t.Parallel()
-
-	assert := require.New(t)
-	fs, cfg := newTestBundleSourcesMultilingual(t)
-	assert.NoError(loadDefaultSettingsFor(cfg))
-	assert.NoError(loadLanguageSettings(cfg, nil))
-
-	ps, err := helpers.NewPathSpec(fs, cfg)
-	assert.NoError(err)
-
-	sourceSpec := source.NewSourceSpec(ps, ps.BaseFs.Content.Fs)
-	fileStore := &storeFilenames{}
-	c := newCapturer(loggers.NewErrorLogger(), sourceSpec, fileStore, nil)
-
-	assert.NoError(c.capture())
-
-	expected := `
-F:
-/work/base/1s/mypage.md
-/work/base/1s/mypage.nn.md
-/work/base/bb/_1.md
-/work/base/bb/_1.nn.md
-/work/base/bb/en.md
-/work/base/bc/page.md
-/work/base/bc/page.nn.md
-/work/base/be/_index.md
-/work/base/be/page.md
-/work/base/be/page.nn.md
-D:
-__bundle/en/work/base/bb/_index.md/resources/en/work/base/bb/a.png|en/work/base/bb/b.png|nn/work/base/bb/c.nn.png
-__bundle/en/work/base/bc/_index.md/resources/en/work/base/bc/logo-bc.png
-__bundle/en/work/base/bd/index.md/resources/en/work/base/bd/page.md
-__bundle/en/work/base/bf/my-bf-bundle/index.md/resources/en/work/base/bf/my-bf-bundle/page.md
-__bundle/en/work/base/lb/index.md/resources/en/work/base/lb/1.md|en/work/base/lb/2.md|en/work/base/lb/c/d/deep.png|en/work/base/lb/c/logo.png|en/work/base/lb/c/one.png|en/work/base/lb/c/page.md
-__bundle/nn/work/base/bb/_index.nn.md/resources/en/work/base/bb/a.png|nn/work/base/bb/b.nn.png|nn/work/base/bb/c.nn.png
-__bundle/nn/work/base/bd/index.md/resources/nn/work/base/bd/page.nn.md
-__bundle/nn/work/base/bf/my-bf-bundle/index.nn.md/resources
-__bundle/nn/work/base/lb/index.nn.md/resources/en/work/base/lb/c/d/deep.png|en/work/base/lb/c/one.png|nn/work/base/lb/2.nn.md|nn/work/base/lb/c/logo.nn.png
-C:
-/work/base/1s/mylogo.png
-/work/base/bb/b/d.nn.png
-`
-
-	got := fileStore.sortedStr()
-
-	if expected != got {
-		diff := helpers.DiffStringSlices(strings.Fields(expected), strings.Fields(got))
-		t.Log(got)
-		t.Fatalf("Failed:\n%s", strings.Join(diff, "\n"))
-	}
-
-}
-
-type noOpFileStore int
-
-func (noOpFileStore) handleSingles(fis ...*fileInfo)        {}
-func (noOpFileStore) handleBundles(b *bundleDirs)           {}
-func (noOpFileStore) handleCopyFiles(files ...pathLangFile) {}
-
-func BenchmarkPageBundlerCapture(b *testing.B) {
-	capturers := make([]*capturer, b.N)
-
-	for i := 0; i < b.N; i++ {
-		cfg, fs := newTestCfg()
-		ps, _ := helpers.NewPathSpec(fs, cfg)
-		sourceSpec := source.NewSourceSpec(ps, fs.Source)
-
-		base := fmt.Sprintf("base%d", i)
-		for j := 1; j <= 5; j++ {
-			js := fmt.Sprintf("j%d", j)
-			writeSource(b, fs, filepath.Join(base, js, "index.md"), "content")
-			writeSource(b, fs, filepath.Join(base, js, "logo1.png"), "content")
-			writeSource(b, fs, filepath.Join(base, js, "sub", "logo2.png"), "content")
-			writeSource(b, fs, filepath.Join(base, js, "section", "_index.md"), "content")
-			writeSource(b, fs, filepath.Join(base, js, "section", "logo.png"), "content")
-			writeSource(b, fs, filepath.Join(base, js, "section", "sub", "logo.png"), "content")
-
-			for k := 1; k <= 5; k++ {
-				ks := fmt.Sprintf("k%d", k)
-				writeSource(b, fs, filepath.Join(base, js, ks, "logo1.png"), "content")
-				writeSource(b, fs, filepath.Join(base, js, "section", ks, "logo.png"), "content")
-			}
-		}
-
-		for i := 1; i <= 5; i++ {
-			writeSource(b, fs, filepath.Join(base, "assetsonly", fmt.Sprintf("image%d.png", i)), "image")
-		}
-
-		for i := 1; i <= 5; i++ {
-			writeSource(b, fs, filepath.Join(base, "contentonly", fmt.Sprintf("c%d.md", i)), "content")
-		}
-
-		capturers[i] = newCapturer(loggers.NewErrorLogger(), sourceSpec, new(noOpFileStore), nil, base)
-	}
-
-	b.ResetTimer()
-	for i := 0; i < b.N; i++ {
-		err := capturers[i].capture()
-		if err != nil {
-			b.Fatal(err)
-		}
-	}
-}
--- a/hugolib/page_bundler_handlers.go
+++ /dev/null
@@ -1,345 +1,0 @@
-// Copyright 2017-present 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 hugolib
-
-import (
-	"errors"
-	"fmt"
-	"path/filepath"
-	"sort"
-
-	"strings"
-
-	"github.com/gohugoio/hugo/helpers"
-	"github.com/gohugoio/hugo/resource"
-)
-
-var (
-	// This should be the only list of valid extensions for content files.
-	contentFileExtensions = []string{
-		"html", "htm",
-		"mdown", "markdown", "md",
-		"asciidoc", "adoc", "ad",
-		"rest", "rst",
-		"mmark",
-		"org",
-		"pandoc", "pdc"}
-
-	contentFileExtensionsSet map[string]bool
-)
-
-func init() {
-	contentFileExtensionsSet = make(map[string]bool)
-	for _, ext := range contentFileExtensions {
-		contentFileExtensionsSet[ext] = true
-	}
-}
-
-func newHandlerChain(s *Site) contentHandler {
-	c := &contentHandlers{s: s}
-
-	contentFlow := c.parsePage(c.processFirstMatch(
-		// Handles all files with a content file extension. See above.
-		c.handlePageContent(),
-
-		// Every HTML file without front matter will be passed on to this handler.
-		c.handleHTMLContent(),
-	))
-
-	c.rootHandler = c.processFirstMatch(
-		contentFlow,
-
-		// Creates a file resource (image, CSS etc.) if there is a parent
-		// page set on the current context.
-		c.createResource(),
-
-		// Everything that isn't handled above, will just be copied
-		// to destination.
-		c.copyFile(),
-	)
-
-	return c.rootHandler
-
-}
-
-type contentHandlers struct {
-	s           *Site
-	rootHandler contentHandler
-}
-
-func (c *contentHandlers) processFirstMatch(handlers ...contentHandler) func(ctx *handlerContext) handlerResult {
-	return func(ctx *handlerContext) handlerResult {
-		for _, h := range handlers {
-			res := h(ctx)
-			if res.handled || res.err != nil {
-				return res
-			}
-		}
-		return handlerResult{err: errors.New("no matching handler found")}
-	}
-}
-
-type handlerContext struct {
-	// These are the pages stored in Site.
-	pages chan<- *Page
-
-	doNotAddToSiteCollections bool
-
-	currentPage *Page
-	parentPage  *Page
-
-	bundle *bundleDir
-
-	source *fileInfo
-
-	// Relative path to the target.
-	target string
-}
-
-func (c *handlerContext) ext() string {
-	if c.currentPage != nil {
-		if c.currentPage.Markup != "" {
-			return c.currentPage.Markup
-		}
-		return c.currentPage.Ext()
-	}
-
-	if c.bundle != nil {
-		return c.bundle.fi.Ext()
-	} else {
-		return c.source.Ext()
-	}
-}
-
-func (c *handlerContext) targetPath() string {
-	if c.target != "" {
-		return c.target
-	}
-
-	return c.source.Filename()
-}
-
-func (c *handlerContext) file() *fileInfo {
-	if c.bundle != nil {
-		return c.bundle.fi
-	}
-
-	return c.source
-}
-
-// Create a copy with the current context as its parent.
-func (c handlerContext) childCtx(fi *fileInfo) *handlerContext {
-	if c.currentPage == nil {
-		panic("Need a Page to create a child context")
-	}
-
-	c.target = strings.TrimPrefix(fi.Path(), c.bundle.fi.Dir())
-	c.source = fi
-
-	c.doNotAddToSiteCollections = c.bundle != nil && c.bundle.tp != bundleBranch
-
-	c.bundle = nil
-
-	c.parentPage = c.currentPage
-	c.currentPage = nil
-
-	return &c
-}
-
-func (c *handlerContext) supports(exts ...string) bool {
-	ext := c.ext()
-	for _, s := range exts {
-		if s == ext {
-			return true
-		}
-	}
-
-	return false
-}
-
-func (c *handlerContext) isContentFile() bool {
-	return contentFileExtensionsSet[c.ext()]
-}
-
-type (
-	handlerResult struct {
-		err      error
-		handled  bool
-		resource resource.Resource
-	}
-
-	contentHandler func(ctx *handlerContext) handlerResult
-)
-
-var (
-	notHandled handlerResult
-)
-
-func (c *contentHandlers) parsePage(h contentHandler) contentHandler {
-	return func(ctx *handlerContext) handlerResult {
-		if !ctx.isContentFile() {
-			return notHandled
-		}
-
-		result := handlerResult{handled: true}
-		fi := ctx.file()
-
-		f, err := fi.Open()
-		if err != nil {
-			return handlerResult{err: fmt.Errorf("(%s) failed to open content file: %s", fi.Filename(), err)}
-		}
-		defer f.Close()
-
-		p := c.s.newPageFromFile(fi)
-
-		_, err = p.ReadFrom(f)
-		if err != nil {
-			return handlerResult{err: err}
-		}
-
-		if !p.shouldBuild() {
-			if !ctx.doNotAddToSiteCollections {
-				ctx.pages <- p
-			}
-			return result
-		}
-
-		ctx.currentPage = p
-
-		if ctx.bundle != nil {
-			// Add the bundled files
-			for _, fi := range ctx.bundle.resources {
-				childCtx := ctx.childCtx(fi)
-				res := c.rootHandler(childCtx)
-				if res.err != nil {
-					return res
-				}
-				if res.resource != nil {
-					if pageResource, ok := res.resource.(*Page); ok {
-						pageResource.resourcePath = filepath.ToSlash(childCtx.target)
-						pageResource.parent = p
-					}
-					p.Resources = append(p.Resources, res.resource)
-				}
-			}
-
-			sort.SliceStable(p.Resources, func(i, j int) bool {
-				if p.Resources[i].ResourceType() < p.Resources[j].ResourceType() {
-					return true
-				}
-
-				p1, ok1 := p.Resources[i].(*Page)
-				p2, ok2 := p.Resources[j].(*Page)
-
-				if ok1 != ok2 {
-					return ok2
-				}
-
-				if ok1 {
-					return defaultPageSort(p1, p2)
-				}
-
-				return p.Resources[i].RelPermalink() < p.Resources[j].RelPermalink()
-			})
-
-			// Assign metadata from front matter if set
-			if len(p.resourcesMetadata) > 0 {
-				resource.AssignMetadata(p.resourcesMetadata, p.Resources...)
-			}
-
-		}
-
-		return h(ctx)
-	}
-}
-
-func (c *contentHandlers) handlePageContent() contentHandler {
-	return func(ctx *handlerContext) handlerResult {
-		if ctx.supports("html", "htm") {
-			return notHandled
-		}
-
-		p := ctx.currentPage
-
-		if c.s.Cfg.GetBool("enableEmoji") {
-			p.workContent = helpers.Emojify(p.workContent)
-		}
-
-		p.workContent = p.renderContent(p.workContent)
-
-		tmpContent, tmpTableOfContents := helpers.ExtractTOC(p.workContent)
-		p.TableOfContents = helpers.BytesToHTML(tmpTableOfContents)
-		p.workContent = tmpContent
-
-		if !ctx.doNotAddToSiteCollections {
-			ctx.pages <- p
-		}
-
-		return handlerResult{handled: true, resource: p}
-	}
-}
-
-func (c *contentHandlers) handleHTMLContent() contentHandler {
-	return func(ctx *handlerContext) handlerResult {
-		if !ctx.supports("html", "htm") {
-			return notHandled
-		}
-
-		p := ctx.currentPage
-
-		if !ctx.doNotAddToSiteCollections {
-			ctx.pages <- p
-		}
-
-		return handlerResult{handled: true, resource: p}
-	}
-}
-
-func (c *contentHandlers) createResource() contentHandler {
-	return func(ctx *handlerContext) handlerResult {
-		if ctx.parentPage == nil {
-			return notHandled
-		}
-
-		resource, err := c.s.ResourceSpec.New(
-			resource.ResourceSourceDescriptor{
-				TargetPathBuilder: ctx.parentPage.subResourceTargetPathFactory,
-				SourceFile:        ctx.source,
-				RelTargetFilename: ctx.target,
-				URLBase:           c.s.GetURLLanguageBasePath(),
-				TargetBasePaths:   []string{c.s.GetTargetLanguageBasePath()},
-			})
-
-		return handlerResult{err: err, handled: true, resource: resource}
-	}
-}
-
-func (c *contentHandlers) copyFile() contentHandler {
-	return func(ctx *handlerContext) handlerResult {
-		f, err := c.s.BaseFs.Content.Fs.Open(ctx.source.Filename())
-		if err != nil {
-			err := fmt.Errorf("failed to open file in copyFile: %s", err)
-			return handlerResult{err: err}
-		}
-
-		target := ctx.targetPath()
-
-		defer f.Close()
-		if err := c.s.publish(&c.s.PathSpec.ProcessingStats.Files, target, f); err != nil {
-			return handlerResult{err: err}
-		}
-
-		return handlerResult{handled: true}
-	}
-}
--- a/hugolib/page_bundler_test.go
+++ /dev/null
@@ -1,751 +1,0 @@
-// Copyright 2017-present 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 hugolib
-
-import (
-	"github.com/gohugoio/hugo/common/loggers"
-
-	"os"
-	"runtime"
-	"testing"
-
-	"github.com/gohugoio/hugo/helpers"
-
-	"io"
-
-	"github.com/spf13/afero"
-
-	"github.com/gohugoio/hugo/media"
-
-	"path/filepath"
-
-	"fmt"
-
-	"github.com/gohugoio/hugo/deps"
-	"github.com/gohugoio/hugo/hugofs"
-	"github.com/spf13/viper"
-
-	"github.com/stretchr/testify/require"
-)
-
-func TestPageBundlerSiteRegular(t *testing.T) {
-	t.Parallel()
-
-	for _, ugly := range []bool{false, true} {
-		t.Run(fmt.Sprintf("ugly=%t", ugly),
-			func(t *testing.T) {
-
-				assert := require.New(t)
-				fs, cfg := newTestBundleSources(t)
-				assert.NoError(loadDefaultSettingsFor(cfg))
-				assert.NoError(loadLanguageSettings(cfg, nil))
-
-				cfg.Set("permalinks", map[string]string{
-					"a": ":sections/:filename",
-					"b": ":year/:slug/",
-					"c": ":sections/:slug",
-					"":  ":filename/",
-				})
-
-				cfg.Set("outputFormats", map[string]interface{}{
-					"CUSTOMO": map[string]interface{}{
-						"mediaType": media.HTMLType,
-						"baseName":  "cindex",
-						"path":      "cpath",
-					},
-				})
-
-				cfg.Set("outputs", map[string]interface{}{
-					"home":    []string{"HTML", "CUSTOMO"},
-					"page":    []string{"HTML", "CUSTOMO"},
-					"section": []string{"HTML", "CUSTOMO"},
-				})
-
-				cfg.Set("uglyURLs", ugly)
-
-				s := buildSingleSite(t, deps.DepsCfg{Logger: loggers.NewWarningLogger(), Fs: fs, Cfg: cfg}, BuildCfg{})
-
-				th := testHelper{s.Cfg, s.Fs, t}
-
-				assert.Len(s.RegularPages, 8)
-
-				singlePage := s.getPage(KindPage, "a/1.md")
-				assert.Equal("", singlePage.BundleType())
-
-				assert.NotNil(singlePage)
-				assert.Equal(singlePage, s.getPage("page", "a/1"))
-				assert.Equal(singlePage, s.getPage("page", "1"))
-
-				assert.Contains(singlePage.content(), "TheContent")
-
-				if ugly {
-					assert.Equal("/a/1.html", singlePage.RelPermalink())
-					th.assertFileContent(filepath.FromSlash("/work/public/a/1.html"), "TheContent")
-
-				} else {
-					assert.Equal("/a/1/", singlePage.RelPermalink())
-					th.assertFileContent(filepath.FromSlash("/work/public/a/1/index.html"), "TheContent")
-				}
-
-				th.assertFileContent(filepath.FromSlash("/work/public/images/hugo-logo.png"), "content")
-
-				// This should be just copied to destination.
-				th.assertFileContent(filepath.FromSlash("/work/public/assets/pic1.png"), "content")
-
-				leafBundle1 := s.getPage(KindPage, "b/my-bundle/index.md")
-				assert.NotNil(leafBundle1)
-				assert.Equal("leaf", leafBundle1.BundleType())
-				assert.Equal("b", leafBundle1.Section())
-				sectionB := s.getPage(KindSection, "b")
-				assert.NotNil(sectionB)
-				home, _ := s.Info.Home()
-				assert.Equal("branch", home.BundleType())
-
-				// This is a root bundle and should live in the "home section"
-				// See https://github.com/gohugoio/hugo/issues/4332
-				rootBundle := s.getPage(KindPage, "root")
-				assert.NotNil(rootBundle)
-				assert.True(rootBundle.Parent().IsHome())
-				if ugly {
-					assert.Equal("/root.html", rootBundle.RelPermalink())
-				} else {
-					assert.Equal("/root/", rootBundle.RelPermalink())
-				}
-
-				leafBundle2 := s.getPage(KindPage, "a/b/index.md")
-				assert.NotNil(leafBundle2)
-				unicodeBundle := s.getPage(KindPage, "c/bundle/index.md")
-				assert.NotNil(unicodeBundle)
-
-				pageResources := leafBundle1.Resources.ByType(pageResourceType)
-				assert.Len(pageResources, 2)
-				firstPage := pageResources[0].(*Page)
-				secondPage := pageResources[1].(*Page)
-				assert.Equal(filepath.FromSlash("/work/base/b/my-bundle/1.md"), firstPage.pathOrTitle(), secondPage.pathOrTitle())
-				assert.Contains(firstPage.content(), "TheContent")
-				assert.Equal(6, len(leafBundle1.Resources))
-
-				// Verify shortcode in bundled page
-				assert.Contains(secondPage.content(), filepath.FromSlash("MyShort in b/my-bundle/2.md"))
-
-				// https://github.com/gohugoio/hugo/issues/4582
-				assert.Equal(leafBundle1, firstPage.Parent())
-				assert.Equal(leafBundle1, secondPage.Parent())
-
-				assert.Equal(firstPage, pageResources.GetMatch("1*"))
-				assert.Equal(secondPage, pageResources.GetMatch("2*"))
-				assert.Nil(pageResources.GetMatch("doesnotexist*"))
-
-				imageResources := leafBundle1.Resources.ByType("image")
-				assert.Equal(3, len(imageResources))
-				image := imageResources[0]
-
-				altFormat := leafBundle1.OutputFormats().Get("CUSTOMO")
-				assert.NotNil(altFormat)
-
-				assert.Equal("https://example.com/2017/pageslug/c/logo.png", image.Permalink())
-
-				th.assertFileContent(filepath.FromSlash("/work/public/2017/pageslug/c/logo.png"), "content")
-				th.assertFileContent(filepath.FromSlash("/work/public/cpath/2017/pageslug/c/logo.png"), "content")
-
-				// Custom media type defined in site config.
-				assert.Len(leafBundle1.Resources.ByType("bepsays"), 1)
-
-				if ugly {
-					assert.Equal("/2017/pageslug.html", leafBundle1.RelPermalink())
-					th.assertFileContent(filepath.FromSlash("/work/public/2017/pageslug.html"),
-						"TheContent",
-						"Sunset RelPermalink: /2017/pageslug/sunset1.jpg",
-						"Thumb Width: 123",
-						"Thumb Name: my-sunset-1",
-						"Short Sunset RelPermalink: /2017/pageslug/sunset2.jpg",
-						"Short Thumb Width: 56",
-						"1: Image Title: Sunset Galore 1",
-						"1: Image Params: map[myparam:My Sunny Param]",
-						"2: Image Title: Sunset Galore 2",
-						"2: Image Params: map[myparam:My Sunny Param]",
-						"1: Image myParam: Lower: My Sunny Param Caps: My Sunny Param",
-					)
-					th.assertFileContent(filepath.FromSlash("/work/public/cpath/2017/pageslug.html"), "TheContent")
-
-					assert.Equal("/a/b.html", leafBundle2.RelPermalink())
-
-					// 은행
-					assert.Equal("/c/%EC%9D%80%ED%96%89.html", unicodeBundle.RelPermalink())
-					th.assertFileContent(filepath.FromSlash("/work/public/c/은행.html"), "Content for 은행")
-					th.assertFileContent(filepath.FromSlash("/work/public/c/은행/logo-은행.png"), "은행 PNG")
-
-				} else {
-					assert.Equal("/2017/pageslug/", leafBundle1.RelPermalink())
-					th.assertFileContent(filepath.FromSlash("/work/public/2017/pageslug/index.html"), "TheContent")
-					th.assertFileContent(filepath.FromSlash("/work/public/cpath/2017/pageslug/cindex.html"), "TheContent")
-					th.assertFileContent(filepath.FromSlash("/work/public/2017/pageslug/index.html"), "Single Title")
-					th.assertFileContent(filepath.FromSlash("/work/public/root/index.html"), "Single Title")
-
-					assert.Equal("/a/b/", leafBundle2.RelPermalink())
-
-				}
-
-			})
-	}
-
-}
-
-func TestPageBundlerSiteMultilingual(t *testing.T) {
-	t.Parallel()
-
-	for _, ugly := range []bool{false, true} {
-		t.Run(fmt.Sprintf("ugly=%t", ugly),
-			func(t *testing.T) {
-
-				assert := require.New(t)
-				fs, cfg := newTestBundleSourcesMultilingual(t)
-				cfg.Set("uglyURLs", ugly)
-
-				assert.NoError(loadDefaultSettingsFor(cfg))
-				assert.NoError(loadLanguageSettings(cfg, nil))
-				sites, err := NewHugoSites(deps.DepsCfg{Fs: fs, Cfg: cfg})
-				assert.NoError(err)
-				assert.Equal(2, len(sites.Sites))
-
-				assert.NoError(sites.Build(BuildCfg{}))
-
-				s := sites.Sites[0]
-
-				assert.Equal(8, len(s.RegularPages))
-				assert.Equal(16, len(s.Pages))
-				assert.Equal(31, len(s.AllPages))
-
-				bundleWithSubPath := s.getPage(KindPage, "lb/index")
-				assert.NotNil(bundleWithSubPath)
-
-				// See https://github.com/gohugoio/hugo/issues/4312
-				// Before that issue:
-				// A bundle in a/b/index.en.md
-				// a/b/index.en.md => OK
-				// a/b/index => OK
-				// index.en.md => ambigous, but OK.
-				// With bundles, the file name has little meaning, the folder it lives in does. So this should also work:
-				// a/b
-				// and probably also just b (aka "my-bundle")
-				// These may also be translated, so we also need to test that.
-				//  "bf", "my-bf-bundle", "index.md + nn
-				bfBundle := s.getPage(KindPage, "bf/my-bf-bundle/index")
-				assert.NotNil(bfBundle)
-				assert.Equal("en", bfBundle.Lang())
-				assert.Equal(bfBundle, s.getPage(KindPage, "bf/my-bf-bundle/index.md"))
-				assert.Equal(bfBundle, s.getPage(KindPage, "bf/my-bf-bundle"))
-				assert.Equal(bfBundle, s.getPage(KindPage, "my-bf-bundle"))
-
-				nnSite := sites.Sites[1]
-				assert.Equal(7, len(nnSite.RegularPages))
-
-				bfBundleNN := nnSite.getPage(KindPage, "bf/my-bf-bundle/index")
-				assert.NotNil(bfBundleNN)
-				assert.Equal("nn", bfBundleNN.Lang())
-				assert.Equal(bfBundleNN, nnSite.getPage(KindPage, "bf/my-bf-bundle/index.nn.md"))
-				assert.Equal(bfBundleNN, nnSite.getPage(KindPage, "bf/my-bf-bundle"))
-				assert.Equal(bfBundleNN, nnSite.getPage(KindPage, "my-bf-bundle"))
-
-				// See https://github.com/gohugoio/hugo/issues/4295
-				// Every resource should have its Name prefixed with its base folder.
-				cBundleResources := bundleWithSubPath.Resources.Match("c/**")
-				assert.Equal(4, len(cBundleResources))
-				bundlePage := bundleWithSubPath.Resources.GetMatch("c/page*")
-				assert.NotNil(bundlePage)
-				assert.IsType(&Page{}, bundlePage)
-
-			})
-	}
-}
-
-func TestMultilingualDisableDefaultLanguage(t *testing.T) {
-	t.Parallel()
-
-	assert := require.New(t)
-	_, cfg := newTestBundleSourcesMultilingual(t)
-
-	cfg.Set("disableLanguages", []string{"en"})
-
-	err := loadDefaultSettingsFor(cfg)
-	assert.NoError(err)
-	err = loadLanguageSettings(cfg, nil)
-	assert.Error(err)
-	assert.Contains(err.Error(), "cannot disable default language")
-}
-
-func TestMultilingualDisableLanguage(t *testing.T) {
-	t.Parallel()
-
-	assert := require.New(t)
-	fs, cfg := newTestBundleSourcesMultilingual(t)
-	cfg.Set("disableLanguages", []string{"nn"})
-
-	assert.NoError(loadDefaultSettingsFor(cfg))
-	assert.NoError(loadLanguageSettings(cfg, nil))
-
-	sites, err := NewHugoSites(deps.DepsCfg{Fs: fs, Cfg: cfg})
-	assert.NoError(err)
-	assert.Equal(1, len(sites.Sites))
-
-	assert.NoError(sites.Build(BuildCfg{}))
-
-	s := sites.Sites[0]
-
-	assert.Equal(8, len(s.RegularPages))
-	assert.Equal(16, len(s.Pages))
-	// No nn pages
-	assert.Equal(16, len(s.AllPages))
-	for _, p := range s.rawAllPages {
-		assert.True(p.Lang() != "nn")
-	}
-	for _, p := range s.AllPages {
-		assert.True(p.Lang() != "nn")
-	}
-
-}
-
-func TestPageBundlerSiteWitSymbolicLinksInContent(t *testing.T) {
-	if runtime.GOOS == "windows" && os.Getenv("CI") == "" {
-		t.Skip("Skip TestPageBundlerSiteWitSymbolicLinksInContent as os.Symlink needs administrator rights on Windows")
-	}
-
-	assert := require.New(t)
-	ps, clean, workDir := newTestBundleSymbolicSources(t)
-	defer clean()
-
-	cfg := ps.Cfg
-	fs := ps.Fs
-
-	s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg, Logger: loggers.NewErrorLogger()}, BuildCfg{})
-
-	th := testHelper{s.Cfg, s.Fs, t}
-
-	assert.Equal(7, len(s.RegularPages))
-	a1Bundle := s.getPage(KindPage, "symbolic2/a1/index.md")
-	assert.NotNil(a1Bundle)
-	assert.Equal(2, len(a1Bundle.Resources))
-	assert.Equal(1, len(a1Bundle.Resources.ByType(pageResourceType)))
-
-	th.assertFileContent(filepath.FromSlash(workDir+"/public/a/page/index.html"), "TheContent")
-	th.assertFileContent(filepath.FromSlash(workDir+"/public/symbolic1/s1/index.html"), "TheContent")
-	th.assertFileContent(filepath.FromSlash(workDir+"/public/symbolic2/a1/index.html"), "TheContent")
-
-}
-
-func TestPageBundlerHeadless(t *testing.T) {
-	t.Parallel()
-
-	cfg, fs := newTestCfg()
-	assert := require.New(t)
-
-	workDir := "/work"
-	cfg.Set("workingDir", workDir)
-	cfg.Set("contentDir", "base")
-	cfg.Set("baseURL", "https://example.com")
-
-	pageContent := `---
-title: "Bundle Galore"
-slug: s1
-date: 2017-01-23
----
-
-TheContent.
-
-{{< myShort >}}
-`
-
-	writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "single.html"), "single {{ .Content }}")
-	writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "list.html"), "list")
-	writeSource(t, fs, filepath.Join(workDir, "layouts", "shortcodes", "myShort.html"), "SHORTCODE")
-
-	writeSource(t, fs, filepath.Join(workDir, "base", "a", "index.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "a", "l1.png"), "PNG image")
-	writeSource(t, fs, filepath.Join(workDir, "base", "a", "l2.png"), "PNG image")
-
-	writeSource(t, fs, filepath.Join(workDir, "base", "b", "index.md"), `---
-title: "Headless Bundle in Topless Bar"
-slug: s2
-headless: true
-date: 2017-01-23
----
-
-TheContent.
-HEADLESS {{< myShort >}}
-`)
-	writeSource(t, fs, filepath.Join(workDir, "base", "b", "l1.png"), "PNG image")
-	writeSource(t, fs, filepath.Join(workDir, "base", "b", "l2.png"), "PNG image")
-	writeSource(t, fs, filepath.Join(workDir, "base", "b", "p1.md"), pageContent)
-
-	s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{})
-
-	assert.Equal(1, len(s.RegularPages))
-	assert.Equal(1, len(s.headlessPages))
-
-	regular := s.getPage(KindPage, "a/index")
-	assert.Equal("/a/s1/", regular.RelPermalink())
-
-	headless := s.getPage(KindPage, "b/index")
-	assert.NotNil(headless)
-	assert.True(headless.headless)
-	assert.Equal("Headless Bundle in Topless Bar", headless.Title())
-	assert.Equal("", headless.RelPermalink())
-	assert.Equal("", headless.Permalink())
-	assert.Contains(headless.content(), "HEADLESS SHORTCODE")
-
-	headlessResources := headless.Resources
-	assert.Equal(3, len(headlessResources))
-	assert.Equal(2, len(headlessResources.Match("l*")))
-	pageResource := headlessResources.GetMatch("p*")
-	assert.NotNil(pageResource)
-	assert.IsType(&Page{}, pageResource)
-	p := pageResource.(*Page)
-	assert.Contains(p.content(), "SHORTCODE")
-	assert.Equal("p1.md", p.Name())
-
-	th := testHelper{s.Cfg, s.Fs, t}
-
-	th.assertFileContent(filepath.FromSlash(workDir+"/public/a/s1/index.html"), "TheContent")
-	th.assertFileContent(filepath.FromSlash(workDir+"/public/a/s1/l1.png"), "PNG")
-
-	th.assertFileNotExist(workDir + "/public/b/s2/index.html")
-	// But the bundled resources needs to be published
-	th.assertFileContent(filepath.FromSlash(workDir+"/public/b/s2/l1.png"), "PNG")
-
-}
-
-func newTestBundleSources(t *testing.T) (*hugofs.Fs, *viper.Viper) {
-	cfg, fs := newTestCfg()
-	assert := require.New(t)
-
-	workDir := "/work"
-	cfg.Set("workingDir", workDir)
-	cfg.Set("contentDir", "base")
-	cfg.Set("baseURL", "https://example.com")
-	cfg.Set("mediaTypes", map[string]interface{}{
-		"text/bepsays": map[string]interface{}{
-			"suffixes": []string{"bep"},
-		},
-	})
-
-	pageContent := `---
-title: "Bundle Galore"
-slug: pageslug
-date: 2017-10-09
----
-
-TheContent.
-`
-
-	pageContentShortcode := `---
-title: "Bundle Galore"
-slug: pageslug
-date: 2017-10-09
----
-
-TheContent.
-
-{{< myShort >}}
-`
-
-	pageWithImageShortcodeAndResourceMetadataContent := `---
-title: "Bundle Galore"
-slug: pageslug
-date: 2017-10-09
-resources:
-- src: "*.jpg"
-  name: "my-sunset-:counter"
-  title: "Sunset Galore :counter"
-  params:
-    myParam: "My Sunny Param"
----
-
-TheContent.
-
-{{< myShort >}}
-`
-
-	pageContentNoSlug := `---
-title: "Bundle Galore #2"
-date: 2017-10-09
----
-
-TheContent.
-`
-
-	singleLayout := `
-Single Title: {{ .Title }}
-Content: {{ .Content }}
-{{ $sunset := .Resources.GetMatch "my-sunset-1*" }}
-{{ with $sunset }}
-Sunset RelPermalink: {{ .RelPermalink }}
-{{ $thumb := .Fill "123x123" }}
-Thumb Width: {{ $thumb.Width }}
-Thumb Name: {{ $thumb.Name }}
-Thumb Title: {{ $thumb.Title }}
-Thumb RelPermalink: {{ $thumb.RelPermalink }}
-{{ end }}
-{{ range $i, $e := .Resources.ByType "image" }}
-{{ $i }}: Image Title: {{ .Title }}
-{{ $i }}: Image Name: {{ .Name }}
-{{ $i }}: Image Params: {{ printf "%v" .Params }}
-{{ $i }}: Image myParam: Lower: {{ .Params.myparam }} Caps: {{ .Params.MYPARAM }}
-{{ end }}
-`
-
-	myShort := `
-MyShort in {{ .Page.Path }}:
-{{ $sunset := .Page.Resources.GetMatch "my-sunset-2*" }}
-{{ with $sunset }}
-Short Sunset RelPermalink: {{ .RelPermalink }}
-{{ $thumb := .Fill "56x56" }}
-Short Thumb Width: {{ $thumb.Width }}
-{{ end }}
-`
-
-	listLayout := `{{ .Title }}|{{ .Content }}`
-
-	writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "single.html"), singleLayout)
-	writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "list.html"), listLayout)
-	writeSource(t, fs, filepath.Join(workDir, "layouts", "shortcodes", "myShort.html"), myShort)
-
-	writeSource(t, fs, filepath.Join(workDir, "base", "_index.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "_1.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "_1.png"), pageContent)
-
-	writeSource(t, fs, filepath.Join(workDir, "base", "images", "hugo-logo.png"), "content")
-	writeSource(t, fs, filepath.Join(workDir, "base", "a", "2.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "a", "1.md"), pageContent)
-
-	writeSource(t, fs, filepath.Join(workDir, "base", "a", "b", "index.md"), pageContentNoSlug)
-	writeSource(t, fs, filepath.Join(workDir, "base", "a", "b", "ab1.md"), pageContentNoSlug)
-
-	// Mostly plain static assets in a folder with a page in a sub folder thrown in.
-	writeSource(t, fs, filepath.Join(workDir, "base", "assets", "pic1.png"), "content")
-	writeSource(t, fs, filepath.Join(workDir, "base", "assets", "pic2.png"), "content")
-	writeSource(t, fs, filepath.Join(workDir, "base", "assets", "pages", "mypage.md"), pageContent)
-
-	// Bundle
-	writeSource(t, fs, filepath.Join(workDir, "base", "b", "my-bundle", "index.md"), pageWithImageShortcodeAndResourceMetadataContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "b", "my-bundle", "1.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "b", "my-bundle", "2.md"), pageContentShortcode)
-	writeSource(t, fs, filepath.Join(workDir, "base", "b", "my-bundle", "custom-mime.bep"), "bepsays")
-	writeSource(t, fs, filepath.Join(workDir, "base", "b", "my-bundle", "c", "logo.png"), "content")
-
-	// Bundle with 은행 slug
-	// See https://github.com/gohugoio/hugo/issues/4241
-	writeSource(t, fs, filepath.Join(workDir, "base", "c", "bundle", "index.md"), `---
-title: "은행 은행"
-slug: 은행
-date: 2017-10-09
----
-
-Content for 은행.
-`)
-
-	// Bundle in root
-	writeSource(t, fs, filepath.Join(workDir, "base", "root", "index.md"), pageWithImageShortcodeAndResourceMetadataContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "root", "1.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "root", "c", "logo.png"), "content")
-
-	writeSource(t, fs, filepath.Join(workDir, "base", "c", "bundle", "logo-은행.png"), "은행 PNG")
-
-	// Write a real image into one of the bundle above.
-	src, err := os.Open("testdata/sunset.jpg")
-	assert.NoError(err)
-
-	// We need 2 to test https://github.com/gohugoio/hugo/issues/4202
-	out, err := fs.Source.Create(filepath.Join(workDir, "base", "b", "my-bundle", "sunset1.jpg"))
-	assert.NoError(err)
-	out2, err := fs.Source.Create(filepath.Join(workDir, "base", "b", "my-bundle", "sunset2.jpg"))
-	assert.NoError(err)
-
-	_, err = io.Copy(out, src)
-	out.Close()
-	src.Seek(0, 0)
-	_, err = io.Copy(out2, src)
-	out2.Close()
-	src.Close()
-	assert.NoError(err)
-
-	return fs, cfg
-
-}
-
-func newTestBundleSourcesMultilingual(t *testing.T) (*hugofs.Fs, *viper.Viper) {
-	cfg, fs := newTestCfg()
-
-	workDir := "/work"
-	cfg.Set("workingDir", workDir)
-	cfg.Set("contentDir", "base")
-	cfg.Set("baseURL", "https://example.com")
-	cfg.Set("defaultContentLanguage", "en")
-
-	langConfig := map[string]interface{}{
-		"en": map[string]interface{}{
-			"weight":       1,
-			"languageName": "English",
-		},
-		"nn": map[string]interface{}{
-			"weight":       2,
-			"languageName": "Nynorsk",
-		},
-	}
-
-	cfg.Set("languages", langConfig)
-
-	pageContent := `---
-slug: pageslug
-date: 2017-10-09
----
-
-TheContent.
-`
-
-	layout := `{{ .Title }}|{{ .Content }}|Lang: {{ .Site.Language.Lang }}`
-
-	writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "single.html"), layout)
-	writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "list.html"), layout)
-
-	writeSource(t, fs, filepath.Join(workDir, "base", "1s", "mypage.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "1s", "mypage.nn.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "1s", "mylogo.png"), "content")
-
-	writeSource(t, fs, filepath.Join(workDir, "base", "bb", "_index.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "bb", "_index.nn.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "bb", "en.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "bb", "_1.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "bb", "_1.nn.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "bb", "a.png"), "content")
-	writeSource(t, fs, filepath.Join(workDir, "base", "bb", "b.png"), "content")
-	writeSource(t, fs, filepath.Join(workDir, "base", "bb", "b.nn.png"), "content")
-	writeSource(t, fs, filepath.Join(workDir, "base", "bb", "c.nn.png"), "content")
-	writeSource(t, fs, filepath.Join(workDir, "base", "bb", "b", "d.nn.png"), "content")
-
-	writeSource(t, fs, filepath.Join(workDir, "base", "bc", "_index.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "bc", "page.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "bc", "logo-bc.png"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "bc", "page.nn.md"), pageContent)
-
-	writeSource(t, fs, filepath.Join(workDir, "base", "bd", "index.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "bd", "page.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "bd", "page.nn.md"), pageContent)
-
-	writeSource(t, fs, filepath.Join(workDir, "base", "be", "_index.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "be", "page.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "be", "page.nn.md"), pageContent)
-
-	// Bundle leaf,  multilingual
-	writeSource(t, fs, filepath.Join(workDir, "base", "lb", "index.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "lb", "index.nn.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "lb", "1.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "lb", "2.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "lb", "2.nn.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "lb", "c", "page.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "lb", "c", "logo.png"), "content")
-	writeSource(t, fs, filepath.Join(workDir, "base", "lb", "c", "logo.nn.png"), "content")
-	writeSource(t, fs, filepath.Join(workDir, "base", "lb", "c", "one.png"), "content")
-	writeSource(t, fs, filepath.Join(workDir, "base", "lb", "c", "d", "deep.png"), "content")
-
-	//Translated bundle in some sensible sub path.
-	writeSource(t, fs, filepath.Join(workDir, "base", "bf", "my-bf-bundle", "index.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "bf", "my-bf-bundle", "index.nn.md"), pageContent)
-	writeSource(t, fs, filepath.Join(workDir, "base", "bf", "my-bf-bundle", "page.md"), pageContent)
-
-	return fs, cfg
-}
-
-func newTestBundleSymbolicSources(t *testing.T) (*helpers.PathSpec, func(), string) {
-	assert := require.New(t)
-	// We need to use the OS fs for this.
-	cfg := viper.New()
-	fs := hugofs.NewFrom(hugofs.Os, cfg)
-	fs.Destination = &afero.MemMapFs{}
-	loadDefaultSettingsFor(cfg)
-
-	workDir, clean, err := createTempDir("hugosym")
-	assert.NoError(err)
-
-	contentDir := "base"
-	cfg.Set("workingDir", workDir)
-	cfg.Set("contentDir", contentDir)
-	cfg.Set("baseURL", "https://example.com")
-
-	if err := loadLanguageSettings(cfg, nil); err != nil {
-		t.Fatal(err)
-	}
-
-	layout := `{{ .Title }}|{{ .Content }}`
-	pageContent := `---
-slug: %s
-date: 2017-10-09
----
-
-TheContent.
-`
-
-	fs.Source.MkdirAll(filepath.Join(workDir, "layouts", "_default"), 0777)
-	fs.Source.MkdirAll(filepath.Join(workDir, contentDir), 0777)
-	fs.Source.MkdirAll(filepath.Join(workDir, contentDir, "a"), 0777)
-	for i := 1; i <= 3; i++ {
-		fs.Source.MkdirAll(filepath.Join(workDir, fmt.Sprintf("symcontent%d", i)), 0777)
-
-	}
-	fs.Source.MkdirAll(filepath.Join(workDir, "symcontent2", "a1"), 0777)
-
-	writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "single.html"), layout)
-	writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "list.html"), layout)
-
-	writeSource(t, fs, filepath.Join(workDir, contentDir, "a", "regular.md"), fmt.Sprintf(pageContent, "a1"))
-
-	// Regular files inside symlinked folder.
-	writeSource(t, fs, filepath.Join(workDir, "symcontent1", "s1.md"), fmt.Sprintf(pageContent, "s1"))
-	writeSource(t, fs, filepath.Join(workDir, "symcontent1", "s2.md"), fmt.Sprintf(pageContent, "s2"))
-
-	// A bundle
-	writeSource(t, fs, filepath.Join(workDir, "symcontent2", "a1", "index.md"), fmt.Sprintf(pageContent, ""))
-	writeSource(t, fs, filepath.Join(workDir, "symcontent2", "a1", "page.md"), fmt.Sprintf(pageContent, "page"))
-	writeSource(t, fs, filepath.Join(workDir, "symcontent2", "a1", "logo.png"), "image")
-
-	// Assets
-	writeSource(t, fs, filepath.Join(workDir, "symcontent3", "s1.png"), "image")
-	writeSource(t, fs, filepath.Join(workDir, "symcontent3", "s2.png"), "image")
-
-	wd, _ := os.Getwd()
-	defer func() {
-		os.Chdir(wd)
-	}()
-	// Symlinked sections inside content.
-	os.Chdir(filepath.Join(workDir, contentDir))
-	for i := 1; i <= 3; i++ {
-		assert.NoError(os.Symlink(filepath.FromSlash(fmt.Sprintf(("../symcontent%d"), i)), fmt.Sprintf("symbolic%d", i)))
-	}
-
-	os.Chdir(filepath.Join(workDir, contentDir, "a"))
-
-	// Create a symlink to one single content file
-	assert.NoError(os.Symlink(filepath.FromSlash("../../symcontent2/a1/page.md"), "page_s.md"))
-
-	os.Chdir(filepath.FromSlash("../../symcontent3"))
-
-	// Create a circular symlink. Will print some warnings.
-	assert.NoError(os.Symlink(filepath.Join("..", contentDir), filepath.FromSlash("circus")))
-
-	os.Chdir(workDir)
-	assert.NoError(err)
-
-	ps, _ := helpers.NewPathSpec(fs, cfg)
-
-	return ps, clean, workDir
-}
--- a/hugolib/page_collections.go
+++ /dev/null
@@ -1,341 +1,0 @@
-// Copyright 2016 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 hugolib
-
-import (
-	"fmt"
-	"path"
-	"path/filepath"
-	"strings"
-
-	"github.com/gohugoio/hugo/cache"
-	"github.com/gohugoio/hugo/helpers"
-)
-
-// PageCollections contains the page collections for a site.
-type PageCollections struct {
-	// Includes only pages of all types, and only pages in the current language.
-	Pages Pages
-
-	// Includes all pages in all languages, including the current one.
-	// Includes pages of all types.
-	AllPages Pages
-
-	// A convenience cache for the traditional index types, taxonomies, home page etc.
-	// This is for the current language only.
-	indexPages Pages
-
-	// A convenience cache for the regular pages.
-	// This is for the current language only.
-	RegularPages Pages
-
-	// A convenience cache for the all the regular pages.
-	AllRegularPages Pages
-
-	// Includes absolute all pages (of all types), including drafts etc.
-	rawAllPages Pages
-
-	// Includes headless bundles, i.e. bundles that produce no output for its content page.
-	headlessPages Pages
-
-	pageIndex *cache.Lazy
-}
-
-// Get initializes the index if not already done so, then
-// looks up the given page ref, returns nil if no value found.
-func (c *PageCollections) getFromCache(ref string) (*Page, error) {
-	v, found, err := c.pageIndex.Get(ref)
-	if err != nil {
-		return nil, err
-	}
-	if !found {
-		return nil, nil
-	}
-
-	p := v.(*Page)
-
-	if p != ambiguityFlag {
-		return p, nil
-	}
-	return nil, fmt.Errorf("page reference %q is ambiguous", ref)
-}
-
-var ambiguityFlag = &Page{Kind: kindUnknown, title: "ambiguity flag"}
-
-func (c *PageCollections) refreshPageCaches() {
-	c.indexPages = c.findPagesByKindNotIn(KindPage, c.Pages)
-	c.RegularPages = c.findPagesByKindIn(KindPage, c.Pages)
-	c.AllRegularPages = c.findPagesByKindIn(KindPage, c.AllPages)
-
-	indexLoader := func() (map[string]interface{}, error) {
-		index := make(map[string]interface{})
-
-		add := func(ref string, p *Page) {
-			existing := index[ref]
-			if existing == nil {
-				index[ref] = p
-			} else if existing != ambiguityFlag && existing != p {
-				index[ref] = ambiguityFlag
-			}
-		}
-
-		for _, pageCollection := range []Pages{c.RegularPages, c.headlessPages} {
-			for _, p := range pageCollection {
-				sourceRef := p.absoluteSourceRef()
-
-				if sourceRef != "" {
-					// index the canonical ref
-					// e.g. /section/article.md
-					add(sourceRef, p)
-				}
-
-				// Ref/Relref supports this potentially ambiguous lookup.
-				add(p.LogicalName(), p)
-
-				translationBaseName := p.TranslationBaseName()
-
-				dir, _ := path.Split(sourceRef)
-				dir = strings.TrimSuffix(dir, "/")
-
-				if translationBaseName == "index" {
-					add(dir, p)
-					add(path.Base(dir), p)
-				} else {
-					add(translationBaseName, p)
-				}
-
-				// We need a way to get to the current language version.
-				pathWithNoExtensions := path.Join(dir, translationBaseName)
-				add(pathWithNoExtensions, p)
-			}
-		}
-
-		for _, p := range c.indexPages {
-			// index the canonical, unambiguous ref for any backing file
-			// e.g. /section/_index.md
-			sourceRef := p.absoluteSourceRef()
-			if sourceRef != "" {
-				add(sourceRef, p)
-			}
-
-			ref := path.Join(p.sections...)
-
-			// index the canonical, unambiguous virtual ref
-			// e.g. /section
-			// (this may already have been indexed above)
-			add("/"+ref, p)
-		}
-
-		return index, nil
-	}
-
-	c.pageIndex = cache.NewLazy(indexLoader)
-}
-
-func newPageCollections() *PageCollections {
-	return &PageCollections{}
-}
-
-func newPageCollectionsFromPages(pages Pages) *PageCollections {
-	return &PageCollections{rawAllPages: pages}
-}
-
-// This is an adapter func for the old API with Kind as first argument.
-// This is invoked when you do .Site.GetPage. We drop the Kind and fails
-// if there are more than 2 arguments, which would be ambigous.
-func (c *PageCollections) getPageOldVersion(ref ...string) (*Page, error) {
-	var refs []string
-	for _, r := range ref {
-		// A common construct in the wild is
-		// .Site.GetPage "home" "" or
-		// .Site.GetPage "home" "/"
-		if r != "" && r != "/" {
-			refs = append(refs, r)
-		}
-	}
-
-	var key string
-
-	if len(refs) > 2 {
-		// This was allowed in Hugo <= 0.44, but we cannot support this with the
-		// new API. This should be the most unusual case.
-		return nil, fmt.Errorf(`too many arguments to .Site.GetPage: %v. Use lookups on the form {{ .Site.GetPage "/posts/mypage-md" }}`, ref)
-	}
-
-	if len(refs) == 0 || refs[0] == KindHome {
-		key = "/"
-	} else if len(refs) == 1 {
-		if len(ref) == 2 && refs[0] == KindSection {
-			// This is an old style reference to the "Home Page section".
-			// Typically fetched via {{ .Site.GetPage "section" .Section }}
-			// See https://github.com/gohugoio/hugo/issues/4989
-			key = "/"
-		} else {
-			key = refs[0]
-		}
-	} else {
-		key = refs[1]
-	}
-
-	key = filepath.ToSlash(key)
-	if !strings.HasPrefix(key, "/") {
-		key = "/" + key
-	}
-
-	return c.getPageNew(nil, key)
-}
-
-// 	Only used in tests.
-func (c *PageCollections) getPage(typ string, sections ...string) *Page {
-	refs := append([]string{typ}, path.Join(sections...))
-	p, _ := c.getPageOldVersion(refs...)
-	return p
-}
-
-// Ref is either unix-style paths (i.e. callers responsible for
-// calling filepath.ToSlash as necessary) or shorthand refs.
-func (c *PageCollections) getPageNew(context *Page, ref string) (*Page, error) {
-	var anError error
-
-	// Absolute (content root relative) reference.
-	if strings.HasPrefix(ref, "/") {
-		p, err := c.getFromCache(ref)
-		if err == nil && p != nil {
-			return p, nil
-		}
-		if err != nil {
-			anError = err
-		}
-
-	} else if context != nil {
-		// Try the page-relative path.
-		ppath := path.Join("/", strings.Join(context.sections, "/"), ref)
-		p, err := c.getFromCache(ppath)
-		if err == nil && p != nil {
-			return p, nil
-		}
-		if err != nil {
-			anError = err
-		}
-	}
-
-	if !strings.HasPrefix(ref, "/") {
-		// Many people will have "post/foo.md" in their content files.
-		p, err := c.getFromCache("/" + ref)
-		if err == nil && p != nil {
-			if context != nil {
-				// TODO(bep) remove this case and the message below when the storm has passed
-				helpers.DistinctFeedbackLog.Printf(`WARNING: make non-relative ref/relref page reference(s) in page %q absolute, e.g. {{< ref "/blog/my-post.md" >}}`, context.absoluteSourceRef())
-			}
-			return p, nil
-		}
-		if err != nil {
-			anError = err
-		}
-	}
-
-	// Last try.
-	ref = strings.TrimPrefix(ref, "/")
-	p, err := c.getFromCache(ref)
-	if err != nil {
-		anError = err
-	}
-
-	if p == nil && anError != nil {
-		if context != nil {
-			return nil, fmt.Errorf("failed to resolve path from page %q: %s", context.absoluteSourceRef(), anError)
-		}
-		return nil, fmt.Errorf("failed to resolve page: %s", anError)
-	}
-
-	return p, nil
-}
-
-func (*PageCollections) findPagesByKindIn(kind string, inPages Pages) Pages {
-	var pages Pages
-	for _, p := range inPages {
-		if p.Kind == kind {
-			pages = append(pages, p)
-		}
-	}
-	return pages
-}
-
-func (*PageCollections) findFirstPageByKindIn(kind string, inPages Pages) *Page {
-	for _, p := range inPages {
-		if p.Kind == kind {
-			return p
-		}
-	}
-	return nil
-}
-
-func (*PageCollections) findPagesByKindNotIn(kind string, inPages Pages) Pages {
-	var pages Pages
-	for _, p := range inPages {
-		if p.Kind != kind {
-			pages = append(pages, p)
-		}
-	}
-	return pages
-}
-
-func (c *PageCollections) findPagesByKind(kind string) Pages {
-	return c.findPagesByKindIn(kind, c.Pages)
-}
-
-func (c *PageCollections) addPage(page *Page) {
-	c.rawAllPages = append(c.rawAllPages, page)
-}
-
-func (c *PageCollections) removePageFilename(filename string) {
-	if i := c.rawAllPages.findPagePosByFilename(filename); i >= 0 {
-		c.clearResourceCacheForPage(c.rawAllPages[i])
-		c.rawAllPages = append(c.rawAllPages[:i], c.rawAllPages[i+1:]...)
-	}
-
-}
-
-func (c *PageCollections) removePage(page *Page) {
-	if i := c.rawAllPages.findPagePos(page); i >= 0 {
-		c.clearResourceCacheForPage(c.rawAllPages[i])
-		c.rawAllPages = append(c.rawAllPages[:i], c.rawAllPages[i+1:]...)
-	}
-
-}
-
-func (c *PageCollections) findPagesByShortcode(shortcode string) Pages {
-	var pages Pages
-
-	for _, p := range c.rawAllPages {
-		if p.shortcodeState != nil {
-			if _, ok := p.shortcodeState.nameSet[shortcode]; ok {
-				pages = append(pages, p)
-			}
-		}
-	}
-	return pages
-}
-
-func (c *PageCollections) replacePage(page *Page) {
-	// will find existing page that matches filepath and remove it
-	c.removePage(page)
-	c.addPage(page)
-}
-
-func (c *PageCollections) clearResourceCacheForPage(page *Page) {
-	if len(page.Resources) > 0 {
-		page.s.ResourceSpec.DeleteCacheByPrefix(page.relTargetPathBase)
-	}
-}
--- a/hugolib/page_collections_test.go
+++ /dev/null
@@ -1,242 +1,0 @@
-// Copyright 2017 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 hugolib
-
-import (
-	"fmt"
-	"math/rand"
-	"path"
-	"path/filepath"
-	"testing"
-	"time"
-
-	"github.com/gohugoio/hugo/deps"
-	"github.com/stretchr/testify/require"
-)
-
-const pageCollectionsPageTemplate = `---
-title: "%s"
-categories:
-- Hugo
----
-# Doc
-`
-
-func BenchmarkGetPage(b *testing.B) {
-	var (
-		cfg, fs = newTestCfg()
-		r       = rand.New(rand.NewSource(time.Now().UnixNano()))
-	)
-
-	for i := 0; i < 10; i++ {
-		for j := 0; j < 100; j++ {
-			writeSource(b, fs, filepath.Join("content", fmt.Sprintf("sect%d", i), fmt.Sprintf("page%d.md", j)), "CONTENT")
-		}
-	}
-
-	s := buildSingleSite(b, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true})
-
-	pagePaths := make([]string, b.N)
-
-	for i := 0; i < b.N; i++ {
-		pagePaths[i] = fmt.Sprintf("sect%d", r.Intn(10))
-	}
-
-	b.ResetTimer()
-	for i := 0; i < b.N; i++ {
-		home, _ := s.getPageNew(nil, "/")
-		if home == nil {
-			b.Fatal("Home is nil")
-		}
-
-		p, _ := s.getPageNew(nil, pagePaths[i])
-		if p == nil {
-			b.Fatal("Section is nil")
-		}
-
-	}
-}
-
-func BenchmarkGetPageRegular(b *testing.B) {
-	var (
-		cfg, fs = newTestCfg()
-		r       = rand.New(rand.NewSource(time.Now().UnixNano()))
-	)
-
-	for i := 0; i < 10; i++ {
-		for j := 0; j < 100; j++ {
-			content := fmt.Sprintf(pageCollectionsPageTemplate, fmt.Sprintf("Title%d_%d", i, j))
-			writeSource(b, fs, filepath.Join("content", fmt.Sprintf("sect%d", i), fmt.Sprintf("page%d.md", j)), content)
-		}
-	}
-
-	s := buildSingleSite(b, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true})
-
-	pagePaths := make([]string, b.N)
-
-	for i := 0; i < b.N; i++ {
-		pagePaths[i] = path.Join(fmt.Sprintf("sect%d", r.Intn(10)), fmt.Sprintf("page%d.md", r.Intn(100)))
-	}
-
-	b.ResetTimer()
-	for i := 0; i < b.N; i++ {
-		page, _ := s.getPageNew(nil, pagePaths[i])
-		require.NotNil(b, page)
-	}
-}
-
-type testCase struct {
-	kind          string
-	context       *Page
-	path          []string
-	expectedTitle string
-}
-
-func (t *testCase) check(p *Page, err error, errorMsg string, assert *require.Assertions) {
-	switch t.kind {
-	case "Ambiguous":
-		assert.Error(err)
-		assert.Nil(p, errorMsg)
-	case "NoPage":
-		assert.NoError(err)
-		assert.Nil(p, errorMsg)
-	default:
-		assert.NoError(err, errorMsg)
-		assert.NotNil(p, errorMsg)
-		assert.Equal(t.kind, p.Kind, errorMsg)
-		assert.Equal(t.expectedTitle, p.title, errorMsg)
-	}
-}
-
-func TestGetPage(t *testing.T) {
-
-	var (
-		assert  = require.New(t)
-		cfg, fs = newTestCfg()
-	)
-
-	for i := 0; i < 10; i++ {
-		for j := 0; j < 10; j++ {
-			content := fmt.Sprintf(pageCollectionsPageTemplate, fmt.Sprintf("Title%d_%d", i, j))
-			writeSource(t, fs, filepath.Join("content", fmt.Sprintf("sect%d", i), fmt.Sprintf("page%d.md", j)), content)
-		}
-	}
-
-	content := fmt.Sprintf(pageCollectionsPageTemplate, "home page")
-	writeSource(t, fs, filepath.Join("content", "_index.md"), content)
-
-	content = fmt.Sprintf(pageCollectionsPageTemplate, "about page")
-	writeSource(t, fs, filepath.Join("content", "about.md"), content)
-
-	content = fmt.Sprintf(pageCollectionsPageTemplate, "section 3")
-	writeSource(t, fs, filepath.Join("content", "sect3", "_index.md"), content)
-
-	content = fmt.Sprintf(pageCollectionsPageTemplate, "UniqueBase")
-	writeSource(t, fs, filepath.Join("content", "sect3", "unique.md"), content)
-
-	content = fmt.Sprintf(pageCollectionsPageTemplate, "another sect7")
-	writeSource(t, fs, filepath.Join("content", "sect3", "sect7", "_index.md"), content)
-
-	content = fmt.Sprintf(pageCollectionsPageTemplate, "deep page")
-	writeSource(t, fs, filepath.Join("content", "sect3", "subsect", "deep.md"), content)
-
-	s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true})
-
-	sec3, err := s.getPageNew(nil, "/sect3")
-	assert.NoError(err, "error getting Page for /sec3")
-	assert.NotNil(sec3, "failed to get Page for /sec3")
-
-	tests := []testCase{
-		// legacy content root relative paths
-		{KindHome, nil, []string{}, "home page"},
-		{KindPage, nil, []string{"about.md"}, "about page"},
-		{KindSection, nil, []string{"sect3"}, "section 3"},
-		{KindPage, nil, []string{"sect3/page1.md"}, "Title3_1"},
-		{KindPage, nil, []string{"sect4/page2.md"}, "Title4_2"},
-		{KindSection, nil, []string{"sect3/sect7"}, "another sect7"},
-		{KindPage, nil, []string{"sect3/subsect/deep.md"}, "deep page"},
-		{KindPage, nil, []string{filepath.FromSlash("sect5/page3.md")}, "Title5_3"}, //test OS-specific path
-
-		// shorthand refs (potentially ambiguous)
-		{KindPage, nil, []string{"unique.md"}, "UniqueBase"},
-		{"Ambiguous", nil, []string{"page1.md"}, ""},
-
-		// ISSUE: This is an ambiguous ref, but because we have to support the legacy
-		// content root relative paths without a leading slash, the lookup
-		// returns /sect7. This undermines ambiguity detection, but we have no choice.
-		//{"Ambiguous", nil, []string{"sect7"}, ""},
-		{KindSection, nil, []string{"sect7"}, "Sect7s"},
-
-		// absolute paths
-		{KindHome, nil, []string{"/"}, "home page"},
-		{KindPage, nil, []string{"/about.md"}, "about page"},
-		{KindSection, nil, []string{"/sect3"}, "section 3"},
-		{KindPage, nil, []string{"/sect3/page1.md"}, "Title3_1"},
-		{KindPage, nil, []string{"/sect4/page2.md"}, "Title4_2"},
-		{KindSection, nil, []string{"/sect3/sect7"}, "another sect7"},
-		{KindPage, nil, []string{"/sect3/subsect/deep.md"}, "deep page"},
-		{KindPage, nil, []string{filepath.FromSlash("/sect5/page3.md")}, "Title5_3"}, //test OS-specific path
-		{KindPage, nil, []string{"/sect3/unique.md"}, "UniqueBase"},                  //next test depends on this page existing
-		// {"NoPage", nil, []string{"/unique.md"}, ""},  // ISSUE #4969: this is resolving to /sect3/unique.md
-		{"NoPage", nil, []string{"/missing-page.md"}, ""},
-		{"NoPage", nil, []string{"/missing-section"}, ""},
-
-		// relative paths
-		{KindHome, sec3, []string{".."}, "home page"},
-		{KindHome, sec3, []string{"../"}, "home page"},
-		{KindPage, sec3, []string{"../about.md"}, "about page"},
-		{KindSection, sec3, []string{"."}, "section 3"},
-		{KindSection, sec3, []string{"./"}, "section 3"},
-		{KindPage, sec3, []string{"page1.md"}, "Title3_1"},
-		{KindPage, sec3, []string{"./page1.md"}, "Title3_1"},
-		{KindPage, sec3, []string{"../sect4/page2.md"}, "Title4_2"},
-		{KindSection, sec3, []string{"sect7"}, "another sect7"},
-		{KindSection, sec3, []string{"./sect7"}, "another sect7"},
-		{KindPage, sec3, []string{"./subsect/deep.md"}, "deep page"},
-		{KindPage, sec3, []string{"./subsect/../../sect7/page9.md"}, "Title7_9"},
-		{KindPage, sec3, []string{filepath.FromSlash("../sect5/page3.md")}, "Title5_3"}, //test OS-specific path
-		{KindPage, sec3, []string{"./unique.md"}, "UniqueBase"},
-		{"NoPage", sec3, []string{"./sect2"}, ""},
-		//{"NoPage", sec3, []string{"sect2"}, ""}, // ISSUE: /sect3 page relative query is resolving to /sect2
-
-		// absolute paths ignore context
-		{KindHome, sec3, []string{"/"}, "home page"},
-		{KindPage, sec3, []string{"/about.md"}, "about page"},
-		{KindPage, sec3, []string{"/sect4/page2.md"}, "Title4_2"},
-		{KindPage, sec3, []string{"/sect3/subsect/deep.md"}, "deep page"}, //next test depends on this page existing
-		{"NoPage", sec3, []string{"/subsect/deep.md"}, ""},
-	}
-
-	for _, test := range tests {
-		errorMsg := fmt.Sprintf("Test case %s %v -> %s", test.context, test.path, test.expectedTitle)
-
-		// test legacy public Site.GetPage (which does not support page context relative queries)
-		if test.context == nil {
-			args := append([]string{test.kind}, test.path...)
-			page, err := s.Info.GetPage(args...)
-			test.check(page, err, errorMsg, assert)
-		}
-
-		// test new internal Site.getPageNew
-		var ref string
-		if len(test.path) == 1 {
-			ref = filepath.ToSlash(test.path[0])
-		} else {
-			ref = path.Join(test.path...)
-		}
-		page2, err := s.getPageNew(test.context, ref)
-		test.check(page2, err, errorMsg, assert)
-	}
-
-}
--- /dev/null
+++ b/hugolib/pagebundler.go
@@ -1,0 +1,209 @@
+// Copyright 2017-present 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 hugolib
+
+import (
+	"context"
+	"fmt"
+	"math"
+	"runtime"
+
+	_errors "github.com/pkg/errors"
+
+	"golang.org/x/sync/errgroup"
+)
+
+type siteContentProcessor struct {
+	site *Site
+
+	handleContent contentHandler
+
+	ctx context.Context
+
+	// The input file bundles.
+	fileBundlesChan chan *bundleDir
+
+	// The input file singles.
+	fileSinglesChan chan *fileInfo
+
+	// These assets should be just copied to destination.
+	fileAssetsChan chan []pathLangFile
+
+	numWorkers int
+
+	// The output Pages
+	pagesChan chan *Page
+
+	// Used for partial rebuilds (aka. live reload)
+	// Will signal replacement of pages in the site collection.
+	partialBuild bool
+}
+
+func (s *siteContentProcessor) processBundle(b *bundleDir) {
+	select {
+	case s.fileBundlesChan <- b:
+	case <-s.ctx.Done():
+	}
+}
+
+func (s *siteContentProcessor) processSingle(fi *fileInfo) {
+	select {
+	case s.fileSinglesChan <- fi:
+	case <-s.ctx.Done():
+	}
+}
+
+func (s *siteContentProcessor) processAssets(assets []pathLangFile) {
+	select {
+	case s.fileAssetsChan <- assets:
+	case <-s.ctx.Done():
+	}
+}
+
+func newSiteContentProcessor(ctx context.Context, partialBuild bool, s *Site) *siteContentProcessor {
+	numWorkers := 12
+	if n := runtime.NumCPU() * 3; n > numWorkers {
+		numWorkers = n
+	}
+
+	numWorkers = int(math.Ceil(float64(numWorkers) / float64(len(s.owner.Sites))))
+
+	return &siteContentProcessor{
+		ctx:             ctx,
+		partialBuild:    partialBuild,
+		site:            s,
+		handleContent:   newHandlerChain(s),
+		fileBundlesChan: make(chan *bundleDir, numWorkers),
+		fileSinglesChan: make(chan *fileInfo, numWorkers),
+		fileAssetsChan:  make(chan []pathLangFile, numWorkers),
+		numWorkers:      numWorkers,
+		pagesChan:       make(chan *Page, numWorkers),
+	}
+}
+
+func (s *siteContentProcessor) closeInput() {
+	close(s.fileSinglesChan)
+	close(s.fileBundlesChan)
+	close(s.fileAssetsChan)
+}
+
+func (s *siteContentProcessor) process(ctx context.Context) error {
+	g1, ctx := errgroup.WithContext(ctx)
+	g2, ctx := errgroup.WithContext(ctx)
+
+	// There can be only one of these per site.
+	g1.Go(func() error {
+		for p := range s.pagesChan {
+			if p.s != s.site {
+				panic(fmt.Sprintf("invalid page site: %v vs %v", p.s, s))
+			}
+
+			if s.partialBuild {
+				p.forceRender = true
+				s.site.replacePage(p)
+			} else {
+				s.site.addPage(p)
+			}
+		}
+		return nil
+	})
+
+	for i := 0; i < s.numWorkers; i++ {
+		g2.Go(func() error {
+			for {
+				select {
+				case f, ok := <-s.fileSinglesChan:
+					if !ok {
+						return nil
+					}
+					err := s.readAndConvertContentFile(f)
+					if err != nil {
+						return err
+					}
+				case <-ctx.Done():
+					return ctx.Err()
+				}
+			}
+		})
+
+		g2.Go(func() error {
+			for {
+				select {
+				case files, ok := <-s.fileAssetsChan:
+					if !ok {
+						return nil
+					}
+					for _, file := range files {
+						f, err := s.site.BaseFs.Content.Fs.Open(file.Filename())
+						if err != nil {
+							return _errors.Wrap(err, "failed to open assets file")
+						}
+						err = s.site.publish(&s.site.PathSpec.ProcessingStats.Files, file.Path(), f)
+						f.Close()
+						if err != nil {
+							return err
+						}
+					}
+
+				case <-ctx.Done():
+					return ctx.Err()
+				}
+			}
+		})
+
+		g2.Go(func() error {
+			for {
+				select {
+				case bundle, ok := <-s.fileBundlesChan:
+					if !ok {
+						return nil
+					}
+					err := s.readAndConvertContentBundle(bundle)
+					if err != nil {
+						return err
+					}
+				case <-ctx.Done():
+					return ctx.Err()
+				}
+			}
+		})
+	}
+
+	err := g2.Wait()
+
+	close(s.pagesChan)
+
+	if err != nil {
+		return err
+	}
+
+	if err := g1.Wait(); err != nil {
+		return err
+	}
+
+	s.site.rawAllPages.sort()
+
+	return nil
+
+}
+
+func (s *siteContentProcessor) readAndConvertContentFile(file *fileInfo) error {
+	ctx := &handlerContext{source: file, pages: s.pagesChan}
+	return s.handleContent(ctx).err
+}
+
+func (s *siteContentProcessor) readAndConvertContentBundle(bundle *bundleDir) error {
+	ctx := &handlerContext{bundle: bundle, pages: s.pagesChan}
+	return s.handleContent(ctx).err
+}
--- /dev/null
+++ b/hugolib/pagebundler_capture.go
@@ -1,0 +1,775 @@
+// Copyright 2017-present 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 hugolib
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"path"
+	"path/filepath"
+	"runtime"
+
+	"github.com/gohugoio/hugo/common/loggers"
+	_errors "github.com/pkg/errors"
+
+	"sort"
+	"strings"
+	"sync"
+
+	"github.com/spf13/afero"
+
+	"github.com/gohugoio/hugo/hugofs"
+
+	"github.com/gohugoio/hugo/helpers"
+
+	"golang.org/x/sync/errgroup"
+
+	"github.com/gohugoio/hugo/source"
+)
+
+var errSkipCyclicDir = errors.New("skip potential cyclic dir")
+
+type capturer struct {
+	// To prevent symbolic link cycles: Visit same folder only once.
+	seen   map[string]bool
+	seenMu sync.Mutex
+
+	handler captureResultHandler
+
+	sourceSpec *source.SourceSpec
+	fs         afero.Fs
+	logger     *loggers.Logger
+
+	// Filenames limits the content to process to a list of filenames/directories.
+	// This is used for partial building in server mode.
+	filenames []string
+
+	// Used to determine how to handle content changes in server mode.
+	contentChanges *contentChangeMap
+
+	// Semaphore used to throttle the concurrent sub directory handling.
+	sem chan bool
+}
+
+func newCapturer(
+	logger *loggers.Logger,
+	sourceSpec *source.SourceSpec,
+	handler captureResultHandler,
+	contentChanges *contentChangeMap,
+	filenames ...string) *capturer {
+
+	numWorkers := 4
+	if n := runtime.NumCPU(); n > numWorkers {
+		numWorkers = n
+	}
+
+	// TODO(bep) the "index" vs "_index" check/strings should be moved in one place.
+	isBundleHeader := func(filename string) bool {
+		base := filepath.Base(filename)
+		name := helpers.Filename(base)
+		return IsContentFile(base) && (name == "index" || name == "_index")
+	}
+
+	// Make sure that any bundle header files are processed before the others. This makes
+	// sure that any bundle head is processed before its resources.
+	sort.Slice(filenames, func(i, j int) bool {
+		a, b := filenames[i], filenames[j]
+		ac, bc := isBundleHeader(a), isBundleHeader(b)
+
+		if ac {
+			return true
+		}
+
+		if bc {
+			return false
+		}
+
+		return a < b
+	})
+
+	c := &capturer{
+		sem:            make(chan bool, numWorkers),
+		handler:        handler,
+		sourceSpec:     sourceSpec,
+		fs:             sourceSpec.SourceFs,
+		logger:         logger,
+		contentChanges: contentChanges,
+		seen:           make(map[string]bool),
+		filenames:      filenames}
+
+	return c
+}
+
+// Captured files and bundles ready to be processed will be passed on to
+// these channels.
+type captureResultHandler interface {
+	handleSingles(fis ...*fileInfo)
+	handleCopyFiles(fis ...pathLangFile)
+	captureBundlesHandler
+}
+
+type captureBundlesHandler interface {
+	handleBundles(b *bundleDirs)
+}
+
+type captureResultHandlerChain struct {
+	handlers []captureBundlesHandler
+}
+
+func (c *captureResultHandlerChain) handleSingles(fis ...*fileInfo) {
+	for _, h := range c.handlers {
+		if hh, ok := h.(captureResultHandler); ok {
+			hh.handleSingles(fis...)
+		}
+	}
+}
+func (c *captureResultHandlerChain) handleBundles(b *bundleDirs) {
+	for _, h := range c.handlers {
+		h.handleBundles(b)
+	}
+}
+
+func (c *captureResultHandlerChain) handleCopyFiles(files ...pathLangFile) {
+	for _, h := range c.handlers {
+		if hh, ok := h.(captureResultHandler); ok {
+			hh.handleCopyFiles(files...)
+		}
+	}
+}
+
+func (c *capturer) capturePartial(filenames ...string) error {
+	handled := make(map[string]bool)
+
+	for _, filename := range filenames {
+		dir, resolvedFilename, tp := c.contentChanges.resolveAndRemove(filename)
+		if handled[resolvedFilename] {
+			continue
+		}
+
+		handled[resolvedFilename] = true
+
+		switch tp {
+		case bundleLeaf:
+			if err := c.handleDir(resolvedFilename); err != nil {
+				// Directory may have been deleted.
+				if !os.IsNotExist(err) {
+					return err
+				}
+			}
+		case bundleBranch:
+			if err := c.handleBranchDir(resolvedFilename); err != nil {
+				// Directory may have been deleted.
+				if !os.IsNotExist(err) {
+					return err
+				}
+			}
+		default:
+			fi, err := c.resolveRealPath(resolvedFilename)
+			if os.IsNotExist(err) {
+				// File has been deleted.
+				continue
+			}
+
+			// Just in case the owning dir is a new symlink -- this will
+			// create the proper mapping for it.
+			c.resolveRealPath(dir)
+
+			f, active := c.newFileInfo(fi, tp)
+			if active {
+				c.copyOrHandleSingle(f)
+			}
+		}
+	}
+
+	return nil
+}
+
+func (c *capturer) capture() error {
+	if len(c.filenames) > 0 {
+		return c.capturePartial(c.filenames...)
+	}
+
+	err := c.handleDir(helpers.FilePathSeparator)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (c *capturer) handleNestedDir(dirname string) error {
+	select {
+	case c.sem <- true:
+		var g errgroup.Group
+
+		g.Go(func() error {
+			defer func() {
+				<-c.sem
+			}()
+			return c.handleDir(dirname)
+		})
+		return g.Wait()
+	default:
+		// For deeply nested file trees, waiting for a semaphore wil deadlock.
+		return c.handleDir(dirname)
+	}
+}
+
+// This handles a bundle branch and its resources only. This is used
+// in server mode on changes. If this dir does not (anymore) represent a bundle
+// branch, the handling is upgraded to the full handleDir method.
+func (c *capturer) handleBranchDir(dirname string) error {
+	files, err := c.readDir(dirname)
+	if err != nil {
+
+		return err
+	}
+
+	var (
+		dirType bundleDirType
+	)
+
+	for _, fi := range files {
+		if !fi.IsDir() {
+			tp, _ := classifyBundledFile(fi.RealName())
+			if dirType == bundleNot {
+				dirType = tp
+			}
+
+			if dirType == bundleLeaf {
+				return c.handleDir(dirname)
+			}
+		}
+	}
+
+	if dirType != bundleBranch {
+		return c.handleDir(dirname)
+	}
+
+	dirs := newBundleDirs(bundleBranch, c)
+
+	var secondPass []*fileInfo
+
+	// Handle potential bundle headers first.
+	for _, fi := range files {
+		if fi.IsDir() {
+			continue
+		}
+
+		tp, isContent := classifyBundledFile(fi.RealName())
+
+		f, active := c.newFileInfo(fi, tp)
+
+		if !active {
+			continue
+		}
+
+		if !f.isOwner() {
+			if !isContent {
+				// This is a partial update -- we only care about the files that
+				// is in this bundle.
+				secondPass = append(secondPass, f)
+			}
+			continue
+		}
+		dirs.addBundleHeader(f)
+	}
+
+	for _, f := range secondPass {
+		dirs.addBundleFiles(f)
+	}
+
+	c.handler.handleBundles(dirs)
+
+	return nil
+
+}
+
+func (c *capturer) handleDir(dirname string) error {
+
+	files, err := c.readDir(dirname)
+	if err != nil {
+		return err
+	}
+
+	type dirState int
+
+	const (
+		dirStateDefault dirState = iota
+
+		dirStateAssetsOnly
+		dirStateSinglesOnly
+	)
+
+	var (
+		fileBundleTypes = make([]bundleDirType, len(files))
+
+		// Start with the assumption that this dir contains only non-content assets (images etc.)
+		// If that is still true after we had a first look at the list of files, we
+		// can just copy the files to destination. We will still have to look at the
+		// sub-folders for potential bundles.
+		state = dirStateAssetsOnly
+
+		// Start with the assumption that this dir is not a bundle.
+		// A directory is a bundle if it contains a index content file,
+		// e.g. index.md (a leaf bundle) or a _index.md (a branch bundle).
+		bundleType = bundleNot
+	)
+
+	/* First check for any content files.
+	- If there are none, then this is a assets folder only (images etc.)
+	and we can just plainly copy them to
+	destination.
+	- If this is a section with no image etc. or similar, we can just handle it
+	as it was a single content file.
+	*/
+	var hasNonContent, isBranch bool
+
+	for i, fi := range files {
+		if !fi.IsDir() {
+			tp, isContent := classifyBundledFile(fi.RealName())
+
+			fileBundleTypes[i] = tp
+			if !isBranch {
+				isBranch = tp == bundleBranch
+			}
+
+			if isContent {
+				// This is not a assets-only folder.
+				state = dirStateDefault
+			} else {
+				hasNonContent = true
+			}
+		}
+	}
+
+	if isBranch && !hasNonContent {
+		// This is a section or similar with no need for any bundle handling.
+		state = dirStateSinglesOnly
+	}
+
+	if state > dirStateDefault {
+		return c.handleNonBundle(dirname, files, state == dirStateSinglesOnly)
+	}
+
+	var fileInfos = make([]*fileInfo, 0, len(files))
+
+	for i, fi := range files {
+
+		currentType := bundleNot
+
+		if !fi.IsDir() {
+			currentType = fileBundleTypes[i]
+			if bundleType == bundleNot && currentType != bundleNot {
+				bundleType = currentType
+			}
+		}
+
+		if bundleType == bundleNot && currentType != bundleNot {
+			bundleType = currentType
+		}
+
+		f, active := c.newFileInfo(fi, currentType)
+
+		if !active {
+			continue
+		}
+
+		fileInfos = append(fileInfos, f)
+	}
+
+	var todo []*fileInfo
+
+	if bundleType != bundleLeaf {
+		for _, fi := range fileInfos {
+			if fi.FileInfo().IsDir() {
+				// Handle potential nested bundles.
+				if err := c.handleNestedDir(fi.Path()); err != nil {
+					return err
+				}
+			} else if bundleType == bundleNot || (!fi.isOwner() && fi.isContentFile()) {
+				// Not in a bundle.
+				c.copyOrHandleSingle(fi)
+			} else {
+				// This is a section folder or similar with non-content files in it.
+				todo = append(todo, fi)
+			}
+		}
+	} else {
+		todo = fileInfos
+	}
+
+	if len(todo) == 0 {
+		return nil
+	}
+
+	dirs, err := c.createBundleDirs(todo, bundleType)
+	if err != nil {
+		return err
+	}
+
+	// Send the bundle to the next step in the processor chain.
+	c.handler.handleBundles(dirs)
+
+	return nil
+}
+
+func (c *capturer) handleNonBundle(
+	dirname string,
+	fileInfos pathLangFileFis,
+	singlesOnly bool) error {
+
+	for _, fi := range fileInfos {
+		if fi.IsDir() {
+			if err := c.handleNestedDir(fi.Filename()); err != nil {
+				return err
+			}
+		} else {
+			if singlesOnly {
+				f, active := c.newFileInfo(fi, bundleNot)
+				if !active {
+					continue
+				}
+				c.handler.handleSingles(f)
+			} else {
+				c.handler.handleCopyFiles(fi)
+			}
+		}
+	}
+
+	return nil
+}
+
+func (c *capturer) copyOrHandleSingle(fi *fileInfo) {
+	if fi.isContentFile() {
+		c.handler.handleSingles(fi)
+	} else {
+		// These do not currently need any further processing.
+		c.handler.handleCopyFiles(fi)
+	}
+}
+
+func (c *capturer) createBundleDirs(fileInfos []*fileInfo, bundleType bundleDirType) (*bundleDirs, error) {
+	dirs := newBundleDirs(bundleType, c)
+
+	for _, fi := range fileInfos {
+		if fi.FileInfo().IsDir() {
+			var collector func(fis ...*fileInfo)
+
+			if bundleType == bundleBranch {
+				// All files in the current directory are part of this bundle.
+				// Trying to include sub folders in these bundles are filled with ambiguity.
+				collector = func(fis ...*fileInfo) {
+					for _, fi := range fis {
+						c.copyOrHandleSingle(fi)
+					}
+				}
+			} else {
+				// All nested files and directories are part of this bundle.
+				collector = func(fis ...*fileInfo) {
+					fileInfos = append(fileInfos, fis...)
+				}
+			}
+			err := c.collectFiles(fi.Path(), collector)
+			if err != nil {
+				return nil, err
+			}
+
+		} else if fi.isOwner() {
+			// There can be more than one language, so:
+			// 1. Content files must be attached to its language's bundle.
+			// 2. Other files must be attached to all languages.
+			// 3. Every content file needs a bundle header.
+			dirs.addBundleHeader(fi)
+		}
+	}
+
+	for _, fi := range fileInfos {
+		if fi.FileInfo().IsDir() || fi.isOwner() {
+			continue
+		}
+
+		if fi.isContentFile() {
+			if bundleType != bundleBranch {
+				dirs.addBundleContentFile(fi)
+			}
+		} else {
+			dirs.addBundleFiles(fi)
+		}
+	}
+
+	return dirs, nil
+}
+
+func (c *capturer) collectFiles(dirname string, handleFiles func(fis ...*fileInfo)) error {
+
+	filesInDir, err := c.readDir(dirname)
+	if err != nil {
+		return err
+	}
+
+	for _, fi := range filesInDir {
+		if fi.IsDir() {
+			err := c.collectFiles(fi.Filename(), handleFiles)
+			if err != nil {
+				return err
+			}
+		} else {
+			f, active := c.newFileInfo(fi, bundleNot)
+			if active {
+				handleFiles(f)
+			}
+		}
+	}
+
+	return nil
+}
+
+func (c *capturer) readDir(dirname string) (pathLangFileFis, error) {
+	if c.sourceSpec.IgnoreFile(dirname) {
+		return nil, nil
+	}
+
+	dir, err := c.fs.Open(dirname)
+	if err != nil {
+		return nil, err
+	}
+	defer dir.Close()
+	fis, err := dir.Readdir(-1)
+	if err != nil {
+		return nil, err
+	}
+
+	pfis := make(pathLangFileFis, 0, len(fis))
+
+	for _, fi := range fis {
+		fip := fi.(pathLangFileFi)
+
+		if !c.sourceSpec.IgnoreFile(fip.Filename()) {
+
+			err := c.resolveRealPathIn(fip)
+
+			if err != nil {
+				// It may have been deleted in the meantime.
+				if err == errSkipCyclicDir || os.IsNotExist(err) {
+					continue
+				}
+				return nil, err
+			}
+
+			pfis = append(pfis, fip)
+		}
+	}
+
+	return pfis, nil
+}
+
+func (c *capturer) newFileInfo(fi pathLangFileFi, tp bundleDirType) (*fileInfo, bool) {
+	f := newFileInfo(c.sourceSpec, "", "", fi, tp)
+	return f, !f.disabled
+}
+
+type pathLangFile interface {
+	hugofs.LanguageAnnouncer
+	hugofs.FilePather
+}
+
+type pathLangFileFi interface {
+	os.FileInfo
+	pathLangFile
+}
+
+type pathLangFileFis []pathLangFileFi
+
+type bundleDirs struct {
+	tp bundleDirType
+	// Maps languages to bundles.
+	bundles map[string]*bundleDir
+
+	// Keeps track of language overrides for non-content files, e.g. logo.en.png.
+	langOverrides map[string]bool
+
+	c *capturer
+}
+
+func newBundleDirs(tp bundleDirType, c *capturer) *bundleDirs {
+	return &bundleDirs{tp: tp, bundles: make(map[string]*bundleDir), langOverrides: make(map[string]bool), c: c}
+}
+
+type bundleDir struct {
+	tp bundleDirType
+	fi *fileInfo
+
+	resources map[string]*fileInfo
+}
+
+func (b bundleDir) clone() *bundleDir {
+	b.resources = make(map[string]*fileInfo)
+	fic := *b.fi
+	b.fi = &fic
+	return &b
+}
+
+func newBundleDir(fi *fileInfo, bundleType bundleDirType) *bundleDir {
+	return &bundleDir{fi: fi, tp: bundleType, resources: make(map[string]*fileInfo)}
+}
+
+func (b *bundleDirs) addBundleContentFile(fi *fileInfo) {
+	dir, found := b.bundles[fi.Lang()]
+	if !found {
+		// Every bundled content file needs a bundle header.
+		// If one does not exist in its language, we pick the default
+		// language version, or a random one if that doesn't exist, either.
+		tl := b.c.sourceSpec.DefaultContentLanguage
+		ldir, found := b.bundles[tl]
+		if !found {
+			// Just pick one.
+			for _, v := range b.bundles {
+				ldir = v
+				break
+			}
+		}
+
+		if ldir == nil {
+			panic(fmt.Sprintf("bundle not found for file %q", fi.Filename()))
+		}
+
+		dir = ldir.clone()
+		dir.fi.overriddenLang = fi.Lang()
+		b.bundles[fi.Lang()] = dir
+	}
+
+	dir.resources[fi.Path()] = fi
+}
+
+func (b *bundleDirs) addBundleFiles(fi *fileInfo) {
+	dir := filepath.ToSlash(fi.Dir())
+	p := dir + fi.TranslationBaseName() + "." + fi.Ext()
+	for lang, bdir := range b.bundles {
+		key := path.Join(lang, p)
+
+		// Given mypage.de.md (German translation) and mypage.md we pick the most
+		// specific for that language.
+		if fi.Lang() == lang || !b.langOverrides[key] {
+			bdir.resources[key] = fi
+		}
+		b.langOverrides[key] = true
+	}
+}
+
+func (b *bundleDirs) addBundleHeader(fi *fileInfo) {
+	b.bundles[fi.Lang()] = newBundleDir(fi, b.tp)
+}
+
+func (c *capturer) isSeen(dirname string) bool {
+	c.seenMu.Lock()
+	defer c.seenMu.Unlock()
+	seen := c.seen[dirname]
+	c.seen[dirname] = true
+	if seen {
+		c.logger.WARN.Printf("Content dir %q already processed; skipped to avoid infinite recursion.", dirname)
+		return true
+
+	}
+	return false
+}
+
+func (c *capturer) resolveRealPath(path string) (pathLangFileFi, error) {
+	fileInfo, err := c.lstatIfPossible(path)
+	if err != nil {
+		return nil, err
+	}
+	return fileInfo, c.resolveRealPathIn(fileInfo)
+}
+
+func (c *capturer) resolveRealPathIn(fileInfo pathLangFileFi) error {
+
+	basePath := fileInfo.BaseDir()
+	path := fileInfo.Filename()
+
+	realPath := path
+
+	if fileInfo.Mode()&os.ModeSymlink == os.ModeSymlink {
+		link, err := filepath.EvalSymlinks(path)
+		if err != nil {
+			return _errors.Wrapf(err, "Cannot read symbolic link %q, error was:", path)
+		}
+
+		// This is a file on the outside of any base fs, so we have to use the os package.
+		sfi, err := os.Stat(link)
+		if err != nil {
+			return _errors.Wrapf(err, "Cannot stat  %q, error was:", link)
+		}
+
+		// TODO(bep) improve all of this.
+		if a, ok := fileInfo.(*hugofs.LanguageFileInfo); ok {
+			a.FileInfo = sfi
+		}
+
+		realPath = link
+
+		if realPath != path && sfi.IsDir() && c.isSeen(realPath) {
+			// Avoid cyclic symlinks.
+			// Note that this may prevent some uses that isn't cyclic and also
+			// potential useful, but this implementation is both robust and simple:
+			// We stop at the first directory that we have seen before, e.g.
+			// /content/blog will only be processed once.
+			return errSkipCyclicDir
+		}
+
+		if c.contentChanges != nil {
+			// Keep track of symbolic links in watch mode.
+			var from, to string
+			if sfi.IsDir() {
+				from = realPath
+				to = path
+
+				if !strings.HasSuffix(to, helpers.FilePathSeparator) {
+					to = to + helpers.FilePathSeparator
+				}
+				if !strings.HasSuffix(from, helpers.FilePathSeparator) {
+					from = from + helpers.FilePathSeparator
+				}
+
+				if !strings.HasSuffix(basePath, helpers.FilePathSeparator) {
+					basePath = basePath + helpers.FilePathSeparator
+				}
+
+				if strings.HasPrefix(from, basePath) {
+					// With symbolic links inside /content we need to keep
+					// a reference to both. This may be confusing with --navigateToChanged
+					// but the user has chosen this him or herself.
+					c.contentChanges.addSymbolicLinkMapping(from, from)
+				}
+
+			} else {
+				from = realPath
+				to = path
+			}
+
+			c.contentChanges.addSymbolicLinkMapping(from, to)
+		}
+	}
+
+	return nil
+}
+
+func (c *capturer) lstatIfPossible(path string) (pathLangFileFi, error) {
+	fi, err := helpers.LstatIfPossible(c.fs, path)
+	if err != nil {
+		return nil, err
+	}
+	return fi.(pathLangFileFi), nil
+}
--- /dev/null
+++ b/hugolib/pagebundler_capture_test.go
@@ -1,0 +1,274 @@
+// Copyright 2017-present 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 hugolib
+
+import (
+	"fmt"
+	"os"
+	"path"
+	"path/filepath"
+	"sort"
+
+	"github.com/gohugoio/hugo/common/loggers"
+
+	"runtime"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/gohugoio/hugo/helpers"
+	"github.com/gohugoio/hugo/source"
+	"github.com/stretchr/testify/require"
+)
+
+type storeFilenames struct {
+	sync.Mutex
+	filenames []string
+	copyNames []string
+	dirKeys   []string
+}
+
+func (s *storeFilenames) handleSingles(fis ...*fileInfo) {
+	s.Lock()
+	defer s.Unlock()
+	for _, fi := range fis {
+		s.filenames = append(s.filenames, filepath.ToSlash(fi.Filename()))
+	}
+}
+
+func (s *storeFilenames) handleBundles(d *bundleDirs) {
+	s.Lock()
+	defer s.Unlock()
+	var keys []string
+	for _, b := range d.bundles {
+		res := make([]string, len(b.resources))
+		i := 0
+		for _, r := range b.resources {
+			res[i] = path.Join(r.Lang(), filepath.ToSlash(r.Filename()))
+			i++
+		}
+		sort.Strings(res)
+		keys = append(keys, path.Join("__bundle", b.fi.Lang(), filepath.ToSlash(b.fi.Filename()), "resources", strings.Join(res, "|")))
+	}
+	s.dirKeys = append(s.dirKeys, keys...)
+}
+
+func (s *storeFilenames) handleCopyFiles(files ...pathLangFile) {
+	s.Lock()
+	defer s.Unlock()
+	for _, file := range files {
+		s.copyNames = append(s.copyNames, filepath.ToSlash(file.Filename()))
+	}
+}
+
+func (s *storeFilenames) sortedStr() string {
+	s.Lock()
+	defer s.Unlock()
+	sort.Strings(s.filenames)
+	sort.Strings(s.dirKeys)
+	sort.Strings(s.copyNames)
+	return "\nF:\n" + strings.Join(s.filenames, "\n") + "\nD:\n" + strings.Join(s.dirKeys, "\n") +
+		"\nC:\n" + strings.Join(s.copyNames, "\n") + "\n"
+}
+
+func TestPageBundlerCaptureSymlinks(t *testing.T) {
+	if runtime.GOOS == "windows" && os.Getenv("CI") == "" {
+		t.Skip("Skip TestPageBundlerCaptureSymlinks as os.Symlink needs administrator rights on Windows")
+	}
+
+	assert := require.New(t)
+	ps, clean, workDir := newTestBundleSymbolicSources(t)
+	sourceSpec := source.NewSourceSpec(ps, ps.BaseFs.Content.Fs)
+	defer clean()
+
+	fileStore := &storeFilenames{}
+	logger := loggers.NewErrorLogger()
+	c := newCapturer(logger, sourceSpec, fileStore, nil)
+
+	assert.NoError(c.capture())
+
+	expected := `
+F:
+/base/a/page_s.md
+/base/a/regular.md
+/base/symbolic1/s1.md
+/base/symbolic1/s2.md
+/base/symbolic3/circus/a/page_s.md
+/base/symbolic3/circus/a/regular.md
+D:
+__bundle/en/base/symbolic2/a1/index.md/resources/en/base/symbolic2/a1/logo.png|en/base/symbolic2/a1/page.md
+C:
+/base/symbolic3/s1.png
+/base/symbolic3/s2.png
+`
+
+	got := strings.Replace(fileStore.sortedStr(), filepath.ToSlash(workDir), "", -1)
+	got = strings.Replace(got, "//", "/", -1)
+
+	if expected != got {
+		diff := helpers.DiffStringSlices(strings.Fields(expected), strings.Fields(got))
+		t.Log(got)
+		t.Fatalf("Failed:\n%s", diff)
+	}
+}
+
+func TestPageBundlerCaptureBasic(t *testing.T) {
+	t.Parallel()
+
+	assert := require.New(t)
+	fs, cfg := newTestBundleSources(t)
+	assert.NoError(loadDefaultSettingsFor(cfg))
+	assert.NoError(loadLanguageSettings(cfg, nil))
+	ps, err := helpers.NewPathSpec(fs, cfg)
+	assert.NoError(err)
+
+	sourceSpec := source.NewSourceSpec(ps, ps.BaseFs.Content.Fs)
+
+	fileStore := &storeFilenames{}
+
+	c := newCapturer(loggers.NewErrorLogger(), sourceSpec, fileStore, nil)
+
+	assert.NoError(c.capture())
+
+	expected := `
+F:
+/work/base/_1.md
+/work/base/a/1.md
+/work/base/a/2.md
+/work/base/assets/pages/mypage.md
+D:
+__bundle/en/work/base/_index.md/resources/en/work/base/_1.png
+__bundle/en/work/base/a/b/index.md/resources/en/work/base/a/b/ab1.md
+__bundle/en/work/base/b/my-bundle/index.md/resources/en/work/base/b/my-bundle/1.md|en/work/base/b/my-bundle/2.md|en/work/base/b/my-bundle/c/logo.png|en/work/base/b/my-bundle/custom-mime.bep|en/work/base/b/my-bundle/sunset1.jpg|en/work/base/b/my-bundle/sunset2.jpg
+__bundle/en/work/base/c/bundle/index.md/resources/en/work/base/c/bundle/logo-은행.png
+__bundle/en/work/base/root/index.md/resources/en/work/base/root/1.md|en/work/base/root/c/logo.png
+C:
+/work/base/assets/pic1.png
+/work/base/assets/pic2.png
+/work/base/images/hugo-logo.png
+`
+
+	got := fileStore.sortedStr()
+
+	if expected != got {
+		diff := helpers.DiffStringSlices(strings.Fields(expected), strings.Fields(got))
+		t.Log(got)
+		t.Fatalf("Failed:\n%s", diff)
+	}
+}
+
+func TestPageBundlerCaptureMultilingual(t *testing.T) {
+	t.Parallel()
+
+	assert := require.New(t)
+	fs, cfg := newTestBundleSourcesMultilingual(t)
+	assert.NoError(loadDefaultSettingsFor(cfg))
+	assert.NoError(loadLanguageSettings(cfg, nil))
+
+	ps, err := helpers.NewPathSpec(fs, cfg)
+	assert.NoError(err)
+
+	sourceSpec := source.NewSourceSpec(ps, ps.BaseFs.Content.Fs)
+	fileStore := &storeFilenames{}
+	c := newCapturer(loggers.NewErrorLogger(), sourceSpec, fileStore, nil)
+
+	assert.NoError(c.capture())
+
+	expected := `
+F:
+/work/base/1s/mypage.md
+/work/base/1s/mypage.nn.md
+/work/base/bb/_1.md
+/work/base/bb/_1.nn.md
+/work/base/bb/en.md
+/work/base/bc/page.md
+/work/base/bc/page.nn.md
+/work/base/be/_index.md
+/work/base/be/page.md
+/work/base/be/page.nn.md
+D:
+__bundle/en/work/base/bb/_index.md/resources/en/work/base/bb/a.png|en/work/base/bb/b.png|nn/work/base/bb/c.nn.png
+__bundle/en/work/base/bc/_index.md/resources/en/work/base/bc/logo-bc.png
+__bundle/en/work/base/bd/index.md/resources/en/work/base/bd/page.md
+__bundle/en/work/base/bf/my-bf-bundle/index.md/resources/en/work/base/bf/my-bf-bundle/page.md
+__bundle/en/work/base/lb/index.md/resources/en/work/base/lb/1.md|en/work/base/lb/2.md|en/work/base/lb/c/d/deep.png|en/work/base/lb/c/logo.png|en/work/base/lb/c/one.png|en/work/base/lb/c/page.md
+__bundle/nn/work/base/bb/_index.nn.md/resources/en/work/base/bb/a.png|nn/work/base/bb/b.nn.png|nn/work/base/bb/c.nn.png
+__bundle/nn/work/base/bd/index.md/resources/nn/work/base/bd/page.nn.md
+__bundle/nn/work/base/bf/my-bf-bundle/index.nn.md/resources
+__bundle/nn/work/base/lb/index.nn.md/resources/en/work/base/lb/c/d/deep.png|en/work/base/lb/c/one.png|nn/work/base/lb/2.nn.md|nn/work/base/lb/c/logo.nn.png
+C:
+/work/base/1s/mylogo.png
+/work/base/bb/b/d.nn.png
+`
+
+	got := fileStore.sortedStr()
+
+	if expected != got {
+		diff := helpers.DiffStringSlices(strings.Fields(expected), strings.Fields(got))
+		t.Log(got)
+		t.Fatalf("Failed:\n%s", strings.Join(diff, "\n"))
+	}
+
+}
+
+type noOpFileStore int
+
+func (noOpFileStore) handleSingles(fis ...*fileInfo)        {}
+func (noOpFileStore) handleBundles(b *bundleDirs)           {}
+func (noOpFileStore) handleCopyFiles(files ...pathLangFile) {}
+
+func BenchmarkPageBundlerCapture(b *testing.B) {
+	capturers := make([]*capturer, b.N)
+
+	for i := 0; i < b.N; i++ {
+		cfg, fs := newTestCfg()
+		ps, _ := helpers.NewPathSpec(fs, cfg)
+		sourceSpec := source.NewSourceSpec(ps, fs.Source)
+
+		base := fmt.Sprintf("base%d", i)
+		for j := 1; j <= 5; j++ {
+			js := fmt.Sprintf("j%d", j)
+			writeSource(b, fs, filepath.Join(base, js, "index.md"), "content")
+			writeSource(b, fs, filepath.Join(base, js, "logo1.png"), "content")
+			writeSource(b, fs, filepath.Join(base, js, "sub", "logo2.png"), "content")
+			writeSource(b, fs, filepath.Join(base, js, "section", "_index.md"), "content")
+			writeSource(b, fs, filepath.Join(base, js, "section", "logo.png"), "content")
+			writeSource(b, fs, filepath.Join(base, js, "section", "sub", "logo.png"), "content")
+
+			for k := 1; k <= 5; k++ {
+				ks := fmt.Sprintf("k%d", k)
+				writeSource(b, fs, filepath.Join(base, js, ks, "logo1.png"), "content")
+				writeSource(b, fs, filepath.Join(base, js, "section", ks, "logo.png"), "content")
+			}
+		}
+
+		for i := 1; i <= 5; i++ {
+			writeSource(b, fs, filepath.Join(base, "assetsonly", fmt.Sprintf("image%d.png", i)), "image")
+		}
+
+		for i := 1; i <= 5; i++ {
+			writeSource(b, fs, filepath.Join(base, "contentonly", fmt.Sprintf("c%d.md", i)), "content")
+		}
+
+		capturers[i] = newCapturer(loggers.NewErrorLogger(), sourceSpec, new(noOpFileStore), nil, base)
+	}
+
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		err := capturers[i].capture()
+		if err != nil {
+			b.Fatal(err)
+		}
+	}
+}
--- /dev/null
+++ b/hugolib/pagebundler_handlers.go
@@ -1,0 +1,345 @@
+// Copyright 2017-present 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 hugolib
+
+import (
+	"errors"
+	"fmt"
+	"path/filepath"
+	"sort"
+
+	"strings"
+
+	"github.com/gohugoio/hugo/helpers"
+	"github.com/gohugoio/hugo/resource"
+)
+
+var (
+	// This should be the only list of valid extensions for content files.
+	contentFileExtensions = []string{
+		"html", "htm",
+		"mdown", "markdown", "md",
+		"asciidoc", "adoc", "ad",
+		"rest", "rst",
+		"mmark",
+		"org",
+		"pandoc", "pdc"}
+
+	contentFileExtensionsSet map[string]bool
+)
+
+func init() {
+	contentFileExtensionsSet = make(map[string]bool)
+	for _, ext := range contentFileExtensions {
+		contentFileExtensionsSet[ext] = true
+	}
+}
+
+func newHandlerChain(s *Site) contentHandler {
+	c := &contentHandlers{s: s}
+
+	contentFlow := c.parsePage(c.processFirstMatch(
+		// Handles all files with a content file extension. See above.
+		c.handlePageContent(),
+
+		// Every HTML file without front matter will be passed on to this handler.
+		c.handleHTMLContent(),
+	))
+
+	c.rootHandler = c.processFirstMatch(
+		contentFlow,
+
+		// Creates a file resource (image, CSS etc.) if there is a parent
+		// page set on the current context.
+		c.createResource(),
+
+		// Everything that isn't handled above, will just be copied
+		// to destination.
+		c.copyFile(),
+	)
+
+	return c.rootHandler
+
+}
+
+type contentHandlers struct {
+	s           *Site
+	rootHandler contentHandler
+}
+
+func (c *contentHandlers) processFirstMatch(handlers ...contentHandler) func(ctx *handlerContext) handlerResult {
+	return func(ctx *handlerContext) handlerResult {
+		for _, h := range handlers {
+			res := h(ctx)
+			if res.handled || res.err != nil {
+				return res
+			}
+		}
+		return handlerResult{err: errors.New("no matching handler found")}
+	}
+}
+
+type handlerContext struct {
+	// These are the pages stored in Site.
+	pages chan<- *Page
+
+	doNotAddToSiteCollections bool
+
+	currentPage *Page
+	parentPage  *Page
+
+	bundle *bundleDir
+
+	source *fileInfo
+
+	// Relative path to the target.
+	target string
+}
+
+func (c *handlerContext) ext() string {
+	if c.currentPage != nil {
+		if c.currentPage.Markup != "" {
+			return c.currentPage.Markup
+		}
+		return c.currentPage.Ext()
+	}
+
+	if c.bundle != nil {
+		return c.bundle.fi.Ext()
+	} else {
+		return c.source.Ext()
+	}
+}
+
+func (c *handlerContext) targetPath() string {
+	if c.target != "" {
+		return c.target
+	}
+
+	return c.source.Filename()
+}
+
+func (c *handlerContext) file() *fileInfo {
+	if c.bundle != nil {
+		return c.bundle.fi
+	}
+
+	return c.source
+}
+
+// Create a copy with the current context as its parent.
+func (c handlerContext) childCtx(fi *fileInfo) *handlerContext {
+	if c.currentPage == nil {
+		panic("Need a Page to create a child context")
+	}
+
+	c.target = strings.TrimPrefix(fi.Path(), c.bundle.fi.Dir())
+	c.source = fi
+
+	c.doNotAddToSiteCollections = c.bundle != nil && c.bundle.tp != bundleBranch
+
+	c.bundle = nil
+
+	c.parentPage = c.currentPage
+	c.currentPage = nil
+
+	return &c
+}
+
+func (c *handlerContext) supports(exts ...string) bool {
+	ext := c.ext()
+	for _, s := range exts {
+		if s == ext {
+			return true
+		}
+	}
+
+	return false
+}
+
+func (c *handlerContext) isContentFile() bool {
+	return contentFileExtensionsSet[c.ext()]
+}
+
+type (
+	handlerResult struct {
+		err      error
+		handled  bool
+		resource resource.Resource
+	}
+
+	contentHandler func(ctx *handlerContext) handlerResult
+)
+
+var (
+	notHandled handlerResult
+)
+
+func (c *contentHandlers) parsePage(h contentHandler) contentHandler {
+	return func(ctx *handlerContext) handlerResult {
+		if !ctx.isContentFile() {
+			return notHandled
+		}
+
+		result := handlerResult{handled: true}
+		fi := ctx.file()
+
+		f, err := fi.Open()
+		if err != nil {
+			return handlerResult{err: fmt.Errorf("(%s) failed to open content file: %s", fi.Filename(), err)}
+		}
+		defer f.Close()
+
+		p := c.s.newPageFromFile(fi)
+
+		_, err = p.ReadFrom(f)
+		if err != nil {
+			return handlerResult{err: err}
+		}
+
+		if !p.shouldBuild() {
+			if !ctx.doNotAddToSiteCollections {
+				ctx.pages <- p
+			}
+			return result
+		}
+
+		ctx.currentPage = p
+
+		if ctx.bundle != nil {
+			// Add the bundled files
+			for _, fi := range ctx.bundle.resources {
+				childCtx := ctx.childCtx(fi)
+				res := c.rootHandler(childCtx)
+				if res.err != nil {
+					return res
+				}
+				if res.resource != nil {
+					if pageResource, ok := res.resource.(*Page); ok {
+						pageResource.resourcePath = filepath.ToSlash(childCtx.target)
+						pageResource.parent = p
+					}
+					p.Resources = append(p.Resources, res.resource)
+				}
+			}
+
+			sort.SliceStable(p.Resources, func(i, j int) bool {
+				if p.Resources[i].ResourceType() < p.Resources[j].ResourceType() {
+					return true
+				}
+
+				p1, ok1 := p.Resources[i].(*Page)
+				p2, ok2 := p.Resources[j].(*Page)
+
+				if ok1 != ok2 {
+					return ok2
+				}
+
+				if ok1 {
+					return defaultPageSort(p1, p2)
+				}
+
+				return p.Resources[i].RelPermalink() < p.Resources[j].RelPermalink()
+			})
+
+			// Assign metadata from front matter if set
+			if len(p.resourcesMetadata) > 0 {
+				resource.AssignMetadata(p.resourcesMetadata, p.Resources...)
+			}
+
+		}
+
+		return h(ctx)
+	}
+}
+
+func (c *contentHandlers) handlePageContent() contentHandler {
+	return func(ctx *handlerContext) handlerResult {
+		if ctx.supports("html", "htm") {
+			return notHandled
+		}
+
+		p := ctx.currentPage
+
+		if c.s.Cfg.GetBool("enableEmoji") {
+			p.workContent = helpers.Emojify(p.workContent)
+		}
+
+		p.workContent = p.renderContent(p.workContent)
+
+		tmpContent, tmpTableOfContents := helpers.ExtractTOC(p.workContent)
+		p.TableOfContents = helpers.BytesToHTML(tmpTableOfContents)
+		p.workContent = tmpContent
+
+		if !ctx.doNotAddToSiteCollections {
+			ctx.pages <- p
+		}
+
+		return handlerResult{handled: true, resource: p}
+	}
+}
+
+func (c *contentHandlers) handleHTMLContent() contentHandler {
+	return func(ctx *handlerContext) handlerResult {
+		if !ctx.supports("html", "htm") {
+			return notHandled
+		}
+
+		p := ctx.currentPage
+
+		if !ctx.doNotAddToSiteCollections {
+			ctx.pages <- p
+		}
+
+		return handlerResult{handled: true, resource: p}
+	}
+}
+
+func (c *contentHandlers) createResource() contentHandler {
+	return func(ctx *handlerContext) handlerResult {
+		if ctx.parentPage == nil {
+			return notHandled
+		}
+
+		resource, err := c.s.ResourceSpec.New(
+			resource.ResourceSourceDescriptor{
+				TargetPathBuilder: ctx.parentPage.subResourceTargetPathFactory,
+				SourceFile:        ctx.source,
+				RelTargetFilename: ctx.target,
+				URLBase:           c.s.GetURLLanguageBasePath(),
+				TargetBasePaths:   []string{c.s.GetTargetLanguageBasePath()},
+			})
+
+		return handlerResult{err: err, handled: true, resource: resource}
+	}
+}
+
+func (c *contentHandlers) copyFile() contentHandler {
+	return func(ctx *handlerContext) handlerResult {
+		f, err := c.s.BaseFs.Content.Fs.Open(ctx.source.Filename())
+		if err != nil {
+			err := fmt.Errorf("failed to open file in copyFile: %s", err)
+			return handlerResult{err: err}
+		}
+
+		target := ctx.targetPath()
+
+		defer f.Close()
+		if err := c.s.publish(&c.s.PathSpec.ProcessingStats.Files, target, f); err != nil {
+			return handlerResult{err: err}
+		}
+
+		return handlerResult{handled: true}
+	}
+}
--- /dev/null
+++ b/hugolib/pagebundler_test.go
@@ -1,0 +1,751 @@
+// Copyright 2017-present 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 hugolib
+
+import (
+	"github.com/gohugoio/hugo/common/loggers"
+
+	"os"
+	"runtime"
+	"testing"
+
+	"github.com/gohugoio/hugo/helpers"
+
+	"io"
+
+	"github.com/spf13/afero"
+
+	"github.com/gohugoio/hugo/media"
+
+	"path/filepath"
+
+	"fmt"
+
+	"github.com/gohugoio/hugo/deps"
+	"github.com/gohugoio/hugo/hugofs"
+	"github.com/spf13/viper"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestPageBundlerSiteRegular(t *testing.T) {
+	t.Parallel()
+
+	for _, ugly := range []bool{false, true} {
+		t.Run(fmt.Sprintf("ugly=%t", ugly),
+			func(t *testing.T) {
+
+				assert := require.New(t)
+				fs, cfg := newTestBundleSources(t)
+				assert.NoError(loadDefaultSettingsFor(cfg))
+				assert.NoError(loadLanguageSettings(cfg, nil))
+
+				cfg.Set("permalinks", map[string]string{
+					"a": ":sections/:filename",
+					"b": ":year/:slug/",
+					"c": ":sections/:slug",
+					"":  ":filename/",
+				})
+
+				cfg.Set("outputFormats", map[string]interface{}{
+					"CUSTOMO": map[string]interface{}{
+						"mediaType": media.HTMLType,
+						"baseName":  "cindex",
+						"path":      "cpath",
+					},
+				})
+
+				cfg.Set("outputs", map[string]interface{}{
+					"home":    []string{"HTML", "CUSTOMO"},
+					"page":    []string{"HTML", "CUSTOMO"},
+					"section": []string{"HTML", "CUSTOMO"},
+				})
+
+				cfg.Set("uglyURLs", ugly)
+
+				s := buildSingleSite(t, deps.DepsCfg{Logger: loggers.NewWarningLogger(), Fs: fs, Cfg: cfg}, BuildCfg{})
+
+				th := testHelper{s.Cfg, s.Fs, t}
+
+				assert.Len(s.RegularPages, 8)
+
+				singlePage := s.getPage(KindPage, "a/1.md")
+				assert.Equal("", singlePage.BundleType())
+
+				assert.NotNil(singlePage)
+				assert.Equal(singlePage, s.getPage("page", "a/1"))
+				assert.Equal(singlePage, s.getPage("page", "1"))
+
+				assert.Contains(singlePage.content(), "TheContent")
+
+				if ugly {
+					assert.Equal("/a/1.html", singlePage.RelPermalink())
+					th.assertFileContent(filepath.FromSlash("/work/public/a/1.html"), "TheContent")
+
+				} else {
+					assert.Equal("/a/1/", singlePage.RelPermalink())
+					th.assertFileContent(filepath.FromSlash("/work/public/a/1/index.html"), "TheContent")
+				}
+
+				th.assertFileContent(filepath.FromSlash("/work/public/images/hugo-logo.png"), "content")
+
+				// This should be just copied to destination.
+				th.assertFileContent(filepath.FromSlash("/work/public/assets/pic1.png"), "content")
+
+				leafBundle1 := s.getPage(KindPage, "b/my-bundle/index.md")
+				assert.NotNil(leafBundle1)
+				assert.Equal("leaf", leafBundle1.BundleType())
+				assert.Equal("b", leafBundle1.Section())
+				sectionB := s.getPage(KindSection, "b")
+				assert.NotNil(sectionB)
+				home, _ := s.Info.Home()
+				assert.Equal("branch", home.BundleType())
+
+				// This is a root bundle and should live in the "home section"
+				// See https://github.com/gohugoio/hugo/issues/4332
+				rootBundle := s.getPage(KindPage, "root")
+				assert.NotNil(rootBundle)
+				assert.True(rootBundle.Parent().IsHome())
+				if ugly {
+					assert.Equal("/root.html", rootBundle.RelPermalink())
+				} else {
+					assert.Equal("/root/", rootBundle.RelPermalink())
+				}
+
+				leafBundle2 := s.getPage(KindPage, "a/b/index.md")
+				assert.NotNil(leafBundle2)
+				unicodeBundle := s.getPage(KindPage, "c/bundle/index.md")
+				assert.NotNil(unicodeBundle)
+
+				pageResources := leafBundle1.Resources.ByType(pageResourceType)
+				assert.Len(pageResources, 2)
+				firstPage := pageResources[0].(*Page)
+				secondPage := pageResources[1].(*Page)
+				assert.Equal(filepath.FromSlash("/work/base/b/my-bundle/1.md"), firstPage.pathOrTitle(), secondPage.pathOrTitle())
+				assert.Contains(firstPage.content(), "TheContent")
+				assert.Equal(6, len(leafBundle1.Resources))
+
+				// Verify shortcode in bundled page
+				assert.Contains(secondPage.content(), filepath.FromSlash("MyShort in b/my-bundle/2.md"))
+
+				// https://github.com/gohugoio/hugo/issues/4582
+				assert.Equal(leafBundle1, firstPage.Parent())
+				assert.Equal(leafBundle1, secondPage.Parent())
+
+				assert.Equal(firstPage, pageResources.GetMatch("1*"))
+				assert.Equal(secondPage, pageResources.GetMatch("2*"))
+				assert.Nil(pageResources.GetMatch("doesnotexist*"))
+
+				imageResources := leafBundle1.Resources.ByType("image")
+				assert.Equal(3, len(imageResources))
+				image := imageResources[0]
+
+				altFormat := leafBundle1.OutputFormats().Get("CUSTOMO")
+				assert.NotNil(altFormat)
+
+				assert.Equal("https://example.com/2017/pageslug/c/logo.png", image.Permalink())
+
+				th.assertFileContent(filepath.FromSlash("/work/public/2017/pageslug/c/logo.png"), "content")
+				th.assertFileContent(filepath.FromSlash("/work/public/cpath/2017/pageslug/c/logo.png"), "content")
+
+				// Custom media type defined in site config.
+				assert.Len(leafBundle1.Resources.ByType("bepsays"), 1)
+
+				if ugly {
+					assert.Equal("/2017/pageslug.html", leafBundle1.RelPermalink())
+					th.assertFileContent(filepath.FromSlash("/work/public/2017/pageslug.html"),
+						"TheContent",
+						"Sunset RelPermalink: /2017/pageslug/sunset1.jpg",
+						"Thumb Width: 123",
+						"Thumb Name: my-sunset-1",
+						"Short Sunset RelPermalink: /2017/pageslug/sunset2.jpg",
+						"Short Thumb Width: 56",
+						"1: Image Title: Sunset Galore 1",
+						"1: Image Params: map[myparam:My Sunny Param]",
+						"2: Image Title: Sunset Galore 2",
+						"2: Image Params: map[myparam:My Sunny Param]",
+						"1: Image myParam: Lower: My Sunny Param Caps: My Sunny Param",
+					)
+					th.assertFileContent(filepath.FromSlash("/work/public/cpath/2017/pageslug.html"), "TheContent")
+
+					assert.Equal("/a/b.html", leafBundle2.RelPermalink())
+
+					// 은행
+					assert.Equal("/c/%EC%9D%80%ED%96%89.html", unicodeBundle.RelPermalink())
+					th.assertFileContent(filepath.FromSlash("/work/public/c/은행.html"), "Content for 은행")
+					th.assertFileContent(filepath.FromSlash("/work/public/c/은행/logo-은행.png"), "은행 PNG")
+
+				} else {
+					assert.Equal("/2017/pageslug/", leafBundle1.RelPermalink())
+					th.assertFileContent(filepath.FromSlash("/work/public/2017/pageslug/index.html"), "TheContent")
+					th.assertFileContent(filepath.FromSlash("/work/public/cpath/2017/pageslug/cindex.html"), "TheContent")
+					th.assertFileContent(filepath.FromSlash("/work/public/2017/pageslug/index.html"), "Single Title")
+					th.assertFileContent(filepath.FromSlash("/work/public/root/index.html"), "Single Title")
+
+					assert.Equal("/a/b/", leafBundle2.RelPermalink())
+
+				}
+
+			})
+	}
+
+}
+
+func TestPageBundlerSiteMultilingual(t *testing.T) {
+	t.Parallel()
+
+	for _, ugly := range []bool{false, true} {
+		t.Run(fmt.Sprintf("ugly=%t", ugly),
+			func(t *testing.T) {
+
+				assert := require.New(t)
+				fs, cfg := newTestBundleSourcesMultilingual(t)
+				cfg.Set("uglyURLs", ugly)
+
+				assert.NoError(loadDefaultSettingsFor(cfg))
+				assert.NoError(loadLanguageSettings(cfg, nil))
+				sites, err := NewHugoSites(deps.DepsCfg{Fs: fs, Cfg: cfg})
+				assert.NoError(err)
+				assert.Equal(2, len(sites.Sites))
+
+				assert.NoError(sites.Build(BuildCfg{}))
+
+				s := sites.Sites[0]
+
+				assert.Equal(8, len(s.RegularPages))
+				assert.Equal(16, len(s.Pages))
+				assert.Equal(31, len(s.AllPages))
+
+				bundleWithSubPath := s.getPage(KindPage, "lb/index")
+				assert.NotNil(bundleWithSubPath)
+
+				// See https://github.com/gohugoio/hugo/issues/4312
+				// Before that issue:
+				// A bundle in a/b/index.en.md
+				// a/b/index.en.md => OK
+				// a/b/index => OK
+				// index.en.md => ambigous, but OK.
+				// With bundles, the file name has little meaning, the folder it lives in does. So this should also work:
+				// a/b
+				// and probably also just b (aka "my-bundle")
+				// These may also be translated, so we also need to test that.
+				//  "bf", "my-bf-bundle", "index.md + nn
+				bfBundle := s.getPage(KindPage, "bf/my-bf-bundle/index")
+				assert.NotNil(bfBundle)
+				assert.Equal("en", bfBundle.Lang())
+				assert.Equal(bfBundle, s.getPage(KindPage, "bf/my-bf-bundle/index.md"))
+				assert.Equal(bfBundle, s.getPage(KindPage, "bf/my-bf-bundle"))
+				assert.Equal(bfBundle, s.getPage(KindPage, "my-bf-bundle"))
+
+				nnSite := sites.Sites[1]
+				assert.Equal(7, len(nnSite.RegularPages))
+
+				bfBundleNN := nnSite.getPage(KindPage, "bf/my-bf-bundle/index")
+				assert.NotNil(bfBundleNN)
+				assert.Equal("nn", bfBundleNN.Lang())
+				assert.Equal(bfBundleNN, nnSite.getPage(KindPage, "bf/my-bf-bundle/index.nn.md"))
+				assert.Equal(bfBundleNN, nnSite.getPage(KindPage, "bf/my-bf-bundle"))
+				assert.Equal(bfBundleNN, nnSite.getPage(KindPage, "my-bf-bundle"))
+
+				// See https://github.com/gohugoio/hugo/issues/4295
+				// Every resource should have its Name prefixed with its base folder.
+				cBundleResources := bundleWithSubPath.Resources.Match("c/**")
+				assert.Equal(4, len(cBundleResources))
+				bundlePage := bundleWithSubPath.Resources.GetMatch("c/page*")
+				assert.NotNil(bundlePage)
+				assert.IsType(&Page{}, bundlePage)
+
+			})
+	}
+}
+
+func TestMultilingualDisableDefaultLanguage(t *testing.T) {
+	t.Parallel()
+
+	assert := require.New(t)
+	_, cfg := newTestBundleSourcesMultilingual(t)
+
+	cfg.Set("disableLanguages", []string{"en"})
+
+	err := loadDefaultSettingsFor(cfg)
+	assert.NoError(err)
+	err = loadLanguageSettings(cfg, nil)
+	assert.Error(err)
+	assert.Contains(err.Error(), "cannot disable default language")
+}
+
+func TestMultilingualDisableLanguage(t *testing.T) {
+	t.Parallel()
+
+	assert := require.New(t)
+	fs, cfg := newTestBundleSourcesMultilingual(t)
+	cfg.Set("disableLanguages", []string{"nn"})
+
+	assert.NoError(loadDefaultSettingsFor(cfg))
+	assert.NoError(loadLanguageSettings(cfg, nil))
+
+	sites, err := NewHugoSites(deps.DepsCfg{Fs: fs, Cfg: cfg})
+	assert.NoError(err)
+	assert.Equal(1, len(sites.Sites))
+
+	assert.NoError(sites.Build(BuildCfg{}))
+
+	s := sites.Sites[0]
+
+	assert.Equal(8, len(s.RegularPages))
+	assert.Equal(16, len(s.Pages))
+	// No nn pages
+	assert.Equal(16, len(s.AllPages))
+	for _, p := range s.rawAllPages {
+		assert.True(p.Lang() != "nn")
+	}
+	for _, p := range s.AllPages {
+		assert.True(p.Lang() != "nn")
+	}
+
+}
+
+func TestPageBundlerSiteWitSymbolicLinksInContent(t *testing.T) {
+	if runtime.GOOS == "windows" && os.Getenv("CI") == "" {
+		t.Skip("Skip TestPageBundlerSiteWitSymbolicLinksInContent as os.Symlink needs administrator rights on Windows")
+	}
+
+	assert := require.New(t)
+	ps, clean, workDir := newTestBundleSymbolicSources(t)
+	defer clean()
+
+	cfg := ps.Cfg
+	fs := ps.Fs
+
+	s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg, Logger: loggers.NewErrorLogger()}, BuildCfg{})
+
+	th := testHelper{s.Cfg, s.Fs, t}
+
+	assert.Equal(7, len(s.RegularPages))
+	a1Bundle := s.getPage(KindPage, "symbolic2/a1/index.md")
+	assert.NotNil(a1Bundle)
+	assert.Equal(2, len(a1Bundle.Resources))
+	assert.Equal(1, len(a1Bundle.Resources.ByType(pageResourceType)))
+
+	th.assertFileContent(filepath.FromSlash(workDir+"/public/a/page/index.html"), "TheContent")
+	th.assertFileContent(filepath.FromSlash(workDir+"/public/symbolic1/s1/index.html"), "TheContent")
+	th.assertFileContent(filepath.FromSlash(workDir+"/public/symbolic2/a1/index.html"), "TheContent")
+
+}
+
+func TestPageBundlerHeadless(t *testing.T) {
+	t.Parallel()
+
+	cfg, fs := newTestCfg()
+	assert := require.New(t)
+
+	workDir := "/work"
+	cfg.Set("workingDir", workDir)
+	cfg.Set("contentDir", "base")
+	cfg.Set("baseURL", "https://example.com")
+
+	pageContent := `---
+title: "Bundle Galore"
+slug: s1
+date: 2017-01-23
+---
+
+TheContent.
+
+{{< myShort >}}
+`
+
+	writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "single.html"), "single {{ .Content }}")
+	writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "list.html"), "list")
+	writeSource(t, fs, filepath.Join(workDir, "layouts", "shortcodes", "myShort.html"), "SHORTCODE")
+
+	writeSource(t, fs, filepath.Join(workDir, "base", "a", "index.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "a", "l1.png"), "PNG image")
+	writeSource(t, fs, filepath.Join(workDir, "base", "a", "l2.png"), "PNG image")
+
+	writeSource(t, fs, filepath.Join(workDir, "base", "b", "index.md"), `---
+title: "Headless Bundle in Topless Bar"
+slug: s2
+headless: true
+date: 2017-01-23
+---
+
+TheContent.
+HEADLESS {{< myShort >}}
+`)
+	writeSource(t, fs, filepath.Join(workDir, "base", "b", "l1.png"), "PNG image")
+	writeSource(t, fs, filepath.Join(workDir, "base", "b", "l2.png"), "PNG image")
+	writeSource(t, fs, filepath.Join(workDir, "base", "b", "p1.md"), pageContent)
+
+	s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{})
+
+	assert.Equal(1, len(s.RegularPages))
+	assert.Equal(1, len(s.headlessPages))
+
+	regular := s.getPage(KindPage, "a/index")
+	assert.Equal("/a/s1/", regular.RelPermalink())
+
+	headless := s.getPage(KindPage, "b/index")
+	assert.NotNil(headless)
+	assert.True(headless.headless)
+	assert.Equal("Headless Bundle in Topless Bar", headless.Title())
+	assert.Equal("", headless.RelPermalink())
+	assert.Equal("", headless.Permalink())
+	assert.Contains(headless.content(), "HEADLESS SHORTCODE")
+
+	headlessResources := headless.Resources
+	assert.Equal(3, len(headlessResources))
+	assert.Equal(2, len(headlessResources.Match("l*")))
+	pageResource := headlessResources.GetMatch("p*")
+	assert.NotNil(pageResource)
+	assert.IsType(&Page{}, pageResource)
+	p := pageResource.(*Page)
+	assert.Contains(p.content(), "SHORTCODE")
+	assert.Equal("p1.md", p.Name())
+
+	th := testHelper{s.Cfg, s.Fs, t}
+
+	th.assertFileContent(filepath.FromSlash(workDir+"/public/a/s1/index.html"), "TheContent")
+	th.assertFileContent(filepath.FromSlash(workDir+"/public/a/s1/l1.png"), "PNG")
+
+	th.assertFileNotExist(workDir + "/public/b/s2/index.html")
+	// But the bundled resources needs to be published
+	th.assertFileContent(filepath.FromSlash(workDir+"/public/b/s2/l1.png"), "PNG")
+
+}
+
+func newTestBundleSources(t *testing.T) (*hugofs.Fs, *viper.Viper) {
+	cfg, fs := newTestCfg()
+	assert := require.New(t)
+
+	workDir := "/work"
+	cfg.Set("workingDir", workDir)
+	cfg.Set("contentDir", "base")
+	cfg.Set("baseURL", "https://example.com")
+	cfg.Set("mediaTypes", map[string]interface{}{
+		"text/bepsays": map[string]interface{}{
+			"suffixes": []string{"bep"},
+		},
+	})
+
+	pageContent := `---
+title: "Bundle Galore"
+slug: pageslug
+date: 2017-10-09
+---
+
+TheContent.
+`
+
+	pageContentShortcode := `---
+title: "Bundle Galore"
+slug: pageslug
+date: 2017-10-09
+---
+
+TheContent.
+
+{{< myShort >}}
+`
+
+	pageWithImageShortcodeAndResourceMetadataContent := `---
+title: "Bundle Galore"
+slug: pageslug
+date: 2017-10-09
+resources:
+- src: "*.jpg"
+  name: "my-sunset-:counter"
+  title: "Sunset Galore :counter"
+  params:
+    myParam: "My Sunny Param"
+---
+
+TheContent.
+
+{{< myShort >}}
+`
+
+	pageContentNoSlug := `---
+title: "Bundle Galore #2"
+date: 2017-10-09
+---
+
+TheContent.
+`
+
+	singleLayout := `
+Single Title: {{ .Title }}
+Content: {{ .Content }}
+{{ $sunset := .Resources.GetMatch "my-sunset-1*" }}
+{{ with $sunset }}
+Sunset RelPermalink: {{ .RelPermalink }}
+{{ $thumb := .Fill "123x123" }}
+Thumb Width: {{ $thumb.Width }}
+Thumb Name: {{ $thumb.Name }}
+Thumb Title: {{ $thumb.Title }}
+Thumb RelPermalink: {{ $thumb.RelPermalink }}
+{{ end }}
+{{ range $i, $e := .Resources.ByType "image" }}
+{{ $i }}: Image Title: {{ .Title }}
+{{ $i }}: Image Name: {{ .Name }}
+{{ $i }}: Image Params: {{ printf "%v" .Params }}
+{{ $i }}: Image myParam: Lower: {{ .Params.myparam }} Caps: {{ .Params.MYPARAM }}
+{{ end }}
+`
+
+	myShort := `
+MyShort in {{ .Page.Path }}:
+{{ $sunset := .Page.Resources.GetMatch "my-sunset-2*" }}
+{{ with $sunset }}
+Short Sunset RelPermalink: {{ .RelPermalink }}
+{{ $thumb := .Fill "56x56" }}
+Short Thumb Width: {{ $thumb.Width }}
+{{ end }}
+`
+
+	listLayout := `{{ .Title }}|{{ .Content }}`
+
+	writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "single.html"), singleLayout)
+	writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "list.html"), listLayout)
+	writeSource(t, fs, filepath.Join(workDir, "layouts", "shortcodes", "myShort.html"), myShort)
+
+	writeSource(t, fs, filepath.Join(workDir, "base", "_index.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "_1.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "_1.png"), pageContent)
+
+	writeSource(t, fs, filepath.Join(workDir, "base", "images", "hugo-logo.png"), "content")
+	writeSource(t, fs, filepath.Join(workDir, "base", "a", "2.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "a", "1.md"), pageContent)
+
+	writeSource(t, fs, filepath.Join(workDir, "base", "a", "b", "index.md"), pageContentNoSlug)
+	writeSource(t, fs, filepath.Join(workDir, "base", "a", "b", "ab1.md"), pageContentNoSlug)
+
+	// Mostly plain static assets in a folder with a page in a sub folder thrown in.
+	writeSource(t, fs, filepath.Join(workDir, "base", "assets", "pic1.png"), "content")
+	writeSource(t, fs, filepath.Join(workDir, "base", "assets", "pic2.png"), "content")
+	writeSource(t, fs, filepath.Join(workDir, "base", "assets", "pages", "mypage.md"), pageContent)
+
+	// Bundle
+	writeSource(t, fs, filepath.Join(workDir, "base", "b", "my-bundle", "index.md"), pageWithImageShortcodeAndResourceMetadataContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "b", "my-bundle", "1.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "b", "my-bundle", "2.md"), pageContentShortcode)
+	writeSource(t, fs, filepath.Join(workDir, "base", "b", "my-bundle", "custom-mime.bep"), "bepsays")
+	writeSource(t, fs, filepath.Join(workDir, "base", "b", "my-bundle", "c", "logo.png"), "content")
+
+	// Bundle with 은행 slug
+	// See https://github.com/gohugoio/hugo/issues/4241
+	writeSource(t, fs, filepath.Join(workDir, "base", "c", "bundle", "index.md"), `---
+title: "은행 은행"
+slug: 은행
+date: 2017-10-09
+---
+
+Content for 은행.
+`)
+
+	// Bundle in root
+	writeSource(t, fs, filepath.Join(workDir, "base", "root", "index.md"), pageWithImageShortcodeAndResourceMetadataContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "root", "1.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "root", "c", "logo.png"), "content")
+
+	writeSource(t, fs, filepath.Join(workDir, "base", "c", "bundle", "logo-은행.png"), "은행 PNG")
+
+	// Write a real image into one of the bundle above.
+	src, err := os.Open("testdata/sunset.jpg")
+	assert.NoError(err)
+
+	// We need 2 to test https://github.com/gohugoio/hugo/issues/4202
+	out, err := fs.Source.Create(filepath.Join(workDir, "base", "b", "my-bundle", "sunset1.jpg"))
+	assert.NoError(err)
+	out2, err := fs.Source.Create(filepath.Join(workDir, "base", "b", "my-bundle", "sunset2.jpg"))
+	assert.NoError(err)
+
+	_, err = io.Copy(out, src)
+	out.Close()
+	src.Seek(0, 0)
+	_, err = io.Copy(out2, src)
+	out2.Close()
+	src.Close()
+	assert.NoError(err)
+
+	return fs, cfg
+
+}
+
+func newTestBundleSourcesMultilingual(t *testing.T) (*hugofs.Fs, *viper.Viper) {
+	cfg, fs := newTestCfg()
+
+	workDir := "/work"
+	cfg.Set("workingDir", workDir)
+	cfg.Set("contentDir", "base")
+	cfg.Set("baseURL", "https://example.com")
+	cfg.Set("defaultContentLanguage", "en")
+
+	langConfig := map[string]interface{}{
+		"en": map[string]interface{}{
+			"weight":       1,
+			"languageName": "English",
+		},
+		"nn": map[string]interface{}{
+			"weight":       2,
+			"languageName": "Nynorsk",
+		},
+	}
+
+	cfg.Set("languages", langConfig)
+
+	pageContent := `---
+slug: pageslug
+date: 2017-10-09
+---
+
+TheContent.
+`
+
+	layout := `{{ .Title }}|{{ .Content }}|Lang: {{ .Site.Language.Lang }}`
+
+	writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "single.html"), layout)
+	writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "list.html"), layout)
+
+	writeSource(t, fs, filepath.Join(workDir, "base", "1s", "mypage.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "1s", "mypage.nn.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "1s", "mylogo.png"), "content")
+
+	writeSource(t, fs, filepath.Join(workDir, "base", "bb", "_index.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "bb", "_index.nn.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "bb", "en.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "bb", "_1.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "bb", "_1.nn.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "bb", "a.png"), "content")
+	writeSource(t, fs, filepath.Join(workDir, "base", "bb", "b.png"), "content")
+	writeSource(t, fs, filepath.Join(workDir, "base", "bb", "b.nn.png"), "content")
+	writeSource(t, fs, filepath.Join(workDir, "base", "bb", "c.nn.png"), "content")
+	writeSource(t, fs, filepath.Join(workDir, "base", "bb", "b", "d.nn.png"), "content")
+
+	writeSource(t, fs, filepath.Join(workDir, "base", "bc", "_index.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "bc", "page.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "bc", "logo-bc.png"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "bc", "page.nn.md"), pageContent)
+
+	writeSource(t, fs, filepath.Join(workDir, "base", "bd", "index.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "bd", "page.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "bd", "page.nn.md"), pageContent)
+
+	writeSource(t, fs, filepath.Join(workDir, "base", "be", "_index.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "be", "page.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "be", "page.nn.md"), pageContent)
+
+	// Bundle leaf,  multilingual
+	writeSource(t, fs, filepath.Join(workDir, "base", "lb", "index.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "lb", "index.nn.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "lb", "1.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "lb", "2.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "lb", "2.nn.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "lb", "c", "page.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "lb", "c", "logo.png"), "content")
+	writeSource(t, fs, filepath.Join(workDir, "base", "lb", "c", "logo.nn.png"), "content")
+	writeSource(t, fs, filepath.Join(workDir, "base", "lb", "c", "one.png"), "content")
+	writeSource(t, fs, filepath.Join(workDir, "base", "lb", "c", "d", "deep.png"), "content")
+
+	//Translated bundle in some sensible sub path.
+	writeSource(t, fs, filepath.Join(workDir, "base", "bf", "my-bf-bundle", "index.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "bf", "my-bf-bundle", "index.nn.md"), pageContent)
+	writeSource(t, fs, filepath.Join(workDir, "base", "bf", "my-bf-bundle", "page.md"), pageContent)
+
+	return fs, cfg
+}
+
+func newTestBundleSymbolicSources(t *testing.T) (*helpers.PathSpec, func(), string) {
+	assert := require.New(t)
+	// We need to use the OS fs for this.
+	cfg := viper.New()
+	fs := hugofs.NewFrom(hugofs.Os, cfg)
+	fs.Destination = &afero.MemMapFs{}
+	loadDefaultSettingsFor(cfg)
+
+	workDir, clean, err := createTempDir("hugosym")
+	assert.NoError(err)
+
+	contentDir := "base"
+	cfg.Set("workingDir", workDir)
+	cfg.Set("contentDir", contentDir)
+	cfg.Set("baseURL", "https://example.com")
+
+	if err := loadLanguageSettings(cfg, nil); err != nil {
+		t.Fatal(err)
+	}
+
+	layout := `{{ .Title }}|{{ .Content }}`
+	pageContent := `---
+slug: %s
+date: 2017-10-09
+---
+
+TheContent.
+`
+
+	fs.Source.MkdirAll(filepath.Join(workDir, "layouts", "_default"), 0777)
+	fs.Source.MkdirAll(filepath.Join(workDir, contentDir), 0777)
+	fs.Source.MkdirAll(filepath.Join(workDir, contentDir, "a"), 0777)
+	for i := 1; i <= 3; i++ {
+		fs.Source.MkdirAll(filepath.Join(workDir, fmt.Sprintf("symcontent%d", i)), 0777)
+
+	}
+	fs.Source.MkdirAll(filepath.Join(workDir, "symcontent2", "a1"), 0777)
+
+	writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "single.html"), layout)
+	writeSource(t, fs, filepath.Join(workDir, "layouts", "_default", "list.html"), layout)
+
+	writeSource(t, fs, filepath.Join(workDir, contentDir, "a", "regular.md"), fmt.Sprintf(pageContent, "a1"))
+
+	// Regular files inside symlinked folder.
+	writeSource(t, fs, filepath.Join(workDir, "symcontent1", "s1.md"), fmt.Sprintf(pageContent, "s1"))
+	writeSource(t, fs, filepath.Join(workDir, "symcontent1", "s2.md"), fmt.Sprintf(pageContent, "s2"))
+
+	// A bundle
+	writeSource(t, fs, filepath.Join(workDir, "symcontent2", "a1", "index.md"), fmt.Sprintf(pageContent, ""))
+	writeSource(t, fs, filepath.Join(workDir, "symcontent2", "a1", "page.md"), fmt.Sprintf(pageContent, "page"))
+	writeSource(t, fs, filepath.Join(workDir, "symcontent2", "a1", "logo.png"), "image")
+
+	// Assets
+	writeSource(t, fs, filepath.Join(workDir, "symcontent3", "s1.png"), "image")
+	writeSource(t, fs, filepath.Join(workDir, "symcontent3", "s2.png"), "image")
+
+	wd, _ := os.Getwd()
+	defer func() {
+		os.Chdir(wd)
+	}()
+	// Symlinked sections inside content.
+	os.Chdir(filepath.Join(workDir, contentDir))
+	for i := 1; i <= 3; i++ {
+		assert.NoError(os.Symlink(filepath.FromSlash(fmt.Sprintf(("../symcontent%d"), i)), fmt.Sprintf("symbolic%d", i)))
+	}
+
+	os.Chdir(filepath.Join(workDir, contentDir, "a"))
+
+	// Create a symlink to one single content file
+	assert.NoError(os.Symlink(filepath.FromSlash("../../symcontent2/a1/page.md"), "page_s.md"))
+
+	os.Chdir(filepath.FromSlash("../../symcontent3"))
+
+	// Create a circular symlink. Will print some warnings.
+	assert.NoError(os.Symlink(filepath.Join("..", contentDir), filepath.FromSlash("circus")))
+
+	os.Chdir(workDir)
+	assert.NoError(err)
+
+	ps, _ := helpers.NewPathSpec(fs, cfg)
+
+	return ps, clean, workDir
+}
--- /dev/null
+++ b/hugolib/pagecollections.go
@@ -1,0 +1,341 @@
+// Copyright 2016 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 hugolib
+
+import (
+	"fmt"
+	"path"
+	"path/filepath"
+	"strings"
+
+	"github.com/gohugoio/hugo/cache"
+	"github.com/gohugoio/hugo/helpers"
+)
+
+// PageCollections contains the page collections for a site.
+type PageCollections struct {
+	// Includes only pages of all types, and only pages in the current language.
+	Pages Pages
+
+	// Includes all pages in all languages, including the current one.
+	// Includes pages of all types.
+	AllPages Pages
+
+	// A convenience cache for the traditional index types, taxonomies, home page etc.
+	// This is for the current language only.
+	indexPages Pages
+
+	// A convenience cache for the regular pages.
+	// This is for the current language only.
+	RegularPages Pages
+
+	// A convenience cache for the all the regular pages.
+	AllRegularPages Pages
+
+	// Includes absolute all pages (of all types), including drafts etc.
+	rawAllPages Pages
+
+	// Includes headless bundles, i.e. bundles that produce no output for its content page.
+	headlessPages Pages
+
+	pageIndex *cache.Lazy
+}
+
+// Get initializes the index if not already done so, then
+// looks up the given page ref, returns nil if no value found.
+func (c *PageCollections) getFromCache(ref string) (*Page, error) {
+	v, found, err := c.pageIndex.Get(ref)
+	if err != nil {
+		return nil, err
+	}
+	if !found {
+		return nil, nil
+	}
+
+	p := v.(*Page)
+
+	if p != ambiguityFlag {
+		return p, nil
+	}
+	return nil, fmt.Errorf("page reference %q is ambiguous", ref)
+}
+
+var ambiguityFlag = &Page{Kind: kindUnknown, title: "ambiguity flag"}
+
+func (c *PageCollections) refreshPageCaches() {
+	c.indexPages = c.findPagesByKindNotIn(KindPage, c.Pages)
+	c.RegularPages = c.findPagesByKindIn(KindPage, c.Pages)
+	c.AllRegularPages = c.findPagesByKindIn(KindPage, c.AllPages)
+
+	indexLoader := func() (map[string]interface{}, error) {
+		index := make(map[string]interface{})
+
+		add := func(ref string, p *Page) {
+			existing := index[ref]
+			if existing == nil {
+				index[ref] = p
+			} else if existing != ambiguityFlag && existing != p {
+				index[ref] = ambiguityFlag
+			}
+		}
+
+		for _, pageCollection := range []Pages{c.RegularPages, c.headlessPages} {
+			for _, p := range pageCollection {
+				sourceRef := p.absoluteSourceRef()
+
+				if sourceRef != "" {
+					// index the canonical ref
+					// e.g. /section/article.md
+					add(sourceRef, p)
+				}
+
+				// Ref/Relref supports this potentially ambiguous lookup.
+				add(p.LogicalName(), p)
+
+				translationBaseName := p.TranslationBaseName()
+
+				dir, _ := path.Split(sourceRef)
+				dir = strings.TrimSuffix(dir, "/")
+
+				if translationBaseName == "index" {
+					add(dir, p)
+					add(path.Base(dir), p)
+				} else {
+					add(translationBaseName, p)
+				}
+
+				// We need a way to get to the current language version.
+				pathWithNoExtensions := path.Join(dir, translationBaseName)
+				add(pathWithNoExtensions, p)
+			}
+		}
+
+		for _, p := range c.indexPages {
+			// index the canonical, unambiguous ref for any backing file
+			// e.g. /section/_index.md
+			sourceRef := p.absoluteSourceRef()
+			if sourceRef != "" {
+				add(sourceRef, p)
+			}
+
+			ref := path.Join(p.sections...)
+
+			// index the canonical, unambiguous virtual ref
+			// e.g. /section
+			// (this may already have been indexed above)
+			add("/"+ref, p)
+		}
+
+		return index, nil
+	}
+
+	c.pageIndex = cache.NewLazy(indexLoader)
+}
+
+func newPageCollections() *PageCollections {
+	return &PageCollections{}
+}
+
+func newPageCollectionsFromPages(pages Pages) *PageCollections {
+	return &PageCollections{rawAllPages: pages}
+}
+
+// This is an adapter func for the old API with Kind as first argument.
+// This is invoked when you do .Site.GetPage. We drop the Kind and fails
+// if there are more than 2 arguments, which would be ambigous.
+func (c *PageCollections) getPageOldVersion(ref ...string) (*Page, error) {
+	var refs []string
+	for _, r := range ref {
+		// A common construct in the wild is
+		// .Site.GetPage "home" "" or
+		// .Site.GetPage "home" "/"
+		if r != "" && r != "/" {
+			refs = append(refs, r)
+		}
+	}
+
+	var key string
+
+	if len(refs) > 2 {
+		// This was allowed in Hugo <= 0.44, but we cannot support this with the
+		// new API. This should be the most unusual case.
+		return nil, fmt.Errorf(`too many arguments to .Site.GetPage: %v. Use lookups on the form {{ .Site.GetPage "/posts/mypage-md" }}`, ref)
+	}
+
+	if len(refs) == 0 || refs[0] == KindHome {
+		key = "/"
+	} else if len(refs) == 1 {
+		if len(ref) == 2 && refs[0] == KindSection {
+			// This is an old style reference to the "Home Page section".
+			// Typically fetched via {{ .Site.GetPage "section" .Section }}
+			// See https://github.com/gohugoio/hugo/issues/4989
+			key = "/"
+		} else {
+			key = refs[0]
+		}
+	} else {
+		key = refs[1]
+	}
+
+	key = filepath.ToSlash(key)
+	if !strings.HasPrefix(key, "/") {
+		key = "/" + key
+	}
+
+	return c.getPageNew(nil, key)
+}
+
+// 	Only used in tests.
+func (c *PageCollections) getPage(typ string, sections ...string) *Page {
+	refs := append([]string{typ}, path.Join(sections...))
+	p, _ := c.getPageOldVersion(refs...)
+	return p
+}
+
+// Ref is either unix-style paths (i.e. callers responsible for
+// calling filepath.ToSlash as necessary) or shorthand refs.
+func (c *PageCollections) getPageNew(context *Page, ref string) (*Page, error) {
+	var anError error
+
+	// Absolute (content root relative) reference.
+	if strings.HasPrefix(ref, "/") {
+		p, err := c.getFromCache(ref)
+		if err == nil && p != nil {
+			return p, nil
+		}
+		if err != nil {
+			anError = err
+		}
+
+	} else if context != nil {
+		// Try the page-relative path.
+		ppath := path.Join("/", strings.Join(context.sections, "/"), ref)
+		p, err := c.getFromCache(ppath)
+		if err == nil && p != nil {
+			return p, nil
+		}
+		if err != nil {
+			anError = err
+		}
+	}
+
+	if !strings.HasPrefix(ref, "/") {
+		// Many people will have "post/foo.md" in their content files.
+		p, err := c.getFromCache("/" + ref)
+		if err == nil && p != nil {
+			if context != nil {
+				// TODO(bep) remove this case and the message below when the storm has passed
+				helpers.DistinctFeedbackLog.Printf(`WARNING: make non-relative ref/relref page reference(s) in page %q absolute, e.g. {{< ref "/blog/my-post.md" >}}`, context.absoluteSourceRef())
+			}
+			return p, nil
+		}
+		if err != nil {
+			anError = err
+		}
+	}
+
+	// Last try.
+	ref = strings.TrimPrefix(ref, "/")
+	p, err := c.getFromCache(ref)
+	if err != nil {
+		anError = err
+	}
+
+	if p == nil && anError != nil {
+		if context != nil {
+			return nil, fmt.Errorf("failed to resolve path from page %q: %s", context.absoluteSourceRef(), anError)
+		}
+		return nil, fmt.Errorf("failed to resolve page: %s", anError)
+	}
+
+	return p, nil
+}
+
+func (*PageCollections) findPagesByKindIn(kind string, inPages Pages) Pages {
+	var pages Pages
+	for _, p := range inPages {
+		if p.Kind == kind {
+			pages = append(pages, p)
+		}
+	}
+	return pages
+}
+
+func (*PageCollections) findFirstPageByKindIn(kind string, inPages Pages) *Page {
+	for _, p := range inPages {
+		if p.Kind == kind {
+			return p
+		}
+	}
+	return nil
+}
+
+func (*PageCollections) findPagesByKindNotIn(kind string, inPages Pages) Pages {
+	var pages Pages
+	for _, p := range inPages {
+		if p.Kind != kind {
+			pages = append(pages, p)
+		}
+	}
+	return pages
+}
+
+func (c *PageCollections) findPagesByKind(kind string) Pages {
+	return c.findPagesByKindIn(kind, c.Pages)
+}
+
+func (c *PageCollections) addPage(page *Page) {
+	c.rawAllPages = append(c.rawAllPages, page)
+}
+
+func (c *PageCollections) removePageFilename(filename string) {
+	if i := c.rawAllPages.findPagePosByFilename(filename); i >= 0 {
+		c.clearResourceCacheForPage(c.rawAllPages[i])
+		c.rawAllPages = append(c.rawAllPages[:i], c.rawAllPages[i+1:]...)
+	}
+
+}
+
+func (c *PageCollections) removePage(page *Page) {
+	if i := c.rawAllPages.findPagePos(page); i >= 0 {
+		c.clearResourceCacheForPage(c.rawAllPages[i])
+		c.rawAllPages = append(c.rawAllPages[:i], c.rawAllPages[i+1:]...)
+	}
+
+}
+
+func (c *PageCollections) findPagesByShortcode(shortcode string) Pages {
+	var pages Pages
+
+	for _, p := range c.rawAllPages {
+		if p.shortcodeState != nil {
+			if _, ok := p.shortcodeState.nameSet[shortcode]; ok {
+				pages = append(pages, p)
+			}
+		}
+	}
+	return pages
+}
+
+func (c *PageCollections) replacePage(page *Page) {
+	// will find existing page that matches filepath and remove it
+	c.removePage(page)
+	c.addPage(page)
+}
+
+func (c *PageCollections) clearResourceCacheForPage(page *Page) {
+	if len(page.Resources) > 0 {
+		page.s.ResourceSpec.DeleteCacheByPrefix(page.relTargetPathBase)
+	}
+}
--- /dev/null
+++ b/hugolib/pagecollections_test.go
@@ -1,0 +1,242 @@
+// Copyright 2017 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 hugolib
+
+import (
+	"fmt"
+	"math/rand"
+	"path"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/gohugoio/hugo/deps"
+	"github.com/stretchr/testify/require"
+)
+
+const pageCollectionsPageTemplate = `---
+title: "%s"
+categories:
+- Hugo
+---
+# Doc
+`
+
+func BenchmarkGetPage(b *testing.B) {
+	var (
+		cfg, fs = newTestCfg()
+		r       = rand.New(rand.NewSource(time.Now().UnixNano()))
+	)
+
+	for i := 0; i < 10; i++ {
+		for j := 0; j < 100; j++ {
+			writeSource(b, fs, filepath.Join("content", fmt.Sprintf("sect%d", i), fmt.Sprintf("page%d.md", j)), "CONTENT")
+		}
+	}
+
+	s := buildSingleSite(b, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true})
+
+	pagePaths := make([]string, b.N)
+
+	for i := 0; i < b.N; i++ {
+		pagePaths[i] = fmt.Sprintf("sect%d", r.Intn(10))
+	}
+
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		home, _ := s.getPageNew(nil, "/")
+		if home == nil {
+			b.Fatal("Home is nil")
+		}
+
+		p, _ := s.getPageNew(nil, pagePaths[i])
+		if p == nil {
+			b.Fatal("Section is nil")
+		}
+
+	}
+}
+
+func BenchmarkGetPageRegular(b *testing.B) {
+	var (
+		cfg, fs = newTestCfg()
+		r       = rand.New(rand.NewSource(time.Now().UnixNano()))
+	)
+
+	for i := 0; i < 10; i++ {
+		for j := 0; j < 100; j++ {
+			content := fmt.Sprintf(pageCollectionsPageTemplate, fmt.Sprintf("Title%d_%d", i, j))
+			writeSource(b, fs, filepath.Join("content", fmt.Sprintf("sect%d", i), fmt.Sprintf("page%d.md", j)), content)
+		}
+	}
+
+	s := buildSingleSite(b, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true})
+
+	pagePaths := make([]string, b.N)
+
+	for i := 0; i < b.N; i++ {
+		pagePaths[i] = path.Join(fmt.Sprintf("sect%d", r.Intn(10)), fmt.Sprintf("page%d.md", r.Intn(100)))
+	}
+
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		page, _ := s.getPageNew(nil, pagePaths[i])
+		require.NotNil(b, page)
+	}
+}
+
+type testCase struct {
+	kind          string
+	context       *Page
+	path          []string
+	expectedTitle string
+}
+
+func (t *testCase) check(p *Page, err error, errorMsg string, assert *require.Assertions) {
+	switch t.kind {
+	case "Ambiguous":
+		assert.Error(err)
+		assert.Nil(p, errorMsg)
+	case "NoPage":
+		assert.NoError(err)
+		assert.Nil(p, errorMsg)
+	default:
+		assert.NoError(err, errorMsg)
+		assert.NotNil(p, errorMsg)
+		assert.Equal(t.kind, p.Kind, errorMsg)
+		assert.Equal(t.expectedTitle, p.title, errorMsg)
+	}
+}
+
+func TestGetPage(t *testing.T) {
+
+	var (
+		assert  = require.New(t)
+		cfg, fs = newTestCfg()
+	)
+
+	for i := 0; i < 10; i++ {
+		for j := 0; j < 10; j++ {
+			content := fmt.Sprintf(pageCollectionsPageTemplate, fmt.Sprintf("Title%d_%d", i, j))
+			writeSource(t, fs, filepath.Join("content", fmt.Sprintf("sect%d", i), fmt.Sprintf("page%d.md", j)), content)
+		}
+	}
+
+	content := fmt.Sprintf(pageCollectionsPageTemplate, "home page")
+	writeSource(t, fs, filepath.Join("content", "_index.md"), content)
+
+	content = fmt.Sprintf(pageCollectionsPageTemplate, "about page")
+	writeSource(t, fs, filepath.Join("content", "about.md"), content)
+
+	content = fmt.Sprintf(pageCollectionsPageTemplate, "section 3")
+	writeSource(t, fs, filepath.Join("content", "sect3", "_index.md"), content)
+
+	content = fmt.Sprintf(pageCollectionsPageTemplate, "UniqueBase")
+	writeSource(t, fs, filepath.Join("content", "sect3", "unique.md"), content)
+
+	content = fmt.Sprintf(pageCollectionsPageTemplate, "another sect7")
+	writeSource(t, fs, filepath.Join("content", "sect3", "sect7", "_index.md"), content)
+
+	content = fmt.Sprintf(pageCollectionsPageTemplate, "deep page")
+	writeSource(t, fs, filepath.Join("content", "sect3", "subsect", "deep.md"), content)
+
+	s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg}, BuildCfg{SkipRender: true})
+
+	sec3, err := s.getPageNew(nil, "/sect3")
+	assert.NoError(err, "error getting Page for /sec3")
+	assert.NotNil(sec3, "failed to get Page for /sec3")
+
+	tests := []testCase{
+		// legacy content root relative paths
+		{KindHome, nil, []string{}, "home page"},
+		{KindPage, nil, []string{"about.md"}, "about page"},
+		{KindSection, nil, []string{"sect3"}, "section 3"},
+		{KindPage, nil, []string{"sect3/page1.md"}, "Title3_1"},
+		{KindPage, nil, []string{"sect4/page2.md"}, "Title4_2"},
+		{KindSection, nil, []string{"sect3/sect7"}, "another sect7"},
+		{KindPage, nil, []string{"sect3/subsect/deep.md"}, "deep page"},
+		{KindPage, nil, []string{filepath.FromSlash("sect5/page3.md")}, "Title5_3"}, //test OS-specific path
+
+		// shorthand refs (potentially ambiguous)
+		{KindPage, nil, []string{"unique.md"}, "UniqueBase"},
+		{"Ambiguous", nil, []string{"page1.md"}, ""},
+
+		// ISSUE: This is an ambiguous ref, but because we have to support the legacy
+		// content root relative paths without a leading slash, the lookup
+		// returns /sect7. This undermines ambiguity detection, but we have no choice.
+		//{"Ambiguous", nil, []string{"sect7"}, ""},
+		{KindSection, nil, []string{"sect7"}, "Sect7s"},
+
+		// absolute paths
+		{KindHome, nil, []string{"/"}, "home page"},
+		{KindPage, nil, []string{"/about.md"}, "about page"},
+		{KindSection, nil, []string{"/sect3"}, "section 3"},
+		{KindPage, nil, []string{"/sect3/page1.md"}, "Title3_1"},
+		{KindPage, nil, []string{"/sect4/page2.md"}, "Title4_2"},
+		{KindSection, nil, []string{"/sect3/sect7"}, "another sect7"},
+		{KindPage, nil, []string{"/sect3/subsect/deep.md"}, "deep page"},
+		{KindPage, nil, []string{filepath.FromSlash("/sect5/page3.md")}, "Title5_3"}, //test OS-specific path
+		{KindPage, nil, []string{"/sect3/unique.md"}, "UniqueBase"},                  //next test depends on this page existing
+		// {"NoPage", nil, []string{"/unique.md"}, ""},  // ISSUE #4969: this is resolving to /sect3/unique.md
+		{"NoPage", nil, []string{"/missing-page.md"}, ""},
+		{"NoPage", nil, []string{"/missing-section"}, ""},
+
+		// relative paths
+		{KindHome, sec3, []string{".."}, "home page"},
+		{KindHome, sec3, []string{"../"}, "home page"},
+		{KindPage, sec3, []string{"../about.md"}, "about page"},
+		{KindSection, sec3, []string{"."}, "section 3"},
+		{KindSection, sec3, []string{"./"}, "section 3"},
+		{KindPage, sec3, []string{"page1.md"}, "Title3_1"},
+		{KindPage, sec3, []string{"./page1.md"}, "Title3_1"},
+		{KindPage, sec3, []string{"../sect4/page2.md"}, "Title4_2"},
+		{KindSection, sec3, []string{"sect7"}, "another sect7"},
+		{KindSection, sec3, []string{"./sect7"}, "another sect7"},
+		{KindPage, sec3, []string{"./subsect/deep.md"}, "deep page"},
+		{KindPage, sec3, []string{"./subsect/../../sect7/page9.md"}, "Title7_9"},
+		{KindPage, sec3, []string{filepath.FromSlash("../sect5/page3.md")}, "Title5_3"}, //test OS-specific path
+		{KindPage, sec3, []string{"./unique.md"}, "UniqueBase"},
+		{"NoPage", sec3, []string{"./sect2"}, ""},
+		//{"NoPage", sec3, []string{"sect2"}, ""}, // ISSUE: /sect3 page relative query is resolving to /sect2
+
+		// absolute paths ignore context
+		{KindHome, sec3, []string{"/"}, "home page"},
+		{KindPage, sec3, []string{"/about.md"}, "about page"},
+		{KindPage, sec3, []string{"/sect4/page2.md"}, "Title4_2"},
+		{KindPage, sec3, []string{"/sect3/subsect/deep.md"}, "deep page"}, //next test depends on this page existing
+		{"NoPage", sec3, []string{"/subsect/deep.md"}, ""},
+	}
+
+	for _, test := range tests {
+		errorMsg := fmt.Sprintf("Test case %s %v -> %s", test.context, test.path, test.expectedTitle)
+
+		// test legacy public Site.GetPage (which does not support page context relative queries)
+		if test.context == nil {
+			args := append([]string{test.kind}, test.path...)
+			page, err := s.Info.GetPage(args...)
+			test.check(page, err, errorMsg, assert)
+		}
+
+		// test new internal Site.getPageNew
+		var ref string
+		if len(test.path) == 1 {
+			ref = filepath.ToSlash(test.path[0])
+		} else {
+			ref = path.Join(test.path...)
+		}
+		page2, err := s.getPageNew(test.context, ref)
+		test.check(page2, err, errorMsg, assert)
+	}
+
+}