URI: 
       Allow themes to define output formats, media types and params - hugo - [fork] hugo port for 9front
  HTML git clone git@git.drkhsh.at/hugo.git
   DIR Log
   DIR Files
   DIR Refs
   DIR Submodules
   DIR README
   DIR LICENSE
       ---
   DIR commit e9c7b6205f94a7edac0e0df2cd18d1456cb26a06
   DIR parent 3d1a6e109ce9b25fc2e9731098a82fb4c0abff68
  HTML Author: Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
       Date:   Sun, 18 Mar 2018 11:07:24 +0100
       
       Allow themes to define output formats, media types and params
       
       This allows a `config.toml` (or `yaml`, ´yml`, or `json`)  in the theme to set:
       
       1) `params` (but cannot override params in project. Will also get its own "namespace", i.e. `{{ .Site.Params.mytheme.my_param }}` will be the same as `{{ .Site.Params.my_param }}` providing that the main project does not define a param with that key.
       2) `menu` -- but cannot redefine/add menus in the project. Must create its own menus with its own identifiers.
       3) `languages` -- only `params` and `menu`. Same rules as above.
       4) **new** `outputFormats`
       5) **new** `mediaTypes`
       
       This should help with the "theme portability" issue and people having to copy and paste lots of setting into their projects.
       
       Fixes #4490
       
       Diffstat:
         M Gopkg.lock                          |      13 ++++++++++---
         M Gopkg.toml                          |       4 ++++
         M commands/commandeer.go              |     177 +++++++++++++++++++++++++++++--
         M commands/hugo.go                    |     173 ++++++-------------------------
         M commands/server.go                  |      68 +++++++++++++++++--------------
         M helpers/path.go                     |       9 +++++++--
         M hugolib/case_insensitive_test.go    |       2 +-
         M hugolib/config.go                   |     204 ++++++++++++++++++++++++++++---
         M hugolib/config_test.go              |     314 ++++++++++++++++++++++++++++++-
         M hugolib/page_bundler_test.go        |       3 +++
         M hugolib/site.go                     |       2 ++
         M hugolib/testhelpers_test.go         |      41 +++++++++++++++++++++++++++++--
       
       12 files changed, 794 insertions(+), 216 deletions(-)
       ---
   DIR diff --git a/Gopkg.lock b/Gopkg.lock
       @@ -163,6 +163,7 @@
            ".",
            "hcl/ast",
            "hcl/parser",
       +    "hcl/printer",
            "hcl/scanner",
            "hcl/strconv",
            "hcl/token",
       @@ -275,6 +276,12 @@
          revision = "55d61fa8aa702f59229e6cff85793c22e580eaf5"
        
        [[projects]]
       +  name = "github.com/sanity-io/litter"
       +  packages = ["."]
       +  revision = "ae543b7ba8fd6af63e4976198f146e1348ae53c1"
       +  version = "v1.1.0"
       +
       +[[projects]]
          branch = "master"
          name = "github.com/shurcooL/sanitized_anchor_name"
          packages = ["."]
       @@ -331,8 +338,8 @@
        [[projects]]
          name = "github.com/spf13/viper"
          packages = ["."]
       -  revision = "25b30aa063fc18e48662b86996252eabdcf2f0c7"
       -  version = "v1.0.0"
       +  revision = "b5e8006cbee93ec955a89ab31e0e3ce3204f3736"
       +  version = "v1.0.2"
        
        [[projects]]
          name = "github.com/stretchr/testify"
       @@ -417,6 +424,6 @@
        [solve-meta]
          analyzer-name = "dep"
          analyzer-version = 1
       -  inputs-digest = "4657586103d844434bda6db23d03f30e2ae0db16dc48746b9559ce742902535a"
       +  inputs-digest = "13ab39f8bfafadc12c05726e565ee3f3d94bf7d6c0e8adf04056de0691bf2dd6"
          solver-name = "gps-cdcl"
          solver-version = 1
   DIR diff --git a/Gopkg.toml b/Gopkg.toml
       @@ -141,3 +141,7 @@
          name = "github.com/muesli/smartcrop"
          branch = "master"
        
       +
       +[[constraint]]
       +  name = "github.com/sanity-io/litter"
       +  version = "1.1.0"
   DIR diff --git a/commands/commandeer.go b/commands/commandeer.go
       @@ -14,6 +14,18 @@
        package commands
        
        import (
       +        "os"
       +        "path/filepath"
       +        "sync"
       +
       +        "github.com/spf13/cobra"
       +
       +        "github.com/gohugoio/hugo/utils"
       +
       +        "github.com/spf13/afero"
       +
       +        "github.com/gohugoio/hugo/hugolib"
       +
                "github.com/gohugoio/hugo/common/types"
                "github.com/gohugoio/hugo/deps"
                "github.com/gohugoio/hugo/helpers"
       @@ -23,11 +35,22 @@ import (
        
        type commandeer struct {
                *deps.DepsCfg
       +
       +        subCmdVs []*cobra.Command
       +
                pathSpec    *helpers.PathSpec
                visitedURLs *types.EvictingStringQueue
        
                staticDirsConfig []*src.Dirs
        
       +        // We watch these for changes.
       +        configFiles []string
       +
       +        doWithCommandeer func(c *commandeer) error
       +
       +        // We can do this only once.
       +        fsCreate sync.Once
       +
                serverPorts []int
                languages   helpers.Languages
        
       @@ -65,16 +88,158 @@ func (c *commandeer) initFs(fs *hugofs.Fs) error {
                return nil
        }
        
       -func newCommandeer(cfg *deps.DepsCfg, running bool) (*commandeer, error) {
       +func newCommandeer(running bool, doWithCommandeer func(c *commandeer) error, subCmdVs ...*cobra.Command) (*commandeer, error) {
       +
       +        c := &commandeer{
       +                doWithCommandeer: doWithCommandeer,
       +                subCmdVs:         append([]*cobra.Command{hugoCmdV}, subCmdVs...),
       +                visitedURLs:      types.NewEvictingStringQueue(10)}
       +
       +        return c, c.loadConfig(running)
       +}
       +
       +func (c *commandeer) loadConfig(running bool) error {
       +
       +        if c.DepsCfg == nil {
       +                c.DepsCfg = &deps.DepsCfg{}
       +        }
       +
       +        cfg := c.DepsCfg
       +        c.configured = false
                cfg.Running = running
        
       -        var languages helpers.Languages
       +        var dir string
       +        if source != "" {
       +                dir, _ = filepath.Abs(source)
       +        } else {
       +                dir, _ = os.Getwd()
       +        }
       +
       +        var sourceFs afero.Fs = hugofs.Os
       +        if c.DepsCfg.Fs != nil {
       +                sourceFs = c.DepsCfg.Fs.Source
       +        }
       +
       +        config, configFiles, err := hugolib.LoadConfig(hugolib.ConfigSourceDescriptor{Fs: sourceFs, Path: source, WorkingDir: dir, Filename: cfgFile})
       +        if err != nil {
       +                return err
       +        }
       +
       +        c.Cfg = config
       +        c.configFiles = configFiles
       +
       +        for _, cmdV := range c.subCmdVs {
       +                c.initializeFlags(cmdV)
       +        }
       +
       +        if l, ok := c.Cfg.Get("languagesSorted").(helpers.Languages); ok {
       +                c.languages = l
       +        }
        
       -        if l, ok := cfg.Cfg.Get("languagesSorted").(helpers.Languages); ok {
       -                languages = l
       +        if baseURL != "" {
       +                config.Set("baseURL", baseURL)
                }
        
       -        c := &commandeer{DepsCfg: cfg, languages: languages, visitedURLs: types.NewEvictingStringQueue(10)}
       +        if c.doWithCommandeer != nil {
       +                err = c.doWithCommandeer(c)
       +        }
       +
       +        if err != nil {
       +                return err
       +        }
       +
       +        if len(disableKinds) > 0 {
       +                c.Set("disableKinds", disableKinds)
       +        }
       +
       +        logger, err := createLogger(cfg.Cfg)
       +        if err != nil {
       +                return err
       +        }
       +
       +        cfg.Logger = logger
       +
       +        config.Set("logI18nWarnings", logI18nWarnings)
       +
       +        if theme != "" {
       +                config.Set("theme", theme)
       +        }
       +
       +        if themesDir != "" {
       +                config.Set("themesDir", themesDir)
       +        }
       +
       +        if destination != "" {
       +                config.Set("publishDir", destination)
       +        }
       +
       +        config.Set("workingDir", dir)
       +
       +        if contentDir != "" {
       +                config.Set("contentDir", contentDir)
       +        }
       +
       +        if layoutDir != "" {
       +                config.Set("layoutDir", layoutDir)
       +        }
       +
       +        if cacheDir != "" {
       +                config.Set("cacheDir", cacheDir)
       +        }
       +
       +        createMemFs := config.GetBool("renderToMemory")
       +
       +        if createMemFs {
       +                // Rendering to memoryFS, publish to Root regardless of publishDir.
       +                config.Set("publishDir", "/")
       +        }
       +
       +        c.fsCreate.Do(func() {
       +                fs := hugofs.NewFrom(sourceFs, config)
       +
       +                // Hugo writes the output to memory instead of the disk.
       +                if createMemFs {
       +                        fs.Destination = new(afero.MemMapFs)
       +                }
       +
       +                err = c.initFs(fs)
       +        })
       +
       +        if err != nil {
       +                return err
       +        }
       +
       +        cacheDir = config.GetString("cacheDir")
       +        if cacheDir != "" {
       +                if helpers.FilePathSeparator != cacheDir[len(cacheDir)-1:] {
       +                        cacheDir = cacheDir + helpers.FilePathSeparator
       +                }
       +                isDir, err := helpers.DirExists(cacheDir, sourceFs)
       +                utils.CheckErr(cfg.Logger, err)
       +                if !isDir {
       +                        mkdir(cacheDir)
       +                }
       +                config.Set("cacheDir", cacheDir)
       +        } else {
       +                config.Set("cacheDir", helpers.GetTempDir("hugo_cache", sourceFs))
       +        }
       +
       +        cfg.Logger.INFO.Println("Using config file:", config.ConfigFileUsed())
       +
       +        themeDir := c.PathSpec().GetThemeDir()
       +        if themeDir != "" {
       +                if _, err := sourceFs.Stat(themeDir); os.IsNotExist(err) {
       +                        return newSystemError("Unable to find theme Directory:", themeDir)
       +                }
       +        }
       +
       +        themeVersionMismatch, minVersion := c.isThemeVsHugoVersionMismatch(sourceFs)
       +
       +        if themeVersionMismatch {
       +                cfg.Logger.ERROR.Printf("Current theme does not support Hugo version %s. Minimum version required is %s\n",
       +                        helpers.CurrentHugoVersion.ReleaseVersion(), minVersion)
       +        }
       +
       +        return nil
        
       -        return c, nil
        }
   DIR diff --git a/commands/hugo.go b/commands/hugo.go
       @@ -25,8 +25,6 @@ import (
        
                "golang.org/x/sync/errgroup"
        
       -        "github.com/gohugoio/hugo/hugofs"
       -
                "log"
                "os"
                "path/filepath"
       @@ -44,7 +42,6 @@ import (
                "regexp"
        
                "github.com/fsnotify/fsnotify"
       -        "github.com/gohugoio/hugo/deps"
                "github.com/gohugoio/hugo/helpers"
                "github.com/gohugoio/hugo/hugolib"
                "github.com/gohugoio/hugo/livereload"
       @@ -55,7 +52,6 @@ import (
                "github.com/spf13/fsync"
                jww "github.com/spf13/jwalterweatherman"
                "github.com/spf13/nitro"
       -        "github.com/spf13/viper"
        )
        
        // Hugo represents the Hugo sites to build. This variable is exported as it
       @@ -142,10 +138,6 @@ Complete documentation is available at http://gohugo.io/.`,
                                return err
                        }
        
       -                if buildWatch {
       -                        c.watchConfig()
       -                }
       -
                        return c.build()
                },
        }
       @@ -301,129 +293,11 @@ func init() {
        // InitializeConfig initializes a config file with sensible default configuration flags.
        func InitializeConfig(running bool, doWithCommandeer func(c *commandeer) error, subCmdVs ...*cobra.Command) (*commandeer, error) {
        
       -        var cfg *deps.DepsCfg = &deps.DepsCfg{}
       -
       -        // Init file systems. This may be changed at a later point.
       -        osFs := hugofs.Os
       -
       -        config, err := hugolib.LoadConfig(hugolib.ConfigSourceDescriptor{Fs: osFs, Src: source, Name: cfgFile})
       -        if err != nil {
       -                return nil, err
       -        }
       -
       -        // Init file systems. This may be changed at a later point.
       -        cfg.Cfg = config
       -
       -        c, err := newCommandeer(cfg, running)
       -        if err != nil {
       -                return nil, err
       -        }
       -
       -        for _, cmdV := range append([]*cobra.Command{hugoCmdV}, subCmdVs...) {
       -                c.initializeFlags(cmdV)
       -        }
       -
       -        if baseURL != "" {
       -                config.Set("baseURL", baseURL)
       -        }
       -
       -        if doWithCommandeer != nil {
       -                if err := doWithCommandeer(c); err != nil {
       -                        return nil, err
       -                }
       -        }
       -
       -        if len(disableKinds) > 0 {
       -                c.Set("disableKinds", disableKinds)
       -        }
       -
       -        logger, err := createLogger(cfg.Cfg)
       +        c, err := newCommandeer(running, doWithCommandeer, subCmdVs...)
                if err != nil {
                        return nil, err
                }
        
       -        cfg.Logger = logger
       -
       -        config.Set("logI18nWarnings", logI18nWarnings)
       -
       -        if theme != "" {
       -                config.Set("theme", theme)
       -        }
       -
       -        if themesDir != "" {
       -                config.Set("themesDir", themesDir)
       -        }
       -
       -        if destination != "" {
       -                config.Set("publishDir", destination)
       -        }
       -
       -        var dir string
       -        if source != "" {
       -                dir, _ = filepath.Abs(source)
       -        } else {
       -                dir, _ = os.Getwd()
       -        }
       -        config.Set("workingDir", dir)
       -
       -        if contentDir != "" {
       -                config.Set("contentDir", contentDir)
       -        }
       -
       -        if layoutDir != "" {
       -                config.Set("layoutDir", layoutDir)
       -        }
       -
       -        if cacheDir != "" {
       -                config.Set("cacheDir", cacheDir)
       -        }
       -
       -        fs := hugofs.NewFrom(osFs, config)
       -
       -        // Hugo writes the output to memory instead of the disk.
       -        // This is only used for benchmark testing. Cause the content is only visible
       -        // in memory.
       -        if config.GetBool("renderToMemory") {
       -                fs.Destination = new(afero.MemMapFs)
       -                // Rendering to memoryFS, publish to Root regardless of publishDir.
       -                config.Set("publishDir", "/")
       -        }
       -
       -        cacheDir = config.GetString("cacheDir")
       -        if cacheDir != "" {
       -                if helpers.FilePathSeparator != cacheDir[len(cacheDir)-1:] {
       -                        cacheDir = cacheDir + helpers.FilePathSeparator
       -                }
       -                isDir, err := helpers.DirExists(cacheDir, fs.Source)
       -                utils.CheckErr(cfg.Logger, err)
       -                if !isDir {
       -                        mkdir(cacheDir)
       -                }
       -                config.Set("cacheDir", cacheDir)
       -        } else {
       -                config.Set("cacheDir", helpers.GetTempDir("hugo_cache", fs.Source))
       -        }
       -
       -        if err := c.initFs(fs); err != nil {
       -                return nil, err
       -        }
       -
       -        cfg.Logger.INFO.Println("Using config file:", config.ConfigFileUsed())
       -
       -        themeDir := c.PathSpec().GetThemeDir()
       -        if themeDir != "" {
       -                if _, err := cfg.Fs.Source.Stat(themeDir); os.IsNotExist(err) {
       -                        return nil, newSystemError("Unable to find theme Directory:", themeDir)
       -                }
       -        }
       -
       -        themeVersionMismatch, minVersion := c.isThemeVsHugoVersionMismatch()
       -
       -        if themeVersionMismatch {
       -                cfg.Logger.ERROR.Printf("Current theme does not support Hugo version %s. Minimum version required is %s\n",
       -                        helpers.CurrentHugoVersion.ReleaseVersion(), minVersion)
       -        }
       -
                return c, nil
        
        }
       @@ -524,20 +398,6 @@ If you need to set this configuration value from the command line, set it via an
                }
        }
        
       -func (c *commandeer) watchConfig() {
       -        v := c.Cfg.(*viper.Viper)
       -        v.WatchConfig()
       -        v.OnConfigChange(func(e fsnotify.Event) {
       -                c.Logger.FEEDBACK.Println("Config file changed:", e.Name)
       -                // Force a full rebuild
       -                utils.CheckErr(c.Logger, c.recreateAndBuildSites(true))
       -                if !c.Cfg.GetBool("disableLiveReload") {
       -                        // Will block forever trying to write to a channel that nobody is reading if livereload isn't initialized
       -                        livereload.ForceRefresh()
       -                }
       -        })
       -}
       -
        func (c *commandeer) fullBuild() error {
                var (
                        g         errgroup.Group
       @@ -942,6 +802,7 @@ func (c *commandeer) resetAndBuildSites() (err error) {
        
        func (c *commandeer) initSites() error {
                if Hugo != nil {
       +                Hugo.Cfg = c.Cfg
                        Hugo.Log.ResetLogCounters()
                        return nil
                }
       @@ -1009,6 +870,15 @@ func (c *commandeer) newWatcher(dirList ...string) (*watcher.Batcher, error) {
                        }
                }
        
       +        // Identifies changes to config (config.toml) files.
       +        configSet := make(map[string]bool)
       +
       +        for _, configFile := range c.configFiles {
       +                c.Logger.FEEDBACK.Println("Watching for config changes in", configFile)
       +                watcher.Add(configFile)
       +                configSet[configFile] = true
       +        }
       +
                go func() {
                        for {
                                select {
       @@ -1021,6 +891,21 @@ func (c *commandeer) newWatcher(dirList ...string) (*watcher.Batcher, error) {
                                        // Special handling for symbolic links inside /content.
                                        filtered := []fsnotify.Event{}
                                        for _, ev := range evs {
       +                                        if configSet[ev.Name] {
       +                                                if ev.Op&fsnotify.Chmod == fsnotify.Chmod {
       +                                                        continue
       +                                                }
       +                                                // Config file changed. Need full rebuild.
       +                                                if err := c.loadConfig(true); err != nil {
       +                                                        jww.ERROR.Println("Failed to reload config:", err)
       +                                                } else if err := c.recreateAndBuildSites(true); err != nil {
       +                                                        jww.ERROR.Println(err)
       +                                                } else if !buildWatch && !c.Cfg.GetBool("disableLiveReload") {
       +                                                        livereload.ForceRefresh()
       +                                                }
       +                                                break
       +                                        }
       +
                                                // Check the most specific first, i.e. files.
                                                contentMapped := Hugo.ContentChanges.GetSymbolicLinkMappings(ev.Name)
                                                if len(contentMapped) > 0 {
       @@ -1212,7 +1097,7 @@ func pickOneWriteOrCreatePath(events []fsnotify.Event) string {
        
        // isThemeVsHugoVersionMismatch returns whether the current Hugo version is
        // less than the theme's min_version.
       -func (c *commandeer) isThemeVsHugoVersionMismatch() (mismatch bool, requiredMinVersion string) {
       +func (c *commandeer) isThemeVsHugoVersionMismatch(fs afero.Fs) (mismatch bool, requiredMinVersion string) {
                if !c.PathSpec().ThemeSet() {
                        return
                }
       @@ -1221,13 +1106,13 @@ func (c *commandeer) isThemeVsHugoVersionMismatch() (mismatch bool, requiredMinV
        
                path := filepath.Join(themeDir, "theme.toml")
        
       -        exists, err := helpers.Exists(path, c.Fs.Source)
       +        exists, err := helpers.Exists(path, fs)
        
                if err != nil || !exists {
                        return
                }
        
       -        b, err := afero.ReadFile(c.Fs.Source, path)
       +        b, err := afero.ReadFile(fs, path)
        
                tomlMeta, err := parser.HandleTOMLMetaData(b)
        
   DIR diff --git a/commands/server.go b/commands/server.go
       @@ -24,6 +24,7 @@ import (
                "runtime"
                "strconv"
                "strings"
       +        "sync"
                "syscall"
                "time"
        
       @@ -111,12 +112,16 @@ func init() {
        
        }
        
       +var serverPorts []int
       +
        func server(cmd *cobra.Command, args []string) error {
                // If a Destination is provided via flag write to disk
                if destination != "" {
                        renderToDisk = true
                }
        
       +        var serverCfgInit sync.Once
       +
                cfgInit := func(c *commandeer) error {
                        c.Set("renderToMemory", !renderToDisk)
                        if cmd.Flags().Changed("navigateToChanged") {
       @@ -132,37 +137,42 @@ func server(cmd *cobra.Command, args []string) error {
                                c.Set("watch", true)
                        }
        
       -                serverPorts := make([]int, 1)
       +                var err error
        
       -                if c.languages.IsMultihost() {
       -                        if !serverAppend {
       -                                return newSystemError("--appendPort=false not supported when in multihost mode")
       +                // We can only do this once.
       +                serverCfgInit.Do(func() {
       +                        serverPorts = make([]int, 1)
       +
       +                        if c.languages.IsMultihost() {
       +                                if !serverAppend {
       +                                        err = newSystemError("--appendPort=false not supported when in multihost mode")
       +                                }
       +                                serverPorts = make([]int, len(c.languages))
                                }
       -                        serverPorts = make([]int, len(c.languages))
       -                }
        
       -                currentServerPort := serverPort
       +                        currentServerPort := serverPort
        
       -                for i := 0; i < len(serverPorts); i++ {
       -                        l, err := net.Listen("tcp", net.JoinHostPort(serverInterface, strconv.Itoa(currentServerPort)))
       -                        if err == nil {
       -                                l.Close()
       -                                serverPorts[i] = currentServerPort
       -                        } else {
       -                                if i == 0 && serverCmd.Flags().Changed("port") {
       -                                        // port set explicitly by user -- he/she probably meant it!
       -                                        return newSystemErrorF("Server startup failed: %s", err)
       -                                }
       -                                jww.ERROR.Println("port", serverPort, "already in use, attempting to use an available port")
       -                                sp, err := helpers.FindAvailablePort()
       -                                if err != nil {
       -                                        return newSystemError("Unable to find alternative port to use:", err)
       +                        for i := 0; i < len(serverPorts); i++ {
       +                                l, err := net.Listen("tcp", net.JoinHostPort(serverInterface, strconv.Itoa(currentServerPort)))
       +                                if err == nil {
       +                                        l.Close()
       +                                        serverPorts[i] = currentServerPort
       +                                } else {
       +                                        if i == 0 && serverCmd.Flags().Changed("port") {
       +                                                // port set explicitly by user -- he/she probably meant it!
       +                                                err = newSystemErrorF("Server startup failed: %s", err)
       +                                        }
       +                                        jww.ERROR.Println("port", serverPort, "already in use, attempting to use an available port")
       +                                        sp, err := helpers.FindAvailablePort()
       +                                        if err != nil {
       +                                                err = newSystemError("Unable to find alternative port to use:", err)
       +                                        }
       +                                        serverPorts[i] = sp.Port
                                        }
       -                                serverPorts[i] = sp.Port
       -                        }
        
       -                        currentServerPort = serverPorts[i] + 1
       -                }
       +                                currentServerPort = serverPorts[i] + 1
       +                        }
       +                })
        
                        c.serverPorts = serverPorts
        
       @@ -184,7 +194,7 @@ func server(cmd *cobra.Command, args []string) error {
        
                                baseURL, err := fixURL(language, baseURL, serverPort)
                                if err != nil {
       -                                return err
       +                                return nil
                                }
                                if isMultiHost {
                                        language.Set("baseURL", baseURL)
       @@ -194,7 +204,7 @@ func server(cmd *cobra.Command, args []string) error {
                                }
                        }
        
       -                return nil
       +                return err
        
                }
        
       @@ -215,10 +225,6 @@ func server(cmd *cobra.Command, args []string) error {
                        s.RegisterMediaTypes()
                }
        
       -        if serverWatch {
       -                c.watchConfig()
       -        }
       -
                // Watch runs its own server as part of the routine
                if serverWatch {
        
   DIR diff --git a/helpers/path.go b/helpers/path.go
       @@ -154,11 +154,16 @@ func ReplaceExtension(path string, newExt string) string {
        // AbsPathify creates an absolute path if given a relative path. If already
        // absolute, the path is just cleaned.
        func (p *PathSpec) AbsPathify(inPath string) string {
       +        return AbsPathify(p.workingDir, inPath)
       +}
       +
       +// AbsPathify creates an absolute path if given a working dir and arelative path.
       +// If already absolute, the path is just cleaned.
       +func AbsPathify(workingDir, inPath string) string {
                if filepath.IsAbs(inPath) {
                        return filepath.Clean(inPath)
                }
       -
       -        return filepath.Join(p.workingDir, inPath)
       +        return filepath.Join(workingDir, inPath)
        }
        
        // GetLayoutDirPath returns the absolute path to the layout file dir
   DIR diff --git a/hugolib/case_insensitive_test.go b/hugolib/case_insensitive_test.go
       @@ -149,7 +149,7 @@ func TestCaseInsensitiveConfigurationVariations(t *testing.T) {
        
                caseMixingTestsWriteCommonSources(t, mm)
        
       -        cfg, err := LoadConfig(ConfigSourceDescriptor{Fs: mm})
       +        cfg, _, err := LoadConfig(ConfigSourceDescriptor{Fs: mm, Filename: "config.toml"})
                require.NoError(t, err)
        
                fs := hugofs.NewFrom(mm, cfg)
   DIR diff --git a/hugolib/config.go b/hugolib/config.go
       @@ -16,6 +16,7 @@ package hugolib
        import (
                "errors"
                "fmt"
       +        "path/filepath"
        
                "io"
                "strings"
       @@ -28,64 +29,91 @@ import (
        
        // ConfigSourceDescriptor describes where to find the config (e.g. config.toml etc.).
        type ConfigSourceDescriptor struct {
       -        Fs   afero.Fs
       -        Src  string
       -        Name string
       +        Fs afero.Fs
       +
       +        // Full path to the config file to use, i.e. /my/project/config.toml
       +        Filename string
       +
       +        // The path to the directory to look for configuration. Is used if Filename is not
       +        // set.
       +        Path string
       +
       +        // The project's working dir. Is used to look for additional theme config.
       +        WorkingDir string
        }
        
        func (d ConfigSourceDescriptor) configFilenames() []string {
       -        return strings.Split(d.Name, ",")
       +        return strings.Split(d.Filename, ",")
        }
        
        // LoadConfigDefault is a convenience method to load the default "config.toml" config.
        func LoadConfigDefault(fs afero.Fs) (*viper.Viper, error) {
       -        return LoadConfig(ConfigSourceDescriptor{Fs: fs, Name: "config.toml"})
       +        v, _, err := LoadConfig(ConfigSourceDescriptor{Fs: fs, Filename: "config.toml"})
       +        return v, err
        }
        
        // LoadConfig loads Hugo configuration into a new Viper and then adds
        // a set of defaults.
       -func LoadConfig(d ConfigSourceDescriptor) (*viper.Viper, error) {
       +func LoadConfig(d ConfigSourceDescriptor) (*viper.Viper, []string, error) {
       +        var configFiles []string
       +
                fs := d.Fs
                v := viper.New()
                v.SetFs(fs)
        
       -        if d.Name == "" {
       -                d.Name = "config.toml"
       -        }
       -
       -        if d.Src == "" {
       -                d.Src = "."
       +        if d.Path == "" {
       +                d.Path = "."
                }
        
                configFilenames := d.configFilenames()
                v.AutomaticEnv()
                v.SetEnvPrefix("hugo")
                v.SetConfigFile(configFilenames[0])
       -        v.AddConfigPath(d.Src)
       +        v.AddConfigPath(d.Path)
        
                err := v.ReadInConfig()
                if err != nil {
                        if _, ok := err.(viper.ConfigParseError); ok {
       -                        return nil, err
       +                        return nil, configFiles, err
                        }
       -                return nil, fmt.Errorf("Unable to locate Config file. Perhaps you need to create a new site.\n       Run `hugo help new` for details. (%s)\n", err)
       +                return nil, configFiles, fmt.Errorf("Unable to locate Config file. Perhaps you need to create a new site.\n       Run `hugo help new` for details. (%s)\n", err)
       +        }
       +
       +        if cf := v.ConfigFileUsed(); cf != "" {
       +                configFiles = append(configFiles, cf)
                }
       +
                for _, configFile := range configFilenames[1:] {
                        var r io.Reader
                        var err error
                        if r, err = fs.Open(configFile); err != nil {
       -                        return nil, fmt.Errorf("Unable to open Config file.\n (%s)\n", err)
       +                        return nil, configFiles, fmt.Errorf("Unable to open Config file.\n (%s)\n", err)
                        }
                        if err = v.MergeConfig(r); err != nil {
       -                        return nil, fmt.Errorf("Unable to parse/merge Config file (%s).\n (%s)\n", configFile, err)
       +                        return nil, configFiles, fmt.Errorf("Unable to parse/merge Config file (%s).\n (%s)\n", configFile, err)
                        }
       +                configFiles = append(configFiles, configFile)
                }
        
                if err := loadDefaultSettingsFor(v); err != nil {
       -                return v, err
       +                return v, configFiles, err
                }
        
       -        return v, nil
       +        themeConfigFile, err := loadThemeConfig(d, v)
       +        if err != nil {
       +                return v, configFiles, err
       +        }
       +
       +        if themeConfigFile != "" {
       +                configFiles = append(configFiles, themeConfigFile)
       +        }
       +
       +        if err := loadLanguageSettings(v, nil); err != nil {
       +                return v, configFiles, err
       +        }
       +
       +        return v, configFiles, nil
       +
        }
        
        func loadLanguageSettings(cfg config.Provider, oldLangs helpers.Languages) error {
       @@ -201,6 +229,142 @@ func loadLanguageSettings(cfg config.Provider, oldLangs helpers.Languages) error
                return nil
        }
        
       +func loadThemeConfig(d ConfigSourceDescriptor, v1 *viper.Viper) (string, error) {
       +
       +        theme := v1.GetString("theme")
       +        if theme == "" {
       +                return "", nil
       +        }
       +
       +        themesDir := helpers.AbsPathify(d.WorkingDir, v1.GetString("themesDir"))
       +        configDir := filepath.Join(themesDir, theme)
       +
       +        var (
       +                configPath string
       +                exists     bool
       +                err        error
       +        )
       +
       +        // Viper supports more, but this is the sub-set supported by Hugo.
       +        for _, configFormats := range []string{"toml", "yaml", "yml", "json"} {
       +                configPath = filepath.Join(configDir, "config."+configFormats)
       +                exists, err = helpers.Exists(configPath, d.Fs)
       +                if err != nil {
       +                        return "", err
       +                }
       +                if exists {
       +                        break
       +                }
       +        }
       +
       +        if !exists {
       +                // No theme config set.
       +                return "", nil
       +        }
       +
       +        v2 := viper.New()
       +        v2.SetFs(d.Fs)
       +        v2.AutomaticEnv()
       +        v2.SetEnvPrefix("hugo")
       +        v2.SetConfigFile(configPath)
       +
       +        err = v2.ReadInConfig()
       +        if err != nil {
       +                return "", err
       +        }
       +
       +        const (
       +                paramsKey    = "params"
       +                languagesKey = "languages"
       +                menuKey      = "menu"
       +        )
       +
       +        for _, key := range []string{paramsKey, "outputformats", "mediatypes"} {
       +                mergeStringMapKeepLeft("", key, v1, v2)
       +        }
       +
       +        themeLower := strings.ToLower(theme)
       +        themeParamsNamespace := paramsKey + "." + themeLower
       +
       +        // Set namespaced params
       +        if v2.IsSet(paramsKey) && !v1.IsSet(themeParamsNamespace) {
       +                // Set it in the default store to make sure it gets in the same or
       +                // behind the others.
       +                v1.SetDefault(themeParamsNamespace, v2.Get(paramsKey))
       +        }
       +
       +        // Only add params and new menu entries, we do not add language definitions.
       +        if v1.IsSet(languagesKey) && v2.IsSet(languagesKey) {
       +                v1Langs := v1.GetStringMap(languagesKey)
       +                for k, _ := range v1Langs {
       +                        langParamsKey := languagesKey + "." + k + "." + paramsKey
       +                        mergeStringMapKeepLeft(paramsKey, langParamsKey, v1, v2)
       +                }
       +                v2Langs := v2.GetStringMap(languagesKey)
       +                for k, _ := range v2Langs {
       +                        if k == "" {
       +                                continue
       +                        }
       +                        langParamsKey := languagesKey + "." + k + "." + paramsKey
       +                        langParamsThemeNamespace := langParamsKey + "." + themeLower
       +                        // Set namespaced params
       +                        if v2.IsSet(langParamsKey) && !v1.IsSet(langParamsThemeNamespace) {
       +                                v1.SetDefault(langParamsThemeNamespace, v2.Get(langParamsKey))
       +                        }
       +
       +                        langMenuKey := languagesKey + "." + k + "." + menuKey
       +                        if v2.IsSet(langMenuKey) {
       +                                // Only add if not in the main config.
       +                                v2menus := v2.GetStringMap(langMenuKey)
       +                                for k, v := range v2menus {
       +                                        menuEntry := menuKey + "." + k
       +                                        menuLangEntry := langMenuKey + "." + k
       +                                        if !v1.IsSet(menuEntry) && !v1.IsSet(menuLangEntry) {
       +                                                v1.Set(menuLangEntry, v)
       +                                        }
       +                                }
       +                        }
       +                }
       +        }
       +
       +        // Add menu definitions from theme not found in project
       +        if v2.IsSet("menu") {
       +                v2menus := v2.GetStringMap(menuKey)
       +                for k, v := range v2menus {
       +                        menuEntry := menuKey + "." + k
       +                        if !v1.IsSet(menuEntry) {
       +                                v1.SetDefault(menuEntry, v)
       +                        }
       +                }
       +        }
       +
       +        return v2.ConfigFileUsed(), nil
       +
       +}
       +
       +func mergeStringMapKeepLeft(rootKey, key string, v1, v2 *viper.Viper) {
       +        if !v2.IsSet(key) {
       +                return
       +        }
       +
       +        if !v1.IsSet(key) && !(rootKey != "" && rootKey != key && v1.IsSet(rootKey)) {
       +                v1.Set(key, v2.Get(key))
       +                return
       +        }
       +
       +        m1 := v1.GetStringMap(key)
       +        m2 := v2.GetStringMap(key)
       +
       +        for k, v := range m2 {
       +                if _, found := m1[k]; !found {
       +                        if rootKey != "" && v1.IsSet(rootKey+"."+k) {
       +                                continue
       +                        }
       +                        m1[k] = v
       +                }
       +        }
       +}
       +
        func loadDefaultSettingsFor(v *viper.Viper) error {
        
                c, err := helpers.NewContentSpec(v)
       @@ -281,5 +445,5 @@ lastmod = ["lastmod" ,":fileModTime", ":default"]
        
                }
        
       -        return loadLanguageSettings(v, nil)
       +        return nil
        }
   DIR diff --git a/hugolib/config_test.go b/hugolib/config_test.go
       @@ -17,13 +17,15 @@ import (
                "testing"
        
                "github.com/spf13/afero"
       -        "github.com/stretchr/testify/assert"
       +        "github.com/spf13/viper"
                "github.com/stretchr/testify/require"
        )
        
        func TestLoadConfig(t *testing.T) {
                t.Parallel()
        
       +        assert := require.New(t)
       +
                // Add a random config variable for testing.
                // side = page in Norwegian.
                configContent := `
       @@ -34,16 +36,19 @@ func TestLoadConfig(t *testing.T) {
        
                writeToFs(t, mm, "hugo.toml", configContent)
        
       -        cfg, err := LoadConfig(ConfigSourceDescriptor{Fs: mm, Name: "hugo.toml"})
       +        cfg, _, err := LoadConfig(ConfigSourceDescriptor{Fs: mm, Filename: "hugo.toml"})
                require.NoError(t, err)
        
       -        assert.Equal(t, "side", cfg.GetString("paginatePath"))
       +        assert.Equal("side", cfg.GetString("paginatePath"))
                // default
       -        assert.Equal(t, "layouts", cfg.GetString("layoutDir"))
       +        assert.Equal("layouts", cfg.GetString("layoutDir"))
        }
       +
        func TestLoadMultiConfig(t *testing.T) {
                t.Parallel()
        
       +        assert := require.New(t)
       +
                // Add a random config variable for testing.
                // side = page in Norwegian.
                configContentBase := `
       @@ -59,9 +64,304 @@ func TestLoadMultiConfig(t *testing.T) {
        
                writeToFs(t, mm, "override.toml", configContentSub)
        
       -        cfg, err := LoadConfig(ConfigSourceDescriptor{Fs: mm, Name: "base.toml,override.toml"})
       +        cfg, _, err := LoadConfig(ConfigSourceDescriptor{Fs: mm, Filename: "base.toml,override.toml"})
                require.NoError(t, err)
        
       -        assert.Equal(t, "top", cfg.GetString("paginatePath"))
       -        assert.Equal(t, "same", cfg.GetString("DontChange"))
       +        assert.Equal("top", cfg.GetString("paginatePath"))
       +        assert.Equal("same", cfg.GetString("DontChange"))
       +}
       +
       +func TestLoadConfigFromTheme(t *testing.T) {
       +        t.Parallel()
       +
       +        assert := require.New(t)
       +
       +        mainConfigBasic := `
       +theme = "test-theme"
       +baseURL = "https://example.com/"
       +
       +`
       +        mainConfig := `
       +theme = "test-theme"
       +baseURL = "https://example.com/"
       +
       +[frontmatter]
       +date = ["date","publishDate"]
       +
       +[params]
       +p1 = "p1 main"
       +p2 = "p2 main"
       +top = "top"
       +
       +[mediaTypes]
       +[mediaTypes."text/m1"]
       +suffix = "m1main"
       +
       +[outputFormats.o1]
       +mediaType = "text/m1"
       +baseName = "o1main"
       +
       +[languages]
       +[languages.en]
       +languageName = "English"
       +[languages.en.params]
       +pl1 = "p1-en-main"
       +[languages.nb]
       +languageName = "Norsk"
       +[languages.nb.params]
       +pl1 = "p1-nb-main"
       +
       +[[menu.main]]
       +name = "menu-main-main"
       +
       +[[menu.top]]
       +name = "menu-top-main"
       +
       +`
       +
       +        themeConfig := `
       +baseURL = "http://bep.is/"
       +
       +# Can not be set in theme.
       +[frontmatter]
       +expiryDate = ["date"]
       +
       +[params]
       +p1 = "p1 theme"
       +p2 = "p2 theme"
       +p3 = "p3 theme"
       +
       +[mediaTypes]
       +[mediaTypes."text/m1"]
       +suffix = "m1theme"
       +[mediaTypes."text/m2"]
       +suffix = "m2theme"
       +
       +[outputFormats.o1]
       +mediaType = "text/m1"
       +baseName = "o1theme"
       +[outputFormats.o2]
       +mediaType = "text/m2"
       +baseName = "o2theme"
       +
       +[languages]
       +[languages.en]
       +languageName = "English2"
       +[languages.en.params]
       +pl1 = "p1-en-theme"
       +pl2 = "p2-en-theme"
       +[[languages.en.menu.main]]
       +name   = "menu-lang-en-main"
       +[[languages.en.menu.theme]]
       +name   = "menu-lang-en-theme"
       +[languages.nb]
       +languageName = "Norsk2"
       +[languages.nb.params]
       +pl1 = "p1-nb-theme"
       +pl2 = "p2-nb-theme"
       +top = "top-nb-theme"
       +[[languages.nb.menu.main]]
       +name   = "menu-lang-nb-main"
       +[[languages.nb.menu.theme]]
       +name   = "menu-lang-nb-theme"
       +[[languages.nb.menu.top]]
       +name   = "menu-lang-nb-top"
       +
       +[[menu.main]]
       +name = "menu-main-theme"
       +
       +[[menu.thememenu]]
       +name = "menu-theme"
       +
       +`
       +
       +        b := newTestSitesBuilder(t)
       +        b.WithConfigFile("toml", mainConfig).WithThemeConfigFile("toml", themeConfig)
       +        b.CreateSites().Build(BuildCfg{})
       +
       +        got := b.Cfg.(*viper.Viper).AllSettings()
       +
       +        b.AssertObject(`
       +map[string]interface {}{
       +  "p1": "p1 main",
       +  "p2": "p2 main",
       +  "p3": "p3 theme",
       +  "test-theme": map[string]interface {}{
       +    "p1": "p1 theme",
       +    "p2": "p2 theme",
       +    "p3": "p3 theme",
       +  },
       +  "top": "top",
       +}`, got["params"])
       +
       +        b.AssertObject(`
       +map[string]interface {}{
       +  "date": []interface {}{
       +    "date",
       +    "publishDate",
       +  },
       +}`, got["frontmatter"])
       +
       +        b.AssertObject(`
       +map[string]interface {}{
       +  "text/m1": map[string]interface {}{
       +    "suffix": "m1main",
       +  },
       +  "text/m2": map[string]interface {}{
       +    "suffix": "m2theme",
       +  },
       +}`, got["mediatypes"])
       +
       +        b.AssertObject(`
       +map[string]interface {}{
       +  "o1": map[string]interface {}{
       +    "basename": "o1main",
       +    "mediatype": Type{
       +      MainType: "text",
       +      SubType: "m1",
       +      Suffix: "m1main",
       +      Delimiter: ".",
       +    },
       +  },
       +  "o2": map[string]interface {}{
       +    "basename": "o2theme",
       +    "mediatype": Type{
       +      MainType: "text",
       +      SubType: "m2",
       +      Suffix: "m2theme",
       +      Delimiter: ".",
       +    },
       +  },
       +}`, got["outputformats"])
       +
       +        b.AssertObject(`map[string]interface {}{
       +  "en": map[string]interface {}{
       +    "languagename": "English",
       +    "menu": map[string]interface {}{
       +      "theme": []interface {}{
       +        map[string]interface {}{
       +          "name": "menu-lang-en-theme",
       +        },
       +      },
       +    },
       +    "params": map[string]interface {}{
       +      "pl1": "p1-en-main",
       +      "pl2": "p2-en-theme",
       +      "test-theme": map[string]interface {}{
       +        "pl1": "p1-en-theme",
       +        "pl2": "p2-en-theme",
       +      },
       +    },
       +  },
       +  "nb": map[string]interface {}{
       +    "languagename": "Norsk",
       +    "menu": map[string]interface {}{
       +      "theme": []interface {}{
       +        map[string]interface {}{
       +          "name": "menu-lang-nb-theme",
       +        },
       +      },
       +    },
       +    "params": map[string]interface {}{
       +      "pl1": "p1-nb-main",
       +      "pl2": "p2-nb-theme",
       +      "test-theme": map[string]interface {}{
       +        "pl1": "p1-nb-theme",
       +        "pl2": "p2-nb-theme",
       +        "top": "top-nb-theme",
       +      },
       +    },
       +  },
       +}
       +`, got["languages"])
       +
       +        b.AssertObject(`
       +map[string]interface {}{
       +  "main": []interface {}{
       +    map[string]interface {}{
       +      "name": "menu-main-main",
       +    },
       +  },
       +  "thememenu": []interface {}{
       +    map[string]interface {}{
       +      "name": "menu-theme",
       +    },
       +  },
       +  "top": []interface {}{
       +    map[string]interface {}{
       +      "name": "menu-top-main",
       +    },
       +  },
       +}
       +`, got["menu"])
       +
       +        assert.Equal("https://example.com/", got["baseurl"])
       +
       +        if true {
       +                return
       +        }
       +        // Test variants with only values from theme
       +        b = newTestSitesBuilder(t)
       +        b.WithConfigFile("toml", mainConfigBasic).WithThemeConfigFile("toml", themeConfig)
       +        b.CreateSites().Build(BuildCfg{})
       +
       +        got = b.Cfg.(*viper.Viper).AllSettings()
       +
       +        b.AssertObject(`map[string]interface {}{
       +  "p1": "p1 theme",
       +  "p2": "p2 theme",
       +  "p3": "p3 theme",
       +  "test-theme": map[string]interface {}{
       +    "p1": "p1 theme",
       +    "p2": "p2 theme",
       +    "p3": "p3 theme",
       +  },
       +}`, got["params"])
       +
       +        assert.Nil(got["languages"])
       +        b.AssertObject(`
       +map[string]interface {}{
       +  "text/m1": map[string]interface {}{
       +    "suffix": "m1theme",
       +  },
       +  "text/m2": map[string]interface {}{
       +    "suffix": "m2theme",
       +  },
       +}`, got["mediatypes"])
       +
       +        b.AssertObject(`
       +map[string]interface {}{
       +  "o1": map[string]interface {}{
       +    "basename": "o1theme",
       +    "mediatype": Type{
       +      MainType: "text",
       +      SubType: "m1",
       +      Suffix: "m1theme",
       +      Delimiter: ".",
       +    },
       +  },
       +  "o2": map[string]interface {}{
       +    "basename": "o2theme",
       +    "mediatype": Type{
       +      MainType: "text",
       +      SubType: "m2",
       +      Suffix: "m2theme",
       +      Delimiter: ".",
       +    },
       +  },
       +}`, got["outputformats"])
       +        b.AssertObject(`
       +map[string]interface {}{
       +  "main": []interface {}{
       +    map[string]interface {}{
       +      "name": "menu-main-theme",
       +    },
       +  },
       +  "thememenu": []interface {}{
       +    map[string]interface {}{
       +      "name": "menu-theme",
       +    },
       +  },
       +}`, got["menu"])
       +
        }
   DIR diff --git a/hugolib/page_bundler_test.go b/hugolib/page_bundler_test.go
       @@ -200,6 +200,7 @@ func TestPageBundlerSiteMultilingual(t *testing.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))
       @@ -264,6 +265,8 @@ func TestMultilingualDisableDefaultLanguage(t *testing.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")
        }
   DIR diff --git a/hugolib/site.go b/hugolib/site.go
       @@ -296,6 +296,7 @@ func NewSite(cfg deps.DepsCfg) (*Site, error) {
        // NewSiteDefaultLang creates a new site in the default language.
        // The site will have a template system loaded and ready to use.
        // Note: This is mainly used in single site tests.
       +// TODO(bep) test refactor -- remove
        func NewSiteDefaultLang(withTemplate ...func(templ tpl.TemplateHandler) error) (*Site, error) {
                v := viper.New()
                if err := loadDefaultSettingsFor(v); err != nil {
       @@ -307,6 +308,7 @@ func NewSiteDefaultLang(withTemplate ...func(templ tpl.TemplateHandler) error) (
        // NewEnglishSite creates a new site in English language.
        // The site will have a template system loaded and ready to use.
        // Note: This is mainly used in single site tests.
       +// TODO(bep) test refactor -- remove
        func NewEnglishSite(withTemplate ...func(templ tpl.TemplateHandler) error) (*Site, error) {
                v := viper.New()
                if err := loadDefaultSettingsFor(v); err != nil {
   DIR diff --git a/hugolib/testhelpers_test.go b/hugolib/testhelpers_test.go
       @@ -10,6 +10,8 @@ import (
                "strings"
                "text/template"
        
       +        "github.com/sanity-io/litter"
       +
                jww "github.com/spf13/jwalterweatherman"
        
                "github.com/gohugoio/hugo/config"
       @@ -37,11 +39,15 @@ type sitesBuilder struct {
                Fs  *hugofs.Fs
                T   testing.TB
        
       +        dumper litter.Options
       +
                // Aka the Hugo server mode.
                running bool
        
                H *HugoSites
        
       +        theme string
       +
                // Default toml
                configFormat string
        
       @@ -63,7 +69,13 @@ func newTestSitesBuilder(t testing.TB) *sitesBuilder {
                v := viper.New()
                fs := hugofs.NewMem(v)
        
       -        return &sitesBuilder{T: t, Fs: fs, configFormat: "toml"}
       +        litterOptions := litter.Options{
       +                HidePrivateFields: true,
       +                StripPackageNames: true,
       +                Separator:         " ",
       +        }
       +
       +        return &sitesBuilder{T: t, Fs: fs, configFormat: "toml", dumper: litterOptions}
        }
        
        func (s *sitesBuilder) Running() *sitesBuilder {
       @@ -97,6 +109,15 @@ func (s *sitesBuilder) WithConfigFile(format, conf string) *sitesBuilder {
                return s
        }
        
       +func (s *sitesBuilder) WithThemeConfigFile(format, conf string) *sitesBuilder {
       +        if s.theme == "" {
       +                s.theme = "test-theme"
       +        }
       +        filename := filepath.Join("themes", s.theme, "config."+format)
       +        writeSource(s.T, s.Fs, filename, conf)
       +        return s
       +}
       +
        func (s *sitesBuilder) WithSimpleConfigFile() *sitesBuilder {
                var config = `
        baseURL = "http://example.com/"
       @@ -229,10 +250,15 @@ func (s *sitesBuilder) CreateSites() *sitesBuilder {
                s.writeFilePairs("i18n", s.i18nFilePairsAdded)
        
                if s.Cfg == nil {
       -                cfg, err := LoadConfig(ConfigSourceDescriptor{Fs: s.Fs.Source, Name: "config." + s.configFormat})
       +                cfg, configFiles, err := LoadConfig(ConfigSourceDescriptor{Fs: s.Fs.Source, Filename: "config." + s.configFormat})
                        if err != nil {
                                s.Fatalf("Failed to load config: %s", err)
                        }
       +                expectedConfigs := 1
       +                if s.theme != "" {
       +                        expectedConfigs = 2
       +                }
       +                require.Equal(s.T, expectedConfigs, len(configFiles), fmt.Sprintf("Configs: %v", configFiles))
                        s.Cfg = cfg
                }
        
       @@ -345,6 +371,17 @@ func (s *sitesBuilder) AssertFileContent(filename string, matches ...string) {
                }
        }
        
       +func (s *sitesBuilder) AssertObject(expected string, object interface{}) {
       +        got := s.dumper.Sdump(object)
       +        expected = strings.TrimSpace(expected)
       +
       +        if expected != got {
       +                fmt.Println(got)
       +                diff := helpers.DiffStrings(expected, got)
       +                s.Fatalf("diff:\n%s\nexpected\n%s\ngot\n%s", diff, expected, got)
       +        }
       +}
       +
        func (s *sitesBuilder) AssertFileContentRe(filename string, matches ...string) {
                content := readDestination(s.T, s.Fs, filename)
                for _, match := range matches {