diff --git a/Gopkg.lock b/Gopkg.lock index 1b766e9ff54..4b6d6ba5441 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -274,6 +274,12 @@ packages = ["."] 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" @@ -417,6 +423,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "4657586103d844434bda6db23d03f30e2ae0db16dc48746b9559ce742902535a" + inputs-digest = "13ab39f8bfafadc12c05726e565ee3f3d94bf7d6c0e8adf04056de0691bf2dd6" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index 4e0cd5c6b07..fc1af824bc4 100644 --- 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" diff --git a/commands/hugo.go b/commands/hugo.go index a13aa91b540..4424590c9da 100644 --- a/commands/hugo.go +++ b/commands/hugo.go @@ -306,7 +306,14 @@ func InitializeConfig(running bool, doWithCommandeer func(c *commandeer) error, // 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}) + var dir string + if source != "" { + dir, _ = filepath.Abs(source) + } else { + dir, _ = os.Getwd() + } + + config, err := hugolib.LoadConfig(hugolib.ConfigSourceDescriptor{Fs: osFs, Src: source, WorkingDir: dir, Name: cfgFile}) if err != nil { return nil, err } @@ -358,12 +365,6 @@ func InitializeConfig(running bool, doWithCommandeer func(c *commandeer) error, config.Set("publishDir", destination) } - var dir string - if source != "" { - dir, _ = filepath.Abs(source) - } else { - dir, _ = os.Getwd() - } config.Set("workingDir", dir) if contentDir != "" { diff --git a/helpers/path.go b/helpers/path.go index 44d53d018df..0a854435770 100644 --- a/helpers/path.go +++ b/helpers/path.go @@ -154,11 +154,16 @@ func ReplaceExtension(path string, newExt string) string { // AbsPathify creates an absolute path if given a relative path. If already // absolute, the path is just cleaned. func (p *PathSpec) AbsPathify(inPath string) string { + return AbsPathify(p.workingDir, inPath) +} + +// AbsPathify creates an absolute path if given a working dir and arelative path. +// If already absolute, the path is just cleaned. +func AbsPathify(workingDir, inPath string) string { if filepath.IsAbs(inPath) { return filepath.Clean(inPath) } - - return filepath.Join(p.workingDir, inPath) + return filepath.Join(workingDir, inPath) } // GetLayoutDirPath returns the absolute path to the layout file dir diff --git a/hugolib/config.go b/hugolib/config.go index e47e65435b1..9a22d70c8c1 100644 --- a/hugolib/config.go +++ b/hugolib/config.go @@ -16,6 +16,7 @@ package hugolib import ( "errors" "fmt" + "path/filepath" "io" "strings" @@ -28,9 +29,10 @@ import ( // ConfigSourceDescriptor describes where to find the config (e.g. config.toml etc.). type ConfigSourceDescriptor struct { - Fs afero.Fs - Src string - Name string + Fs afero.Fs + Src string + WorkingDir string + Name string } func (d ConfigSourceDescriptor) configFilenames() []string { @@ -85,7 +87,16 @@ func LoadConfig(d ConfigSourceDescriptor) (*viper.Viper, error) { return v, err } + if err := loadThemeConfig(d, v); err != nil { + return v, err + } + + if err := loadLanguageSettings(v, nil); err != nil { + return v, err + } + return v, nil + } func loadLanguageSettings(cfg config.Provider, oldLangs helpers.Languages) error { @@ -201,6 +212,127 @@ func loadLanguageSettings(cfg config.Provider, oldLangs helpers.Languages) error return nil } +func loadThemeConfig(d ConfigSourceDescriptor, v1 *viper.Viper) 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{"params", "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, we do not add language definitions. + if v1.IsSet("languages") && v2.IsSet("languages") { + v1Langs := v1.GetStringMap(languagesKey) + for k, _ := range v1Langs { + mergeStringMapKeepLeft(languagesKey+"."+k+"."+paramsKey, 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)) + } + } + } + + // Add menu definitions from theme not found in project + if v2.IsSet("menu") { + v2menus := v2.GetStringMap(menuKey) + for k, v := range v2menus { + if !v1.IsSet(menuKey + "." + k) { + v1.SetDefault(menuKey+"."+k, v) + } + } + } + + return nil + +} + +func mergeStringMapKeepLeft(key string, v1, v2 *viper.Viper) { + + if !v1.IsSet(key) { + if v2.IsSet(key) { + v1.SetDefault(key, v2.Get(key)) + } + return + } + + if !v2.IsSet(key) { + return + } + + m1 := v1.GetStringMap(key) + m2 := v2.GetStringMap(key) + + for k, v := range m2 { + if _, found := m1[k]; !found { + m1[k] = v + } + } +} + func loadDefaultSettingsFor(v *viper.Viper) error { c, err := helpers.NewContentSpec(v) @@ -281,5 +413,5 @@ lastmod = ["lastmod" ,":fileModTime", ":default"] } - return loadLanguageSettings(v, nil) + return nil } diff --git a/hugolib/config_test.go b/hugolib/config_test.go index ec543d93dc6..21bbc9350e0 100644 --- a/hugolib/config_test.go +++ b/hugolib/config_test.go @@ -17,13 +17,15 @@ import ( "testing" "github.com/spf13/afero" - "github.com/stretchr/testify/assert" + "github.com/spf13/viper" "github.com/stretchr/testify/require" ) func TestLoadConfig(t *testing.T) { t.Parallel() + assert := require.New(t) + // Add a random config variable for testing. // side = page in Norwegian. configContent := ` @@ -37,13 +39,16 @@ func TestLoadConfig(t *testing.T) { cfg, err := LoadConfig(ConfigSourceDescriptor{Fs: mm, Name: "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 := ` @@ -62,6 +67,264 @@ func TestLoadMultiConfig(t *testing.T) { cfg, err := LoadConfig(ConfigSourceDescriptor{Fs: mm, Name: "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" + +[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" + +` + + 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.nb] +languageName = "Norsk2" +[languages.nb.params] +pl1 = "p1-nb-theme" +pl2 = "p2-nb-theme" + +[[menu.main]] +name = "menu-main-theme" + +[[menu.thememenu]] +name = "menu-theme" + +` + + // TODO(bep) config watch + 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", + }, +}`, 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", + "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", + "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", + }, + }, + }, +} +`, got["languages"]) + + b.AssertObject(` +map[string]interface {}{ + "main": []interface {}{ + map[string]interface {}{ + "name": "menu-main-main", + }, + }, + "thememenu": []interface {}{ + map[string]interface {}{ + "name": "menu-theme", + }, + }, +} +`, got["menu"]) + + assert.Equal("https://example.com/", got["baseurl"]) + + // 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"]) + } diff --git a/hugolib/site.go b/hugolib/site.go index 2e8898bd6bf..0ffe153e919 100644 --- a/hugolib/site.go +++ b/hugolib/site.go @@ -296,6 +296,7 @@ func NewSite(cfg deps.DepsCfg) (*Site, error) { // NewSiteDefaultLang creates a new site in the default language. // The site will have a template system loaded and ready to use. // Note: This is mainly used in single site tests. +// TODO(bep) test refactor -- remove func NewSiteDefaultLang(withTemplate ...func(templ tpl.TemplateHandler) error) (*Site, error) { v := viper.New() if err := loadDefaultSettingsFor(v); err != nil { @@ -307,6 +308,7 @@ func NewSiteDefaultLang(withTemplate ...func(templ tpl.TemplateHandler) error) ( // NewEnglishSite creates a new site in English language. // The site will have a template system loaded and ready to use. // Note: This is mainly used in single site tests. +// TODO(bep) test refactor -- remove func NewEnglishSite(withTemplate ...func(templ tpl.TemplateHandler) error) (*Site, error) { v := viper.New() if err := loadDefaultSettingsFor(v); err != nil { diff --git a/hugolib/testhelpers_test.go b/hugolib/testhelpers_test.go index ab23b343cf7..ceadf506b5a 100644 --- a/hugolib/testhelpers_test.go +++ b/hugolib/testhelpers_test.go @@ -10,6 +10,8 @@ import ( "strings" "text/template" + "github.com/sanity-io/litter" + jww "github.com/spf13/jwalterweatherman" "github.com/gohugoio/hugo/config" @@ -37,11 +39,15 @@ type sitesBuilder struct { Fs *hugofs.Fs T testing.TB + dumper litter.Options + // Aka the Hugo server mode. running bool H *HugoSites + theme string + // Default toml configFormat string @@ -63,7 +69,13 @@ func newTestSitesBuilder(t testing.TB) *sitesBuilder { v := viper.New() fs := hugofs.NewMem(v) - return &sitesBuilder{T: t, Fs: fs, configFormat: "toml"} + litterOptions := litter.Options{ + HidePrivateFields: true, + StripPackageNames: true, + Separator: " ", + } + + return &sitesBuilder{T: t, Fs: fs, configFormat: "toml", dumper: litterOptions} } func (s *sitesBuilder) Running() *sitesBuilder { @@ -97,6 +109,15 @@ func (s *sitesBuilder) WithConfigFile(format, conf string) *sitesBuilder { return s } +func (s *sitesBuilder) WithThemeConfigFile(format, conf string) *sitesBuilder { + if s.theme == "" { + s.theme = "test-theme" + } + filename := filepath.Join("themes", s.theme, "config."+format) + writeSource(s.T, s.Fs, filename, conf) + return s +} + func (s *sitesBuilder) WithSimpleConfigFile() *sitesBuilder { var config = ` baseURL = "http://example.com/" @@ -345,6 +366,16 @@ func (s *sitesBuilder) AssertFileContent(filename string, matches ...string) { } } +func (s *sitesBuilder) AssertObject(expected string, object interface{}) { + got := s.dumper.Sdump(object) + expected = strings.TrimSpace(expected) + + if expected != got { + fmt.Println(got) + s.Fatalf("expected\n%s\ngot\n%s", expected, got) + } +} + func (s *sitesBuilder) AssertFileContentRe(filename string, matches ...string) { content := readDestination(s.T, s.Fs, filename) for _, match := range matches {