shithub: hugo

Download patch

ref: 7829474088f835251f04caa1121d47e35fe89f7e
parent: 256418917c6642f7e5b3d3206ff4b6fa03b1cb28
author: Bjørn Erik Pedersen <[email protected]>
date: Thu Nov 15 04:28:02 EST 2018

Add /config dir support

This commit adds support for a configuration directory (default `config`). The different pieces in this puzzle are:

* A new `--environment` (or `-e`) flag. This can also be set with the `HUGO_ENVIRONMENT` OS environment variable. The value for `environment` defaults to `production` when running `hugo` and `development` when running `hugo server`. You can set it to any value you want (e.g. `hugo server -e "Sensible Environment"`), but as it is used to load configuration from the file system, the letter case may be important. You can get this value in your templates with `{{ hugo.Environment }}`.
* A new `--configDir` flag (defaults to `config` below your project). This can also be set with `HUGO_CONFIGDIR` OS environment variable.

If the `configDir` exists, the configuration files will be read and merged on top of each other from left to right; the right-most value will win on duplicates.

Given the example tree below:

If `environment` is `production`, the left-most `config.toml` would be the one directly below the project (this can now be omitted if you want), and then `_default/config.toml` and finally `production/config.toml`. And since these will be merged, you can just provide the environment specific configuration setting in you production config, e.g. `enableGitInfo = true`. The order within the directories will be lexical (`config.toml` and then `params.toml`).

```bash
config
├── _default
│   ├── config.toml
│   ├── languages.toml
│   ├── menus
│   │   ├── menus.en.toml
│   │   └── menus.zh.toml
│   └── params.toml
├── development
│   └── params.toml
└── production
    ├── config.toml
    └── params.toml
```

Some configuration maps support the language code in the filename (e.g. `menus.en.toml`): `menus` (`menu` also works) and `params`.

Also note that the only folders with "a meaning" in the above listing is the top level directories below `config`. The `menus` sub folder is just added for better organization.

We use `TOML` in the example above, but Hugo also supports `JSON` and `YAML` as configuration formats. These can be mixed.

Fixes #5422

--- a/commands/commandeer.go
+++ b/commands/commandeer.go
@@ -249,6 +249,8 @@
 		sourceFs = c.DepsCfg.Fs.Source
 	}
 
+	environment := c.h.getEnvironment(running)
+
 	doWithConfig := func(cfg config.Provider) error {
 
 		if c.ftch != nil {
@@ -256,7 +258,7 @@
 		}
 
 		cfg.Set("workingDir", dir)
-
+		cfg.Set("environment", environment)
 		return nil
 	}
 
@@ -269,8 +271,18 @@
 		return err
 	}
 
+	configPath := c.h.source
+	if configPath == "" {
+		configPath = dir
+	}
 	config, configFiles, err := hugolib.LoadConfig(
-		hugolib.ConfigSourceDescriptor{Fs: sourceFs, Path: c.h.source, WorkingDir: dir, Filename: c.h.cfgFile},
+		hugolib.ConfigSourceDescriptor{
+			Fs:           sourceFs,
+			Path:         configPath,
+			WorkingDir:   dir,
+			Filename:     c.h.cfgFile,
+			AbsConfigDir: c.h.getConfigDir(dir),
+			Environment:  environment},
 		doWithCommandeer,
 		doWithConfig)
 
--- a/commands/commands.go
+++ b/commands/commands.go
@@ -14,6 +14,11 @@
 package commands
 
 import (
+	"os"
+
+	"github.com/gohugoio/hugo/hugolib/paths"
+
+	"github.com/gohugoio/hugo/common/hugo"
 	"github.com/gohugoio/hugo/common/loggers"
 	"github.com/gohugoio/hugo/config"
 	"github.com/gohugoio/hugo/helpers"
@@ -159,6 +164,7 @@
 	})
 
 	cc.cmd.PersistentFlags().StringVar(&cc.cfgFile, "config", "", "config file (default is path/config.yaml|json|toml)")
+	cc.cmd.PersistentFlags().StringVar(&cc.cfgDir, "configDir", "config", "config dir")
 	cc.cmd.PersistentFlags().BoolVar(&cc.quiet, "quiet", false, "build in quiet mode")
 
 	// Set bash-completion
@@ -185,8 +191,9 @@
 }
 
 type hugoBuilderCommon struct {
-	source  string
-	baseURL string
+	source      string
+	baseURL     string
+	environment string
 
 	buildWatch bool
 
@@ -200,9 +207,38 @@
 	quiet      bool
 
 	cfgFile string
+	cfgDir  string
 	logFile string
 }
 
+func (cc *hugoBuilderCommon) getConfigDir(baseDir string) string {
+	if cc.cfgDir != "" {
+		return paths.AbsPathify(baseDir, cc.cfgDir)
+	}
+
+	if v, found := os.LookupEnv("HUGO_CONFIGDIR"); found {
+		return paths.AbsPathify(baseDir, v)
+	}
+
+	return paths.AbsPathify(baseDir, "config")
+}
+
+func (cc *hugoBuilderCommon) getEnvironment(isServer bool) string {
+	if cc.environment != "" {
+		return cc.environment
+	}
+
+	if v, found := os.LookupEnv("HUGO_ENVIRONMENT"); found {
+		return v
+	}
+
+	if isServer {
+		return hugo.EnvironmentDevelopment
+	}
+
+	return hugo.EnvironmentProduction
+}
+
 func (cc *hugoBuilderCommon) handleFlags(cmd *cobra.Command) {
 	cmd.Flags().Bool("cleanDestinationDir", false, "remove files from destination not found in static directories")
 	cmd.Flags().BoolP("buildDrafts", "D", false, "include content marked as draft")
@@ -209,6 +245,7 @@
 	cmd.Flags().BoolP("buildFuture", "F", false, "include content with publishdate in the future")
 	cmd.Flags().BoolP("buildExpired", "E", false, "include expired content")
 	cmd.Flags().StringVarP(&cc.source, "source", "s", "", "filesystem path to read files relative from")
+	cmd.Flags().StringVarP(&cc.environment, "environment", "e", "", "build environment")
 	cmd.Flags().StringP("contentDir", "c", "", "filesystem path to content directory")
 	cmd.Flags().StringP("layoutDir", "l", "", "filesystem path to layout directory")
 	cmd.Flags().StringP("cacheDir", "", "", "filesystem path to cache directory. Defaults: $TMPDIR/hugo_cache/")
--- a/commands/commands_test.go
+++ b/commands/commands_test.go
@@ -56,8 +56,11 @@
 		check func(command []cmder)
 	}{{[]string{"server",
 		"--config=myconfig.toml",
+		"--configDir=myconfigdir",
 		"--contentDir=mycontent",
 		"--disableKinds=page,home",
+		"--environment=testing",
+		"--configDir=myconfigdir",
 		"--layoutDir=mylayouts",
 		"--theme=mytheme",
 		"--gc",
@@ -78,6 +81,7 @@
 			if b, ok := command.(commandsBuilderGetter); ok {
 				v := b.getCommandsBuilder().hugoBuilderCommon
 				assert.Equal("myconfig.toml", v.cfgFile)
+				assert.Equal("myconfigdir", v.cfgDir)
 				assert.Equal("mysource", v.source)
 				assert.Equal("https://example.com/b/", v.baseURL)
 			}
@@ -93,6 +97,7 @@
 		assert.True(sc.noHTTPCache)
 		assert.True(sc.renderToDisk)
 		assert.Equal(1366, sc.serverPort)
+		assert.Equal("testing", sc.environment)
 
 		cfg := viper.New()
 		sc.flagsToConfig(cfg)
@@ -233,6 +238,7 @@
 	writeFile(t, filepath.Join(d, "layouts", "_default", "list.html"), `
 
 List: {{ .Title }}
+Environment: {{ hugo.Environment }}
 
 `)
 
--- a/commands/hugo.go
+++ b/commands/hugo.go
@@ -718,8 +718,8 @@
 	// Identifies changes to config (config.toml) files.
 	configSet := make(map[string]bool)
 
+	c.logger.FEEDBACK.Println("Watching for config changes in", strings.Join(c.configFiles, ", "))
 	for _, configFile := range c.configFiles {
-		c.logger.FEEDBACK.Println("Watching for config changes in", configFile)
 		watcher.Add(configFile)
 		configSet[configFile] = true
 	}
@@ -750,7 +750,17 @@
 	configSet map[string]bool) {
 
 	for _, ev := range evs {
-		if configSet[ev.Name] {
+		isConfig := configSet[ev.Name]
+		if !isConfig {
+			// It may be one of the /config folders
+			dirname := filepath.Dir(ev.Name)
+			if dirname != "." && configSet[dirname] {
+				isConfig = true
+			}
+
+		}
+
+		if isConfig {
 			if ev.Op&fsnotify.Chmod == fsnotify.Chmod {
 				continue
 			}
@@ -766,7 +776,7 @@
 					}
 				}
 			}
-			// Config file changed. Need full rebuild.
+			// Config file(s) changed. Need full rebuild.
 			c.fullRebuild()
 			break
 		}
--- a/commands/server.go
+++ b/commands/server.go
@@ -36,7 +36,6 @@
 	"github.com/gohugoio/hugo/tpl"
 
 	"github.com/gohugoio/hugo/config"
-
 	"github.com/gohugoio/hugo/helpers"
 	"github.com/spf13/afero"
 	"github.com/spf13/cobra"
@@ -300,6 +299,8 @@
 	}
 
 	absPublishDir := f.c.hugo.PathSpec.AbsPathify(publishDir)
+
+	jww.FEEDBACK.Printf("Environment: %q", f.c.hugo.Deps.Site.Hugo().Environment)
 
 	if i == 0 {
 		if f.s.renderToDisk {
--- a/commands/server_test.go
+++ b/commands/server_test.go
@@ -68,6 +68,7 @@
 	homeContent := helpers.ReaderToString(resp.Body)
 
 	assert.Contains(homeContent, "List: Hugo Commands")
+	assert.Contains(homeContent, "Environment: development")
 
 	// Stop the server.
 	stop <- true
--- a/common/herrors/error_locator.go
+++ b/common/herrors/error_locator.go
@@ -17,10 +17,10 @@
 import (
 	"io"
 	"io/ioutil"
+	"path/filepath"
 	"strings"
 
 	"github.com/gohugoio/hugo/common/text"
-	"github.com/gohugoio/hugo/helpers"
 
 	"github.com/spf13/afero"
 )
@@ -172,12 +172,16 @@
 	return fileType
 }
 
+func extNoDelimiter(filename string) string {
+	return strings.TrimPrefix(".", filepath.Ext(filename))
+}
+
 func chromaLexerFromFilename(filename string) string {
 	if strings.Contains(filename, "layouts") {
 		return "go-html-template"
 	}
 
-	ext := helpers.ExtNoDelimiter(filename)
+	ext := extNoDelimiter(filename)
 	return chromaLexerFromType(ext)
 }
 
--- a/common/hugo/hugo.go
+++ b/common/hugo/hugo.go
@@ -18,28 +18,50 @@
 	"html/template"
 )
 
+const (
+	EnvironmentDevelopment = "development"
+	EnvironmentProduction  = "production"
+)
+
 var (
-	// CommitHash contains the current Git revision. Use make to build to make
+	// commitHash contains the current Git revision. Use make to build to make
 	// sure this gets set.
-	CommitHash string
+	commitHash string
 
-	// BuildDate contains the date of the current build.
-	BuildDate string
+	// buildDate contains the date of the current build.
+	buildDate string
 )
 
 // Info contains information about the current Hugo environment
 type Info struct {
-	Version    VersionString
-	Generator  template.HTML
 	CommitHash string
 	BuildDate  string
+
+	// The build environment.
+	// Defaults are "production" (hugo) and "development" (hugo server).
+	// This can also be set by the user.
+	// It can be any string, but it will be all lower case.
+	Environment string
 }
 
-func NewInfo() Info {
+// Version returns the current version as a comparable version string.
+func (i Info) Version() VersionString {
+	return CurrentVersion.Version()
+}
+
+// Generator a Hugo meta generator HTML tag.
+func (i Info) Generator() template.HTML {
+	return template.HTML(fmt.Sprintf(`<meta name="generator" content="Hugo %s" />`, CurrentVersion.String()))
+}
+
+// NewInfo creates a new Hugo Info object.
+func NewInfo(environment string) Info {
+	if environment == "" {
+		environment = EnvironmentProduction
+	}
 	return Info{
-		Version:    CurrentVersion.Version(),
-		CommitHash: CommitHash,
-		BuildDate:  BuildDate,
-		Generator:  template.HTML(fmt.Sprintf(`<meta name="generator" content="Hugo %s" />`, CurrentVersion.String())),
+		CommitHash:  commitHash,
+		BuildDate:   buildDate,
+		Environment: environment,
 	}
 }
--- a/common/hugo/hugo_test.go
+++ b/common/hugo/hugo_test.go
@@ -23,12 +23,13 @@
 func TestHugoInfo(t *testing.T) {
 	assert := require.New(t)
 
-	hugoInfo := NewInfo()
+	hugoInfo := NewInfo("")
 
-	assert.Equal(CurrentVersion.Version(), hugoInfo.Version)
-	assert.IsType(VersionString(""), hugoInfo.Version)
-	assert.Equal(CommitHash, hugoInfo.CommitHash)
-	assert.Equal(BuildDate, hugoInfo.BuildDate)
-	assert.Contains(hugoInfo.Generator, fmt.Sprintf("Hugo %s", hugoInfo.Version))
+	assert.Equal(CurrentVersion.Version(), hugoInfo.Version())
+	assert.IsType(VersionString(""), hugoInfo.Version())
+	assert.Equal(commitHash, hugoInfo.CommitHash)
+	assert.Equal(buildDate, hugoInfo.BuildDate)
+	assert.Equal("production", hugoInfo.Environment)
+	assert.Contains(hugoInfo.Generator(), fmt.Sprintf("Hugo %s", hugoInfo.Version()))
 
 }
--- a/common/hugo/version.go
+++ b/common/hugo/version.go
@@ -130,8 +130,8 @@
 	program := "Hugo Static Site Generator"
 
 	version := "v" + CurrentVersion.String()
-	if CommitHash != "" {
-		version += "-" + strings.ToUpper(CommitHash)
+	if commitHash != "" {
+		version += "-" + strings.ToUpper(commitHash)
 	}
 	if isExtended {
 		version += "/extended"
@@ -139,14 +139,12 @@
 
 	osArch := runtime.GOOS + "/" + runtime.GOARCH
 
-	var buildDate string
-	if BuildDate != "" {
-		buildDate = BuildDate
-	} else {
-		buildDate = "unknown"
+	date := buildDate
+	if date == "" {
+		date = "unknown"
 	}
 
-	return fmt.Sprintf("%s %s %s BuildDate: %s", program, version, osArch, buildDate)
+	return fmt.Sprintf("%s %s %s BuildDate: %s", program, version, osArch, date)
 
 }
 
--- a/common/maps/maps.go
+++ b/common/maps/maps.go
@@ -16,6 +16,8 @@
 import (
 	"strings"
 
+	"github.com/gobwas/glob"
+
 	"github.com/spf13/cast"
 )
 
@@ -40,5 +42,75 @@
 			m[lKey] = v
 		}
 
+	}
+}
+
+type keyRename struct {
+	pattern glob.Glob
+	newKey  string
+}
+
+// KeyRenamer supports renaming of keys in a map.
+type KeyRenamer struct {
+	renames []keyRename
+}
+
+// NewKeyRenamer creates a new KeyRenamer given a list of pattern and new key
+// value pairs.
+func NewKeyRenamer(patternKeys ...string) (KeyRenamer, error) {
+	var renames []keyRename
+	for i := 0; i < len(patternKeys); i += 2 {
+		g, err := glob.Compile(strings.ToLower(patternKeys[i]), '/')
+		if err != nil {
+			return KeyRenamer{}, err
+		}
+		renames = append(renames, keyRename{pattern: g, newKey: patternKeys[i+1]})
+	}
+
+	return KeyRenamer{renames: renames}, nil
+}
+
+func (r KeyRenamer) getNewKey(keyPath string) string {
+	for _, matcher := range r.renames {
+		if matcher.pattern.Match(keyPath) {
+			return matcher.newKey
+		}
+	}
+
+	return ""
+}
+
+// Rename renames the keys in the given map according
+// to the patterns in the current KeyRenamer.
+func (r KeyRenamer) Rename(m map[string]interface{}) {
+	r.renamePath("", m)
+}
+
+func (KeyRenamer) keyPath(k1, k2 string) string {
+	k1, k2 = strings.ToLower(k1), strings.ToLower(k2)
+	if k1 == "" {
+		return k2
+	} else {
+		return k1 + "/" + k2
+	}
+}
+
+func (r KeyRenamer) renamePath(parentKeyPath string, m map[string]interface{}) {
+	for key, val := range m {
+		keyPath := r.keyPath(parentKeyPath, key)
+		switch val.(type) {
+		case map[interface{}]interface{}:
+			val = cast.ToStringMap(val)
+			r.renamePath(keyPath, val.(map[string]interface{}))
+		case map[string]interface{}:
+			r.renamePath(keyPath, val.(map[string]interface{}))
+		}
+
+		newKey := r.getNewKey(keyPath)
+
+		if newKey != "" {
+			delete(m, key)
+			m[newKey] = val
+		}
 	}
 }
--- a/common/maps/maps_test.go
+++ b/common/maps/maps_test.go
@@ -16,6 +16,8 @@
 import (
 	"reflect"
 	"testing"
+
+	"github.com/stretchr/testify/require"
 )
 
 func TestToLower(t *testing.T) {
@@ -69,4 +71,53 @@
 			t.Errorf("[%d] Expected\n%#v, got\n%#v\n", i, test.expected, test.input)
 		}
 	}
+}
+
+func TestRenameKeys(t *testing.T) {
+	assert := require.New(t)
+
+	m := map[string]interface{}{
+		"a":    32,
+		"ren1": "m1",
+		"ren2": "m1_2",
+		"sub": map[string]interface{}{
+			"subsub": map[string]interface{}{
+				"REN1": "m2",
+				"ren2": "m2_2",
+			},
+		},
+		"no": map[string]interface{}{
+			"ren1": "m2",
+			"ren2": "m2_2",
+		},
+	}
+
+	expected := map[string]interface{}{
+		"a":    32,
+		"new1": "m1",
+		"new2": "m1_2",
+		"sub": map[string]interface{}{
+			"subsub": map[string]interface{}{
+				"new1": "m2",
+				"ren2": "m2_2",
+			},
+		},
+		"no": map[string]interface{}{
+			"ren1": "m2",
+			"ren2": "m2_2",
+		},
+	}
+
+	renamer, err := NewKeyRenamer(
+		"{ren1,sub/*/ren1}", "new1",
+		"{Ren2,sub/ren2}", "new2",
+	)
+	assert.NoError(err)
+
+	renamer.Rename(m)
+
+	if !reflect.DeepEqual(expected, m) {
+		t.Errorf("Expected\n%#v, got\n%#v\n", expected, m)
+	}
+
 }
--- /dev/null
+++ b/config/configLoader.go
@@ -1,0 +1,106 @@
+// Copyright 2018 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package config
+
+import (
+	"github.com/gohugoio/hugo/common/maps"
+	"github.com/gohugoio/hugo/parser/metadecoders"
+	"github.com/spf13/afero"
+	"github.com/spf13/viper"
+)
+
+// FromConfigString creates a config from the given YAML, JSON or TOML config. This is useful in tests.
+func FromConfigString(config, configType string) (Provider, error) {
+	v := newViper()
+	m, err := readConfig(metadecoders.FormatFromString(configType), []byte(config))
+	if err != nil {
+		return nil, err
+	}
+
+	v.MergeConfigMap(m)
+
+	return v, nil
+}
+
+// FromFile loads the configuration from the given filename.
+func FromFile(fs afero.Fs, filename string) (Provider, error) {
+	m, err := loadConfigFromFile(fs, filename)
+	if err != nil {
+		return nil, err
+	}
+
+	v := newViper()
+
+	err = v.MergeConfigMap(m)
+	if err != nil {
+		return nil, err
+	}
+
+	return v, nil
+}
+
+// FromFileToMap is the same as FromFile, but it returns the config values
+// as a simple map.
+func FromFileToMap(fs afero.Fs, filename string) (map[string]interface{}, error) {
+	return loadConfigFromFile(fs, filename)
+}
+
+func readConfig(format metadecoders.Format, data []byte) (map[string]interface{}, error) {
+	m, err := metadecoders.UnmarshalToMap(data, format)
+	if err != nil {
+		return nil, err
+	}
+
+	RenameKeys(m)
+
+	return m, nil
+
+}
+
+func loadConfigFromFile(fs afero.Fs, filename string) (map[string]interface{}, error) {
+	m, err := metadecoders.UnmarshalFileToMap(fs, filename)
+	if err != nil {
+		return nil, err
+	}
+	RenameKeys(m)
+	return m, nil
+}
+
+var keyAliases maps.KeyRenamer
+
+func init() {
+	var err error
+	keyAliases, err = maps.NewKeyRenamer(
+		// Before 0.53 we used singular for "menu".
+		"{menu,languages/*/menu}", "menus",
+	)
+
+	if err != nil {
+		panic(err)
+	}
+}
+
+// RenameKeys renames config keys in m recursively according to a global Hugo
+// alias definition.
+func RenameKeys(m map[string]interface{}) {
+	keyAliases.Rename(m)
+}
+
+func newViper() *viper.Viper {
+	v := viper.New()
+	v.AutomaticEnv()
+	v.SetEnvPrefix("hugo")
+
+	return v
+}
--- a/config/configProvider.go
+++ b/config/configProvider.go
@@ -14,11 +14,7 @@
 package config
 
 import (
-	"strings"
-
 	"github.com/spf13/cast"
-
-	"github.com/spf13/viper"
 )
 
 // Provider provides the configuration settings for Hugo.
@@ -32,16 +28,6 @@
 	Get(key string) interface{}
 	Set(key string, value interface{})
 	IsSet(key string) bool
-}
-
-// FromConfigString creates a config from the given YAML, JSON or TOML config. This is useful in tests.
-func FromConfigString(config, configType string) (Provider, error) {
-	v := viper.New()
-	v.SetConfigType(configType)
-	if err := v.ReadConfig(strings.NewReader(config)); err != nil {
-		return nil, err
-	}
-	return v, nil
 }
 
 // GetStringSlicePreserveString returns a string slice from the given config and key.
--- a/go.mod
+++ b/go.mod
@@ -33,7 +33,7 @@
 	github.com/mattn/go-runewidth v0.0.3 // indirect
 	github.com/miekg/mmark v1.3.6
 	github.com/mitchellh/hashstructure v1.0.0
-	github.com/mitchellh/mapstructure v1.0.0
+	github.com/mitchellh/mapstructure v1.1.2
 	github.com/muesli/smartcrop v0.0.0-20180228075044-f6ebaa786a12
 	github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
 	github.com/nicksnyder/go-i18n v1.10.0
@@ -50,16 +50,18 @@
 	github.com/spf13/jwalterweatherman v1.0.1-0.20181028145347-94f6ae3ed3bc
 	github.com/spf13/nitro v0.0.0-20131003134307-24d7ef30a12d
 	github.com/spf13/pflag v1.0.3
-	github.com/spf13/viper v1.2.0
+	github.com/spf13/viper v1.3.1
 	github.com/stretchr/testify v1.2.3-0.20181014000028-04af85275a5c
 	github.com/tdewolff/minify/v2 v2.3.7
+	github.com/ugorji/go/codec v0.0.0-20181206144755-e72634d4d386 // indirect
 	github.com/yosssi/ace v0.0.5
 	golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81
 	golang.org/x/net v0.0.0-20180906233101-161cd47e91fd // indirect
 	golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f
+	golang.org/x/sys v0.0.0-20181206074257-70b957f3b65e // indirect
 	golang.org/x/text v0.3.0
 	gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
-	gopkg.in/yaml.v2 v2.2.1
+	gopkg.in/yaml.v2 v2.2.2
 )
 
 exclude github.com/chaseadamsio/goorgeous v2.0.0+incompatible
--- a/go.sum
+++ b/go.sum
@@ -14,6 +14,7 @@
 github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
 github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY=
 github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
+github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
 github.com/bep/debounce v1.1.0 h1:6ocXeW2iZ/7vAzgXz82J00tYxncMiEEBExPftTtOQzk=
 github.com/bep/debounce v1.1.0/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
 github.com/bep/gitmap v1.0.0 h1:cTTZwq7vpGuhwefKCBDV9UrHnZAPVJTvoWobimrqkUc=
@@ -24,6 +25,9 @@
 github.com/chaseadamsio/goorgeous v1.1.0/go.mod h1:6QaC0vFoKWYDth94dHFNgRT2YkT5FHdQp/Yx15aAAi0=
 github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927 h1:SKI1/fuSdodxmNNyVBR8d7X/HuLnRpvvFO0AgyQk764=
 github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgkuj+NQRlZcDbAbM1ORAbXjXX77sX7T289U=
+github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
+github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
+github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
 github.com/cpuguy83/go-md2man v1.0.8 h1:DwoNytLphI8hzS2Af4D0dfaEaiSq2bN05mEm4R6vf8M=
 github.com/cpuguy83/go-md2man v1.0.8/go.mod h1:N6JayAiVKtlHSnuTCeuLSQVs75hb8q+dYQLjr7cDsKY=
 github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
@@ -79,8 +83,8 @@
 github.com/miekg/mmark v1.3.6/go.mod h1:w7r9mkTvpS55jlfyn22qJ618itLryxXBhA7Jp3FIlkw=
 github.com/mitchellh/hashstructure v1.0.0 h1:ZkRJX1CyOoTkar7p/mLS5TZU4nJ1Rn/F8u9dGS02Q3Y=
 github.com/mitchellh/hashstructure v1.0.0/go.mod h1:QjSHrPWS+BGUVBYkbTZWEnOh3G1DutKwClXU/ABz6AQ=
-github.com/mitchellh/mapstructure v1.0.0 h1:vVpGvMXJPqSDh2VYHF7gsfQj8Ncx+Xw5Y1KHeTRY+7I=
-github.com/mitchellh/mapstructure v1.0.0/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
+github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/muesli/smartcrop v0.0.0-20180228075044-f6ebaa786a12 h1:l0X/8IDy2UoK+oXcQFMRSIOcyuYb5iEPytPGplnM41Y=
 github.com/muesli/smartcrop v0.0.0-20180228075044-f6ebaa786a12/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI=
 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
@@ -105,8 +109,6 @@
 github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
 github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI=
 github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
-github.com/spf13/cast v1.2.0 h1:HHl1DSRbEQN2i8tJmtS6ViPyHx35+p51amrdsiTCrkg=
-github.com/spf13/cast v1.2.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg=
 github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8=
 github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8=
@@ -119,12 +121,12 @@
 github.com/spf13/jwalterweatherman v1.0.1-0.20181028145347-94f6ae3ed3bc/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
 github.com/spf13/nitro v0.0.0-20131003134307-24d7ef30a12d h1:ihvj2nmx8eqWjlgNgdW6h0DyGJuq5GiwHadJkG0wXtQ=
 github.com/spf13/nitro v0.0.0-20131003134307-24d7ef30a12d/go.mod h1:jU8A+8xL+6n1OX4XaZtCj4B3mIa64tULUsD6YegdpFo=
-github.com/spf13/pflag v1.0.2 h1:Fy0orTDgHdbnzHcsOgfCN4LtHf0ec3wwtiwJqwvf3Gc=
-github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
 github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
-github.com/spf13/viper v1.2.0 h1:M4Rzxlu+RgU4pyBRKhKaVN1VeYOm8h2jgyXnAseDgCc=
-github.com/spf13/viper v1.2.0/go.mod h1:P4AexN0a+C9tGAnUFNwDMYYZv3pjFuvmeiMyKRaNVlI=
+github.com/spf13/viper v1.3.0 h1:cO6QlTTeK9RQDhFAbGLV5e3fHXbRpin/Gi8qfL4rdLk=
+github.com/spf13/viper v1.3.0/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
+github.com/spf13/viper v1.3.1 h1:5+8j8FTpnFV4nEImW/ofkzEt8VoOiLXxdYIDsB73T38=
+github.com/spf13/viper v1.3.1/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
 github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.2.3-0.20181014000028-04af85275a5c h1:03OmljzZYsezlgAfa+f/cY8E8XXPiFh5bgANMhUlDI4=
@@ -135,10 +137,14 @@
 github.com/tdewolff/parse/v2 v2.3.5/go.mod h1:HansaqmN4I/U7L6/tUp0NcwT2tFO0F4EAWYGSDzkYNk=
 github.com/tdewolff/test v1.0.0 h1:jOwzqCXr5ePXEPGJaq2ivoR6HOCi+D5TPfpoyg8yvmU=
 github.com/tdewolff/test v1.0.0/go.mod h1:DiQUlutnqlEvdvhSn2LPGy4TFwRauAaYDsL+683RNX4=
+github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
+github.com/ugorji/go/codec v0.0.0-20181206144755-e72634d4d386/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
 github.com/wellington/go-libsass v0.9.3-0.20181113175235-c63644206701 h1:9vG9vvVNVupO4Y7uwFkRgIMNe9rdaJMCINDe8vhAhLo=
 github.com/wellington/go-libsass v0.9.3-0.20181113175235-c63644206701/go.mod h1:mxgxgam0N0E+NAUMHLcu20Ccfc3mVpDkyrLDayqfiTs=
+github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
 github.com/yosssi/ace v0.0.5 h1:tUkIP/BLdKqrlrPwcmH0shwEEhTRHoGnc1wFIWmaBUA=
 github.com/yosssi/ace v0.0.5/go.mod h1:ALfIzm2vT7t5ZE7uoIZqF3TQ7SAOyupFZnkrF5id+K0=
+golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81 h1:00VmoueYNlNz/aHIilyyQz/MHSqGoWJzpFv/HW8xpzI=
 golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd h1:nTDtHvHSdCn1m6ITfMRqtOd/9+7a3s8RBNOZ3eYZzJA=
@@ -145,14 +151,16 @@
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180906133057-8cf3aee42992 h1:BH3eQWeGbwRU2+wxxuuPOdFBmaiBH81O8BugSjHeTFg=
-golang.org/x/sys v0.0.0-20180906133057-8cf3aee42992/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181031143558-9b800f95dbbc h1:SdCq5U4J+PpbSDIl9bM0V1e1Ug1jsnBkAFvTs1htn7U=
 golang.org/x/sys v0.0.0-20181031143558-9b800f95dbbc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a h1:1n5lsVfiQW3yfsRGu98756EH1YthsFqr/5mxHduZW2A=
+golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181206074257-70b957f3b65e h1:njOxP/wVblhCLIUhjHXf6X+dzTt5OQ3vMQo9mkOIKIo=
+golang.org/x/sys v0.0.0-20181206074257-70b957f3b65e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE=
-gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
--- a/goreleaser-extended.yml
+++ b/goreleaser-extended.yml
@@ -2,7 +2,7 @@
 builds:
 - binary: hugo
   ldflags:
-    - -s -w -X github.com/gohugoio/hugo/common/hugo.BuildDate={{.Date}} -X github.com/gohugoio/hugo/common/hugo.CommitHash={{ .ShortCommit }}
+    - -s -w -X github.com/gohugoio/hugo/common/hugo.buildDate={{.Date}} -X github.com/gohugoio/hugo/common/hugo.commitHash={{ .ShortCommit }}
     - "-extldflags '-static'"
   env:
     - CGO_ENABLED=1
--- a/goreleaser.yml
+++ b/goreleaser.yml
@@ -2,7 +2,7 @@
 build:
   main: main.go
   binary: hugo
-  ldflags: -s -w -X github.com/gohugoio/hugo/common/hugo.BuildDate={{.Date}} -X github.com/gohugoio/hugo/common/hugo.CommitHash={{ .ShortCommit }}
+  ldflags: -s -w -X github.com/gohugoio/hugo/common/hugo.buildDate={{.Date}} -X github.com/gohugoio/hugo/common/hugo.commitHash={{ .ShortCommit }}
   env:
     - CGO_ENABLED=0
   goos:
--- a/helpers/path.go
+++ b/helpers/path.go
@@ -274,6 +274,13 @@
 	return fileAndExt(in, fpb)
 }
 
+// FileAndExtNoDelimiter takes a path and returns the file and extension separated,
+// the extension excluding the delmiter, e.g "md".
+func FileAndExtNoDelimiter(in string) (string, string) {
+	file, ext := fileAndExt(in, fpb)
+	return file, strings.TrimPrefix(ext, ".")
+}
+
 // Filename takes a path, strips out the extension,
 // and returns the name of the file.
 func Filename(in string) (name string) {
@@ -399,6 +406,8 @@
 	return r
 
 }
+
+var numInPathRe = regexp.MustCompile("\\.(\\d+)\\.")
 
 // FindCWD returns the current working directory from where the Hugo
 // executable is run.
--- a/htesting/test_structs.go
+++ b/htesting/test_structs.go
@@ -39,7 +39,7 @@
 // NewTestHugoSite creates a new minimal test site.
 func NewTestHugoSite() hugo.Site {
 	return testSite{
-		h: hugo.NewInfo(),
+		h: hugo.NewInfo(hugo.EnvironmentProduction),
 		l: langs.NewLanguage("en", newTestConfig()),
 	}
 }
--- /dev/null
+++ b/htesting/testdata_builder.go
@@ -1,0 +1,59 @@
+// Copyright 2018 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package htesting
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/spf13/afero"
+)
+
+type testFile struct {
+	name    string
+	content string
+}
+
+type testdataBuilder struct {
+	t          testing.TB
+	fs         afero.Fs
+	workingDir string
+
+	files []testFile
+}
+
+func NewTestdataBuilder(fs afero.Fs, workingDir string, t testing.TB) *testdataBuilder {
+	workingDir = filepath.Clean(workingDir)
+	return &testdataBuilder{fs: fs, workingDir: workingDir, t: t}
+}
+
+func (b *testdataBuilder) Add(filename, content string) *testdataBuilder {
+	b.files = append(b.files, testFile{name: filename, content: content})
+	return b
+}
+
+func (b *testdataBuilder) Build() *testdataBuilder {
+	for _, f := range b.files {
+		if err := afero.WriteFile(b.fs, filepath.Join(b.workingDir, f.name), []byte(f.content), 0666); err != nil {
+			b.t.Fatalf("failed to add %q: %s", f.name, err)
+		}
+	}
+	return b
+}
+
+func (b testdataBuilder) WithWorkingDir(dir string) *testdataBuilder {
+	b.workingDir = filepath.Clean(dir)
+	b.files = make([]testFile, 0)
+	return &b
+}
--- a/hugolib/config.go
+++ b/hugolib/config.go
@@ -14,14 +14,19 @@
 package hugolib
 
 import (
-	"errors"
 	"fmt"
-	"io"
+
+	"os"
+	"path/filepath"
 	"strings"
 
-	"github.com/gohugoio/hugo/common/herrors"
+	"github.com/gohugoio/hugo/parser/metadecoders"
 
+	"github.com/gohugoio/hugo/common/herrors"
+	"github.com/gohugoio/hugo/common/hugo"
+	"github.com/gohugoio/hugo/hugofs"
 	"github.com/gohugoio/hugo/hugolib/paths"
+	"github.com/pkg/errors"
 	_errors "github.com/pkg/errors"
 
 	"github.com/gohugoio/hugo/langs"
@@ -65,21 +70,37 @@
 type ConfigSourceDescriptor struct {
 	Fs afero.Fs
 
-	// Full path to the config file to use, i.e. /my/project/config.toml
+	// Path to the config file to use, e.g. /my/project/config.toml
 	Filename string
 
 	// The path to the directory to look for configuration. Is used if Filename is not
-	// set.
+	// set or if it is set to a relative filename.
 	Path string
 
 	// The project's working dir. Is used to look for additional theme config.
 	WorkingDir string
+
+	// The (optional) directory for additional configuration files.
+	AbsConfigDir string
+
+	// production, development
+	Environment string
 }
 
 func (d ConfigSourceDescriptor) configFilenames() []string {
+	if d.Filename == "" {
+		return []string{"config"}
+	}
 	return strings.Split(d.Filename, ",")
 }
 
+func (d ConfigSourceDescriptor) configFileDir() string {
+	if d.Path != "" {
+		return d.Path
+	}
+	return d.WorkingDir
+}
+
 // LoadConfigDefault is a convenience method to load the default "config.toml" config.
 func LoadConfigDefault(fs afero.Fs) (*viper.Viper, error) {
 	v, _, err := LoadConfig(ConfigSourceDescriptor{Fs: fs, Filename: "config.toml"})
@@ -86,66 +107,39 @@
 	return v, err
 }
 
-var ErrNoConfigFile = errors.New("Unable to locate Config file. Perhaps you need to create a new site.\n       Run `hugo help new` for details.\n")
+var ErrNoConfigFile = errors.New("Unable to locate config file or config directory. Perhaps you need to create a new site.\n       Run `hugo help new` for details.\n")
 
 // LoadConfig loads Hugo configuration into a new Viper and then adds
 // a set of defaults.
 func LoadConfig(d ConfigSourceDescriptor, doWithConfig ...func(cfg config.Provider) error) (*viper.Viper, []string, error) {
+	if d.Environment == "" {
+		d.Environment = hugo.EnvironmentProduction
+	}
+
 	var configFiles []string
 
-	fs := d.Fs
 	v := viper.New()
-	v.SetFs(fs)
+	l := configLoader{ConfigSourceDescriptor: d}
 
-	if d.Path == "" {
-		d.Path = "."
-	}
-
-	configFilenames := d.configFilenames()
 	v.AutomaticEnv()
 	v.SetEnvPrefix("hugo")
-	v.SetConfigFile(configFilenames[0])
-	v.AddConfigPath(d.Path)
 
-	applyFileContext := func(filename string, err error) error {
-		err, _ = herrors.WithFileContextForFile(
-			err,
-			filename,
-			filename,
-			fs,
-			herrors.SimpleLineMatcher)
+	var cerr error
 
-		return err
-	}
-
-	var configFileErr error
-
-	err := v.ReadInConfig()
-	if err != nil {
-		if _, ok := err.(viper.ConfigParseError); ok {
-			return nil, configFiles, applyFileContext(v.ConfigFileUsed(), err)
+	for _, name := range d.configFilenames() {
+		var filename string
+		if filename, cerr = l.loadConfig(name, v); cerr != nil && cerr != ErrNoConfigFile {
+			return nil, nil, cerr
 		}
-		configFileErr = ErrNoConfigFile
+		configFiles = append(configFiles, filename)
 	}
 
-	if configFileErr == nil {
-
-		if cf := v.ConfigFileUsed(); cf != "" {
-			configFiles = append(configFiles, cf)
+	if d.AbsConfigDir != "" {
+		dirnames, err := l.loadConfigFromConfigDir(v)
+		if err == nil {
+			configFiles = append(configFiles, dirnames...)
 		}
-
-		for _, configFile := range configFilenames[1:] {
-			var r io.Reader
-			var err error
-			if r, err = fs.Open(configFile); err != nil {
-				return nil, configFiles, fmt.Errorf("Unable to open Config file.\n (%s)\n", err)
-			}
-			if err = v.MergeConfig(r); err != nil {
-				return nil, configFiles, applyFileContext(configFile, err)
-			}
-			configFiles = append(configFiles, configFile)
-		}
-
+		cerr = err
 	}
 
 	if err := loadDefaultSettingsFor(v); err != nil {
@@ -152,9 +146,8 @@
 		return v, configFiles, err
 	}
 
-	if configFileErr == nil {
-
-		themeConfigFiles, err := loadThemeConfig(d, v)
+	if cerr == nil {
+		themeConfigFiles, err := l.loadThemeConfig(v)
 		if err != nil {
 			return v, configFiles, err
 		}
@@ -176,10 +169,181 @@
 		return v, configFiles, err
 	}
 
-	return v, configFiles, configFileErr
+	return v, configFiles, cerr
 
 }
 
+type configLoader struct {
+	ConfigSourceDescriptor
+}
+
+func (l configLoader) wrapFileInfoError(err error, fi os.FileInfo) error {
+	rfi, ok := fi.(hugofs.RealFilenameInfo)
+	if !ok {
+		return err
+	}
+	return l.wrapFileError(err, rfi.RealFilename())
+}
+
+func (l configLoader) loadConfig(configName string, v *viper.Viper) (string, error) {
+	baseDir := l.configFileDir()
+	var baseFilename string
+	if filepath.IsAbs(configName) {
+		baseFilename = configName
+	} else {
+		baseFilename = filepath.Join(baseDir, configName)
+	}
+
+	var filename string
+	fileExt := helpers.ExtNoDelimiter(configName)
+	if fileExt != "" {
+		exists, _ := helpers.Exists(baseFilename, l.Fs)
+		if exists {
+			filename = baseFilename
+		}
+	} else {
+		for _, ext := range []string{"toml", "yaml", "yml", "json"} {
+			filenameToCheck := baseFilename + "." + ext
+			exists, _ := helpers.Exists(filenameToCheck, l.Fs)
+			if exists {
+				filename = filenameToCheck
+				fileExt = ext
+				break
+			}
+		}
+	}
+
+	if filename == "" {
+		return "", ErrNoConfigFile
+	}
+
+	m, err := config.FromFileToMap(l.Fs, filename)
+	if err != nil {
+		return "", l.wrapFileError(err, filename)
+	}
+
+	if err = v.MergeConfigMap(m); err != nil {
+		return "", l.wrapFileError(err, filename)
+	}
+
+	return filename, nil
+
+}
+
+func (l configLoader) wrapFileError(err error, filename string) error {
+	err, _ = herrors.WithFileContextForFile(
+		err,
+		filename,
+		filename,
+		l.Fs,
+		herrors.SimpleLineMatcher)
+	return err
+}
+
+func (l configLoader) newRealBaseFs(path string) afero.Fs {
+	return hugofs.NewBasePathRealFilenameFs(afero.NewBasePathFs(l.Fs, path).(*afero.BasePathFs))
+
+}
+
+func (l configLoader) loadConfigFromConfigDir(v *viper.Viper) ([]string, error) {
+	sourceFs := l.Fs
+	configDir := l.AbsConfigDir
+
+	if _, err := sourceFs.Stat(configDir); err != nil {
+		// Config dir does not exist.
+		return nil, nil
+	}
+
+	defaultConfigDir := filepath.Join(configDir, "_default")
+	environmentConfigDir := filepath.Join(configDir, l.Environment)
+
+	var configDirs []string
+	// Merge from least to most specific.
+	for _, dir := range []string{defaultConfigDir, environmentConfigDir} {
+		if _, err := sourceFs.Stat(dir); err == nil {
+			configDirs = append(configDirs, dir)
+		}
+	}
+
+	if len(configDirs) == 0 {
+		return nil, nil
+	}
+
+	// Keep track of these so we can watch them for changes.
+	var dirnames []string
+
+	for _, configDir := range configDirs {
+		err := afero.Walk(sourceFs, configDir, func(path string, fi os.FileInfo, err error) error {
+			if fi == nil {
+				return nil
+			}
+
+			if fi.IsDir() {
+				dirnames = append(dirnames, path)
+				return nil
+			}
+
+			name := helpers.Filename(filepath.Base(path))
+
+			item, err := metadecoders.UnmarshalFileToMap(sourceFs, path)
+			if err != nil {
+				return l.wrapFileError(err, path)
+			}
+
+			var keyPath []string
+
+			if name != "config" {
+				// Can be params.jp, menus.en etc.
+				name, lang := helpers.FileAndExtNoDelimiter(name)
+
+				keyPath = []string{name}
+
+				if lang != "" {
+					keyPath = []string{"languages", lang}
+					switch name {
+					case "menu", "menus":
+						keyPath = append(keyPath, "menus")
+					case "params":
+						keyPath = append(keyPath, "params")
+					}
+				}
+			}
+
+			root := item
+			if len(keyPath) > 0 {
+				root = make(map[string]interface{})
+				m := root
+				for i, key := range keyPath {
+					if i >= len(keyPath)-1 {
+						m[key] = item
+					} else {
+						nm := make(map[string]interface{})
+						m[key] = nm
+						m = nm
+					}
+				}
+			}
+
+			// Migrate menu => menus etc.
+			config.RenameKeys(root)
+
+			if err := v.MergeConfigMap(root); err != nil {
+				return l.wrapFileError(err, path)
+			}
+
+			return nil
+
+		})
+
+		if err != nil {
+			return nil, err
+		}
+
+	}
+
+	return dirnames, nil
+}
+
 func loadLanguageSettings(cfg config.Provider, oldLangs langs.Languages) error {
 
 	defaultLang := cfg.GetString("defaultContentLanguage")
@@ -289,12 +453,11 @@
 	return nil
 }
 
-func loadThemeConfig(d ConfigSourceDescriptor, v1 *viper.Viper) ([]string, error) {
-	themesDir := paths.AbsPathify(d.WorkingDir, v1.GetString("themesDir"))
+func (l configLoader) loadThemeConfig(v1 *viper.Viper) ([]string, error) {
+	themesDir := paths.AbsPathify(l.WorkingDir, v1.GetString("themesDir"))
 	themes := config.GetStringSlicePreserveString(v1, "theme")
 
-	//  CollectThemes(fs afero.Fs, themesDir string, themes []strin
-	themeConfigs, err := paths.CollectThemes(d.Fs, themesDir, themes)
+	themeConfigs, err := paths.CollectThemes(l.Fs, themesDir, themes)
 	if err != nil {
 		return nil, err
 	}
@@ -309,7 +472,7 @@
 	for _, tc := range themeConfigs {
 		if tc.ConfigFilename != "" {
 			configFilenames = append(configFilenames, tc.ConfigFilename)
-			if err := applyThemeConfig(v1, tc); err != nil {
+			if err := l.applyThemeConfig(v1, tc); err != nil {
 				return nil, err
 			}
 		}
@@ -319,18 +482,18 @@
 
 }
 
-func applyThemeConfig(v1 *viper.Viper, theme paths.ThemeConfig) error {
+func (l configLoader) applyThemeConfig(v1 *viper.Viper, theme paths.ThemeConfig) error {
 
 	const (
 		paramsKey    = "params"
 		languagesKey = "languages"
-		menuKey      = "menu"
+		menuKey      = "menus"
 	)
 
 	v2 := theme.Cfg
 
 	for _, key := range []string{paramsKey, "outputformats", "mediatypes"} {
-		mergeStringMapKeepLeft("", key, v1, v2)
+		l.mergeStringMapKeepLeft("", key, v1, v2)
 	}
 
 	themeLower := strings.ToLower(theme.Name)
@@ -348,7 +511,7 @@
 		v1Langs := v1.GetStringMap(languagesKey)
 		for k := range v1Langs {
 			langParamsKey := languagesKey + "." + k + "." + paramsKey
-			mergeStringMapKeepLeft(paramsKey, langParamsKey, v1, v2)
+			l.mergeStringMapKeepLeft(paramsKey, langParamsKey, v1, v2)
 		}
 		v2Langs := v2.GetStringMap(languagesKey)
 		for k := range v2Langs {
@@ -378,7 +541,7 @@
 	}
 
 	// Add menu definitions from theme not found in project
-	if v2.IsSet("menu") {
+	if v2.IsSet(menuKey) {
 		v2menus := v2.GetStringMap(menuKey)
 		for k, v := range v2menus {
 			menuEntry := menuKey + "." + k
@@ -392,7 +555,7 @@
 
 }
 
-func mergeStringMapKeepLeft(rootKey, key string, v1, v2 config.Provider) {
+func (configLoader) mergeStringMapKeepLeft(rootKey, key string, v1, v2 config.Provider) {
 	if !v2.IsSet(key) {
 		return
 	}
@@ -440,6 +603,7 @@
 	v.SetDefault("buildDrafts", false)
 	v.SetDefault("buildFuture", false)
 	v.SetDefault("buildExpired", false)
+	v.SetDefault("environment", hugo.EnvironmentProduction)
 	v.SetDefault("uglyURLs", false)
 	v.SetDefault("verbose", false)
 	v.SetDefault("ignoreCache", false)
--- a/hugolib/config_test.go
+++ b/hugolib/config_test.go
@@ -247,8 +247,8 @@
 	b.AssertObject(`map[string]interface {}{
   "en": map[string]interface {}{
     "languagename": "English",
-    "menu": map[string]interface {}{
-      "theme": []interface {}{
+    "menus": map[string]interface {}{
+      "theme": []map[string]interface {}{
         map[string]interface {}{
           "name": "menu-lang-en-theme",
         },
@@ -265,8 +265,8 @@
   },
   "nb": map[string]interface {}{
     "languagename": "Norsk",
-    "menu": map[string]interface {}{
-      "theme": []interface {}{
+    "menus": map[string]interface {}{
+      "theme": []map[string]interface {}{
         map[string]interface {}{
           "name": "menu-lang-nb-theme",
         },
@@ -287,23 +287,23 @@
 
 	b.AssertObject(`
 map[string]interface {}{
-  "main": []interface {}{
+  "main": []map[string]interface {}{
     map[string]interface {}{
       "name": "menu-main-main",
     },
   },
-  "thememenu": []interface {}{
+  "thememenu": []map[string]interface {}{
     map[string]interface {}{
       "name": "menu-theme",
     },
   },
-  "top": []interface {}{
+  "top": []map[string]interface {}{
     map[string]interface {}{
       "name": "menu-top-main",
     },
   },
 }
-`, got["menu"])
+`, got["menus"])
 
 	assert.Equal("https://example.com/", got["baseurl"])
 
--- /dev/null
+++ b/hugolib/configdir_test.go
@@ -1,0 +1,152 @@
+// Copyright 2018 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package hugolib
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/gohugoio/hugo/common/herrors"
+
+	"github.com/gohugoio/hugo/htesting"
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/require"
+)
+
+func TestLoadConfigDir(t *testing.T) {
+	t.Parallel()
+
+	assert := require.New(t)
+
+	configContent := `
+baseURL = "https://example.org"
+paginagePath = "pag_root"
+
+[languages.en]
+weight = 0
+languageName = "English"
+
+[languages.no]
+weight = 10
+languageName = "FOO"
+
+[params]
+p1 = "p1_base"
+
+`
+
+	mm := afero.NewMemMapFs()
+
+	writeToFs(t, mm, "hugo.toml", configContent)
+
+	fb := htesting.NewTestdataBuilder(mm, "config/_default", t)
+
+	fb.Add("config.toml", `paginatePath = "pag_default"`)
+
+	fb.Add("params.yaml", `
+p2: "p2params_default"
+p3: "p3params_default"
+p4: "p4params_default"
+`)
+	fb.Add("menus.toml", `
+[[docs]]
+name = "About Hugo"
+weight = 1
+[[docs]]
+name = "Home"
+weight = 2
+	`)
+
+	fb.Add("menus.no.toml", `
+	[[docs]]
+	name = "Om Hugo"
+	weight = 1
+	`)
+
+	fb.Add("params.no.toml",
+		`
+p3 = "p3params_no_default"
+p4 = "p4params_no_default"`,
+	)
+	fb.Add("languages.no.toml", `languageName = "Norsk_no_default"`)
+
+	fb.Build()
+
+	fb = fb.WithWorkingDir("config/production")
+
+	fb.Add("config.toml", `paginatePath = "pag_production"`)
+
+	fb.Add("params.no.toml", `
+p2 = "p2params_no_production"
+p3 = "p3params_no_production"
+`)
+
+	fb.Build()
+
+	fb = fb.WithWorkingDir("config/development")
+
+	// This is set in all the config.toml variants above, but this will win.
+	fb.Add("config.toml", `paginatePath = "pag_development"`)
+
+	fb.Add("params.no.toml", `p3 = "p3params_no_development"`)
+	fb.Add("params.toml", `p3 = "p3params_development"`)
+
+	fb.Build()
+
+	cfg, _, err := LoadConfig(ConfigSourceDescriptor{Fs: mm, Environment: "development", Filename: "hugo.toml", AbsConfigDir: "config"})
+	assert.NoError(err)
+
+	assert.Equal("pag_development", cfg.GetString("paginatePath")) // /config/development/config.toml
+
+	assert.Equal(10, cfg.GetInt("languages.no.weight"))                          //  /config.toml
+	assert.Equal("Norsk_no_default", cfg.GetString("languages.no.languageName")) // /config/_default/languages.no.toml
+
+	assert.Equal("p1_base", cfg.GetString("params.p1"))
+	assert.Equal("p2params_default", cfg.GetString("params.p2")) // Is in both _default and production
+	assert.Equal("p3params_development", cfg.GetString("params.p3"))
+	assert.Equal("p3params_no_development", cfg.GetString("languages.no.params.p3"))
+
+	assert.Equal(2, len(cfg.Get("menus.docs").(([]map[string]interface{}))))
+	noMenus := cfg.Get("languages.no.menus.docs")
+	assert.NotNil(noMenus)
+	assert.Equal(1, len(noMenus.(([]map[string]interface{}))))
+
+}
+
+func TestLoadConfigDirError(t *testing.T) {
+	t.Parallel()
+
+	assert := require.New(t)
+
+	configContent := `
+baseURL = "https://example.org"
+
+`
+
+	mm := afero.NewMemMapFs()
+
+	writeToFs(t, mm, "hugo.toml", configContent)
+
+	fb := htesting.NewTestdataBuilder(mm, "config/development", t)
+
+	fb.Add("config.toml", `invalid & syntax`).Build()
+
+	_, _, err := LoadConfig(ConfigSourceDescriptor{Fs: mm, Environment: "development", Filename: "hugo.toml", AbsConfigDir: "config"})
+	assert.Error(err)
+
+	fe := herrors.UnwrapErrorWithFileContext(err)
+	assert.NotNil(fe)
+	assert.Equal(filepath.FromSlash("config/development/config.toml"), fe.Position().Filename)
+
+}
--- a/hugolib/hugo_sites.go
+++ b/hugolib/hugo_sites.go
@@ -21,6 +21,8 @@
 	"strings"
 	"sync"
 
+	"github.com/gohugoio/hugo/config"
+
 	"github.com/gohugoio/hugo/publisher"
 
 	"github.com/gohugoio/hugo/common/herrors"
@@ -361,7 +363,7 @@
 	}
 }
 
-func (h *HugoSites) createSitesFromConfig() error {
+func (h *HugoSites) createSitesFromConfig(cfg config.Provider) error {
 	oldLangs, _ := h.Cfg.Get("languagesSorted").(langs.Languages)
 
 	if err := loadLanguageSettings(h.Cfg, oldLangs); err != nil {
@@ -368,7 +370,7 @@
 		return err
 	}
 
-	depsCfg := deps.DepsCfg{Fs: h.Fs, Cfg: h.Cfg}
+	depsCfg := deps.DepsCfg{Fs: h.Fs, Cfg: cfg}
 
 	sites, err := createSitesFromConfig(depsCfg)
 
@@ -412,9 +414,9 @@
 type BuildCfg struct {
 	// Reset site state before build. Use to force full rebuilds.
 	ResetState bool
-	// Re-creates the sites from configuration before a build.
+	// If set, we re-create the sites from the given configuration before a build.
 	// This is needed if new languages are added.
-	CreateSitesFromConfig bool
+	NewConfig config.Provider
 	// Skip rendering. Useful for testing.
 	SkipRender bool
 	// Use this to indicate what changed (for rebuilds).
--- a/hugolib/hugo_sites_build.go
+++ b/hugolib/hugo_sites_build.go
@@ -144,8 +144,8 @@
 		h.reset()
 	}
 
-	if config.CreateSitesFromConfig {
-		if err := h.createSitesFromConfig(); err != nil {
+	if config.NewConfig != nil {
+		if err := h.createSitesFromConfig(config.NewConfig); err != nil {
 			return err
 		}
 	}
@@ -154,8 +154,8 @@
 }
 
 func (h *HugoSites) initRebuild(config *BuildCfg) error {
-	if config.CreateSitesFromConfig {
-		return errors.New("Rebuild does not support 'CreateSitesFromConfig'.")
+	if config.NewConfig != nil {
+		return errors.New("Rebuild does not support 'NewConfig'.")
 	}
 
 	if config.ResetState {
--- a/hugolib/hugo_sites_build_test.go
+++ b/hugolib/hugo_sites_build_test.go
@@ -11,14 +11,11 @@
 	"path/filepath"
 	"time"
 
-	"github.com/gohugoio/hugo/langs"
-
 	"github.com/fortytw2/leaktest"
 	"github.com/fsnotify/fsnotify"
 	"github.com/gohugoio/hugo/helpers"
 	"github.com/gohugoio/hugo/hugofs"
 	"github.com/spf13/afero"
-	"github.com/spf13/viper"
 	"github.com/stretchr/testify/require"
 )
 
@@ -661,9 +658,8 @@
 
 	sites := b.H
 
-	// Watching does not work with in-memory fs, so we trigger a reload manually
-	assert.NoError(sites.Cfg.(*langs.Language).Cfg.(*viper.Viper).ReadInConfig())
-	err := b.H.Build(BuildCfg{CreateSitesFromConfig: true})
+	assert.NoError(b.LoadConfig())
+	err := b.H.Build(BuildCfg{NewConfig: b.Cfg})
 
 	if err != nil {
 		t.Fatalf("Failed to rebuild sites: %s", err)
@@ -723,10 +719,9 @@
 		"DefaultContentLanguageInSubdir": false,
 	})
 
-	// Watching does not work with in-memory fs, so we trigger a reload manually
-	// This does not look pretty, so we should think of something else.
-	assert.NoError(b.H.Cfg.(*langs.Language).Cfg.(*viper.Viper).ReadInConfig())
-	err := b.H.Build(BuildCfg{CreateSitesFromConfig: true})
+	assert.NoError(b.LoadConfig())
+	err := b.H.Build(BuildCfg{NewConfig: b.Cfg})
+
 	if err != nil {
 		t.Fatalf("Failed to rebuild sites: %s", err)
 	}
--- a/hugolib/page.go
+++ b/hugolib/page.go
@@ -1647,7 +1647,12 @@
 	p.pageMenusInit.Do(func() {
 		p.pageMenus = PageMenus{}
 
-		if ms, ok := p.params["menu"]; ok {
+		ms, ok := p.params["menus"]
+		if !ok {
+			ms, ok = p.params["menu"]
+		}
+
+		if ok {
 			link := p.RelPermalink()
 
 			me := MenuEntry{Page: p, Name: p.LinkTitle(), Weight: p.Weight, URL: link}
--- a/hugolib/page_test.go
+++ b/hugolib/page_test.go
@@ -1436,7 +1436,7 @@
 		{func(n *Page) bool { return n.IsNode() }},
 		{func(n *Page) bool { return !n.IsPage() }},
 		{func(n *Page) bool { return n.Scratch() != nil }},
-		{func(n *Page) bool { return n.Hugo().Version != "" }},
+		{func(n *Page) bool { return n.Hugo().Version() != "" }},
 	} {
 
 		n := s.newHomePage()
--- a/hugolib/paths/themes.go
+++ b/hugolib/paths/themes.go
@@ -20,7 +20,6 @@
 	"github.com/gohugoio/hugo/config"
 	"github.com/spf13/afero"
 	"github.com/spf13/cast"
-	"github.com/spf13/viper"
 )
 
 type ThemeConfig struct {
@@ -73,18 +72,11 @@
 	var tc ThemeConfig
 
 	if configFilename != "" {
-		v := viper.New()
-		v.SetFs(c.fs)
-		v.AutomaticEnv()
-		v.SetEnvPrefix("hugo")
-		v.SetConfigFile(configFilename)
-
-		err := v.ReadInConfig()
+		var err error
+		cfg, err = config.FromFile(c.fs, configFilename)
 		if err != nil {
-			return tc, err
+			return tc, nil
 		}
-		cfg = v
-
 	}
 
 	tc = ThemeConfig{Name: name, ConfigFilename: configFilename, Cfg: cfg}
--- a/hugolib/site.go
+++ b/hugolib/site.go
@@ -1226,7 +1226,7 @@
 		Data:                           &s.Data,
 		owner:                          s.owner,
 		s:                              s,
-		hugoInfo:                       hugo.NewInfo(),
+		hugoInfo:                       hugo.NewInfo(s.Cfg.GetString("environment")),
 		// TODO(bep) make this Menu and similar into delegate methods on SiteInfo
 		Taxonomies: s.Taxonomies,
 	}
@@ -1370,7 +1370,7 @@
 
 	ret := Menus{}
 
-	if menus := s.Language.GetStringMap("menu"); menus != nil {
+	if menus := s.Language.GetStringMap("menus"); menus != nil {
 		for name, menu := range menus {
 			m, err := cast.ToSliceE(menu)
 			if err != nil {
--- a/hugolib/testhelpers_test.go
+++ b/hugolib/testhelpers_test.go
@@ -322,6 +322,15 @@
 	return s
 }
 
+func (s *sitesBuilder) LoadConfig() error {
+	cfg, _, err := LoadConfig(ConfigSourceDescriptor{Fs: s.Fs.Source, Filename: "config." + s.configFormat})
+	if err != nil {
+		return err
+	}
+	s.Cfg = cfg
+	return nil
+}
+
 func (s *sitesBuilder) CreateSitesE() error {
 	s.addDefaults()
 	s.writeFilePairs("content", s.contentFilePairs)
@@ -334,18 +343,9 @@
 	s.writeFilePairs("i18n", s.i18nFilePairsAdded)
 
 	if s.Cfg == nil {
-		cfg, _, err := LoadConfig(ConfigSourceDescriptor{Fs: s.Fs.Source, Filename: "config." + s.configFormat})
-		if err != nil {
+		if err := s.LoadConfig(); err != nil {
 			return err
 		}
-		// TODO(bep)
-		/*		expectedConfigs := 1
-				if s.theme != "" {
-					expectedConfigs = 2
-				}
-				require.Equal(s.T, expectedConfigs, len(configFiles), fmt.Sprintf("Configs: %v", configFiles))
-		*/
-		s.Cfg = cfg
 	}
 
 	sites, err := NewHugoSites(deps.DepsCfg{Fs: s.Fs, Cfg: s.Cfg, Logger: s.logger, Running: s.running})
--- a/magefile.go
+++ b/magefile.go
@@ -21,10 +21,10 @@
 
 const (
 	packageName  = "github.com/gohugoio/hugo"
-	noGitLdflags = "-X $PACKAGE/common/hugo.BuildDate=$BUILD_DATE"
+	noGitLdflags = "-X $PACKAGE/common/hugo.buildDate=$BUILD_DATE"
 )
 
-var ldflags = "-X $PACKAGE/common/hugo.CommitHash=$COMMIT_HASH -X $PACKAGE/common/hugo.BuildDate=$BUILD_DATE"
+var ldflags = "-X $PACKAGE/common/hugo.commitHash=$COMMIT_HASH -X $PACKAGE/common/hugo.buildDate=$BUILD_DATE"
 
 // allow user to override go executable by running as GOEXE=xxx make ... on unix-like systems
 var goexe = "go"
--- a/parser/metadecoders/decoder.go
+++ b/parser/metadecoders/decoder.go
@@ -22,6 +22,7 @@
 	"github.com/BurntSushi/toml"
 	"github.com/chaseadamsio/goorgeous"
 	"github.com/pkg/errors"
+	"github.com/spf13/afero"
 	"github.com/spf13/cast"
 	yaml "gopkg.in/yaml.v2"
 )
@@ -37,7 +38,21 @@
 	err := unmarshal(data, f, &m)
 
 	return m, err
+}
 
+// UnmarshalFileToMap is the same as UnmarshalToMap, but reads the data from
+// the given filename.
+func UnmarshalFileToMap(fs afero.Fs, filename string) (map[string]interface{}, error) {
+	format := FormatFromString(filename)
+	if format == "" {
+		return nil, errors.Errorf("%q is not a valid configuration format", filename)
+	}
+
+	data, err := afero.ReadFile(fs, filename)
+	if err != nil {
+		return nil, err
+	}
+	return UnmarshalToMap(data, format)
 }
 
 // Unmarshal will unmarshall data in format f into an interface{}.
--- a/parser/metadecoders/format.go
+++ b/parser/metadecoders/format.go
@@ -14,6 +14,7 @@
 package metadecoders
 
 import (
+	"path/filepath"
 	"strings"
 
 	"github.com/gohugoio/hugo/parser/pageparser"
@@ -34,6 +35,11 @@
 // into a Format. It returns an empty string for unknown formats.
 func FormatFromString(formatStr string) Format {
 	formatStr = strings.ToLower(formatStr)
+	if strings.Contains(formatStr, ".") {
+		// Assume a filename
+		formatStr = strings.TrimPrefix(filepath.Ext(formatStr), ".")
+
+	}
 	switch formatStr {
 	case "yaml", "yml":
 		return YAML
--- a/parser/metadecoders/format_test.go
+++ b/parser/metadecoders/format_test.go
@@ -32,6 +32,7 @@
 		{"yaml", YAML},
 		{"yml", YAML},
 		{"toml", TOML},
+		{"config.toml", TOML},
 		{"tOMl", TOML},
 		{"org", ORG},
 		{"foo", ""},