shithub: hugo

Download patch

ref: e9c7b6205f94a7edac0e0df2cd18d1456cb26a06
parent: 3d1a6e109ce9b25fc2e9731098a82fb4c0abff68
author: Bjørn Erik Pedersen <[email protected]>
date: Sun Mar 18 07:07:24 EDT 2018

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

--- 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
--- 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"
--- 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 @@
 
 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 @@
 	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()
+	}
 
-	if l, ok := cfg.Cfg.Get("languagesSorted").(helpers.Languages); ok {
-		languages = l
+	var sourceFs afero.Fs = hugofs.Os
+	if c.DepsCfg.Fs != nil {
+		sourceFs = c.DepsCfg.Fs.Source
 	}
 
-	c := &commandeer{DepsCfg: cfg, languages: languages, visitedURLs: types.NewEvictingStringQueue(10)}
+	config, configFiles, err := hugolib.LoadConfig(hugolib.ConfigSourceDescriptor{Fs: sourceFs, Path: source, WorkingDir: dir, Filename: cfgFile})
+	if err != nil {
+		return err
+	}
 
-	return c, nil
+	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 baseURL != "" {
+		config.Set("baseURL", baseURL)
+	}
+
+	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
+
 }
--- a/commands/hugo.go
+++ b/commands/hugo.go
@@ -25,8 +25,6 @@
 
 	"golang.org/x/sync/errgroup"
 
-	"github.com/gohugoio/hugo/hugofs"
-
 	"log"
 	"os"
 	"path/filepath"
@@ -44,7 +42,6 @@
 	"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 @@
 	"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 @@
 			return err
 		}
 
-		if buildWatch {
-			c.watchConfig()
-		}
-
 		return c.build()
 	},
 }
@@ -301,129 +293,11 @@
 // 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})
+	c, err := newCommandeer(running, doWithCommandeer, subCmdVs...)
 	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)
-	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 @@
 	}
 }
 
-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) initSites() error {
 	if Hugo != nil {
+		Hugo.Cfg = c.Cfg
 		Hugo.Log.ResetLogCounters()
 		return nil
 	}
@@ -1009,6 +870,15 @@
 		}
 	}
 
+	// 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 @@
 				// 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 @@
 
 // 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 @@
 
 	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)
 
--- a/commands/server.go
+++ b/commands/server.go
@@ -24,6 +24,7 @@
 	"runtime"
 	"strconv"
 	"strings"
+	"sync"
 	"syscall"
 	"time"
 
@@ -111,6 +112,8 @@
 
 }
 
+var serverPorts []int
+
 func server(cmd *cobra.Command, args []string) error {
 	// If a Destination is provided via flag write to disk
 	if destination != "" {
@@ -117,6 +120,8 @@
 		renderToDisk = true
 	}
 
+	var serverCfgInit sync.Once
+
 	cfgInit := func(c *commandeer) error {
 		c.Set("renderToMemory", !renderToDisk)
 		if cmd.Flags().Changed("navigateToChanged") {
@@ -132,38 +137,43 @@
 			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)
+			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
 				}
-				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)
-				}
-				serverPorts[i] = sp.Port
+
+				currentServerPort = serverPorts[i] + 1
 			}
+		})
 
-			currentServerPort = serverPorts[i] + 1
-		}
-
 		c.serverPorts = serverPorts
 
 		c.Set("port", serverPort)
@@ -184,7 +194,7 @@
 
 			baseURL, err := fixURL(language, baseURL, serverPort)
 			if err != nil {
-				return err
+				return nil
 			}
 			if isMultiHost {
 				language.Set("baseURL", baseURL)
@@ -194,7 +204,7 @@
 			}
 		}
 
-		return nil
+		return err
 
 	}
 
@@ -213,10 +223,6 @@
 
 	for _, s := range Hugo.Sites {
 		s.RegisterMediaTypes()
-	}
-
-	if serverWatch {
-		c.watchConfig()
 	}
 
 	// Watch runs its own server as part of the routine
--- a/helpers/path.go
+++ b/helpers/path.go
@@ -154,11 +154,16 @@
 // 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
--- a/hugolib/case_insensitive_test.go
+++ b/hugolib/case_insensitive_test.go
@@ -149,7 +149,7 @@
 
 	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)
--- a/hugolib/config.go
+++ b/hugolib/config.go
@@ -16,6 +16,7 @@
 import (
 	"errors"
 	"fmt"
+	"path/filepath"
 
 	"io"
 	"strings"
@@ -28,64 +29,91 @@
 
 // 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.Path == "" {
+		d.Path = "."
 	}
 
-	if d.Src == "" {
-		d.Src = "."
-	}
-
 	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 @@
 	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 @@
 
 	}
 
-	return loadLanguageSettings(v, nil)
+	return nil
 }
--- a/hugolib/config_test.go
+++ b/hugolib/config_test.go
@@ -17,7 +17,7 @@
 	"testing"
 
 	"github.com/spf13/afero"
-	"github.com/stretchr/testify/assert"
+	"github.com/spf13/viper"
 	"github.com/stretchr/testify/require"
 )
 
@@ -24,6 +24,8 @@
 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 @@
 
 	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 @@
 
 	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"])
+
 }
--- a/hugolib/page_bundler_test.go
+++ b/hugolib/page_bundler_test.go
@@ -200,6 +200,7 @@
 				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 @@
 	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")
 }
--- a/hugolib/site.go
+++ b/hugolib/site.go
@@ -296,6 +296,7 @@
 // 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 @@
 // 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 {
--- a/hugolib/testhelpers_test.go
+++ b/hugolib/testhelpers_test.go
@@ -10,6 +10,8 @@
 	"strings"
 	"text/template"
 
+	"github.com/sanity-io/litter"
+
 	jww "github.com/spf13/jwalterweatherman"
 
 	"github.com/gohugoio/hugo/config"
@@ -37,11 +39,15 @@
 	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 @@
 	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 @@
 	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 @@
 	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
 	}
 
@@ -342,6 +368,17 @@
 		if !strings.Contains(content, match) {
 			s.Fatalf("No match for %q in content for %s\n%q", match, filename, content)
 		}
+	}
+}
+
+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)
 	}
 }