Create a struct with all of Hugo's config options

Primary motivation is documentation, but it will also hopefully simplify the code.

Also,

* Lower case the default output format names; this is in line with the custom ones (map keys) and how
it's treated all the places. This avoids doing `stringds.EqualFold` everywhere.

Closes #10896
Closes #10620
This commit is contained in:
Bjørn Erik Pedersen
2023-01-04 18:24:36 +01:00
parent 6aededf6b4
commit 241b21b0fd
337 changed files with 13377 additions and 14898 deletions

View File

@@ -14,213 +14,34 @@
package langs
import (
"fmt"
"path/filepath"
"sort"
"strings"
"github.com/gohugoio/hugo/common/maps"
"github.com/spf13/cast"
"errors"
"github.com/gohugoio/hugo/config"
"github.com/mitchellh/mapstructure"
)
type LanguagesConfig struct {
Languages Languages
Multihost bool
DefaultContentLanguageInSubdir bool
// LanguageConfig holds the configuration for a single language.
// This is what is read from the config file.
type LanguageConfig struct {
// The language name, e.g. "English".
LanguageName string
// The language title. When set, this will
// override site.Title for this language.
Title string
// The language direction, e.g. "ltr" or "rtl".
LanguageDirection string
// The language weight. When set to a non-zero value, this will
// be the main sort criteria for the language.
Weight int
}
func LoadLanguageSettings(cfg config.Provider, oldLangs Languages) (c LanguagesConfig, err error) {
defaultLang := strings.ToLower(cfg.GetString("defaultContentLanguage"))
if defaultLang == "" {
defaultLang = "en"
cfg.Set("defaultContentLanguage", defaultLang)
func DecodeConfig(m map[string]any) (map[string]LanguageConfig, error) {
m = maps.CleanConfigStringMap(m)
var langs map[string]LanguageConfig
if err := mapstructure.WeakDecode(m, &langs); err != nil {
return nil, err
}
var languages map[string]any
languagesFromConfig := cfg.GetParams("languages")
disableLanguages := cfg.GetStringSlice("disableLanguages")
if len(disableLanguages) == 0 {
languages = languagesFromConfig
} else {
languages = make(maps.Params)
for k, v := range languagesFromConfig {
for _, disabled := range disableLanguages {
if disabled == defaultLang {
return c, fmt.Errorf("cannot disable default language %q", defaultLang)
}
if strings.EqualFold(k, disabled) {
v.(maps.Params)["disabled"] = true
break
}
}
languages[k] = v
}
}
var languages2 Languages
if len(languages) == 0 {
languages2 = append(languages2, NewDefaultLanguage(cfg))
} else {
languages2, err = toSortedLanguages(cfg, languages)
if err != nil {
return c, fmt.Errorf("Failed to parse multilingual config: %w", err)
}
}
if oldLangs != nil {
// When in multihost mode, the languages are mapped to a server, so
// some structural language changes will need a restart of the dev server.
// The validation below isn't complete, but should cover the most
// important cases.
var invalid bool
if languages2.IsMultihost() != oldLangs.IsMultihost() {
invalid = true
} else {
if languages2.IsMultihost() && len(languages2) != len(oldLangs) {
invalid = true
}
}
if invalid {
return c, errors.New("language change needing a server restart detected")
}
if languages2.IsMultihost() {
// We need to transfer any server baseURL to the new language
for i, ol := range oldLangs {
nl := languages2[i]
nl.Set("baseURL", ol.GetString("baseURL"))
}
}
}
// The defaultContentLanguage is something the user has to decide, but it needs
// to match a language in the language definition list.
langExists := false
for _, lang := range languages2 {
if lang.Lang == defaultLang {
langExists = true
break
}
}
if !langExists {
return c, fmt.Errorf("site config value %q for defaultContentLanguage does not match any language definition", defaultLang)
}
c.Languages = languages2
c.Multihost = languages2.IsMultihost()
c.DefaultContentLanguageInSubdir = c.Multihost
sortedDefaultFirst := make(Languages, len(c.Languages))
for i, v := range c.Languages {
sortedDefaultFirst[i] = v
}
sort.Slice(sortedDefaultFirst, func(i, j int) bool {
li, lj := sortedDefaultFirst[i], sortedDefaultFirst[j]
if li.Lang == defaultLang {
return true
}
if lj.Lang == defaultLang {
return false
}
return i < j
})
cfg.Set("languagesSorted", c.Languages)
cfg.Set("languagesSortedDefaultFirst", sortedDefaultFirst)
cfg.Set("multilingual", len(languages2) > 1)
multihost := c.Multihost
if multihost {
cfg.Set("defaultContentLanguageInSubdir", true)
cfg.Set("multihost", true)
}
if multihost {
// The baseURL may be provided at the language level. If that is true,
// then every language must have a baseURL. In this case we always render
// to a language sub folder, which is then stripped from all the Permalink URLs etc.
for _, l := range languages2 {
burl := l.GetLocal("baseURL")
if burl == nil {
return c, errors.New("baseURL must be set on all or none of the languages")
}
}
}
for _, language := range c.Languages {
if language.initErr != nil {
return c, language.initErr
}
}
return c, nil
}
func toSortedLanguages(cfg config.Provider, l map[string]any) (Languages, error) {
languages := make(Languages, len(l))
i := 0
for lang, langConf := range l {
langsMap, err := maps.ToStringMapE(langConf)
if err != nil {
return nil, fmt.Errorf("Language config is not a map: %T", langConf)
}
language := NewLanguage(lang, cfg)
for loki, v := range langsMap {
switch loki {
case "title":
language.Title = cast.ToString(v)
case "languagename":
language.LanguageName = cast.ToString(v)
case "languagedirection":
language.LanguageDirection = cast.ToString(v)
case "weight":
language.Weight = cast.ToInt(v)
case "contentdir":
language.ContentDir = filepath.Clean(cast.ToString(v))
case "disabled":
language.Disabled = cast.ToBool(v)
case "params":
m := maps.ToStringMap(v)
// Needed for case insensitive fetching of params values
maps.PrepareParams(m)
for k, vv := range m {
language.SetParam(k, vv)
}
case "timezone":
if err := language.loadLocation(cast.ToString(v)); err != nil {
return nil, err
}
}
// Put all into the Params map
language.SetParam(loki, v)
// Also set it in the configuration map (for baseURL etc.)
language.Set(loki, v)
}
languages[i] = language
i++
}
sort.Sort(languages)
return languages, nil
return langs, nil
}

View File

@@ -37,12 +37,12 @@ var i18nWarningLogger = helpers.NewDistinctErrorLogger()
// Translator handles i18n translations.
type Translator struct {
translateFuncs map[string]translateFunc
cfg config.Provider
cfg config.AllProvider
logger loggers.Logger
}
// NewTranslator creates a new Translator for the given language bundle and configuration.
func NewTranslator(b *i18n.Bundle, cfg config.Provider, logger loggers.Logger) Translator {
func NewTranslator(b *i18n.Bundle, cfg config.AllProvider, logger loggers.Logger) Translator {
t := Translator{cfg: cfg, logger: logger, translateFuncs: make(map[string]translateFunc)}
t.initFuncs(b)
return t
@@ -55,7 +55,7 @@ func (t Translator) Func(lang string) translateFunc {
return f
}
t.logger.Infof("Translation func for language %v not found, use default.", lang)
if f, ok := t.translateFuncs[t.cfg.GetString("defaultContentLanguage")]; ok {
if f, ok := t.translateFuncs[t.cfg.DefaultContentLanguage()]; ok {
return f
}
@@ -66,7 +66,7 @@ func (t Translator) Func(lang string) translateFunc {
}
func (t Translator) initFuncs(bndl *i18n.Bundle) {
enableMissingTranslationPlaceholders := t.cfg.GetBool("enableMissingTranslationPlaceholders")
enableMissingTranslationPlaceholders := t.cfg.EnableMissingTranslationPlaceholders()
for _, lang := range bndl.LanguageTags() {
currentLang := lang
currentLangStr := currentLang.String()
@@ -122,7 +122,7 @@ func (t Translator) initFuncs(bndl *i18n.Bundle) {
t.logger.Warnf("Failed to get translated string for language %q and ID %q: %s", currentLangStr, translationID, err)
}
if t.cfg.GetBool("logI18nWarnings") {
if t.cfg.LogI18nWarnings() {
i18nWarningLogger.Printf("i18n|MISSING_TRANSLATION|%s|%s", currentLangStr, translationID)
}

View File

@@ -20,13 +20,11 @@ import (
"testing"
"github.com/gohugoio/hugo/common/types"
"github.com/gohugoio/hugo/modules"
"github.com/gohugoio/hugo/config/testconfig"
"github.com/gohugoio/hugo/tpl/tplimpl"
"github.com/gohugoio/hugo/common/loggers"
"github.com/gohugoio/hugo/langs"
"github.com/gohugoio/hugo/resources/page"
"github.com/spf13/afero"
@@ -34,7 +32,6 @@ import (
qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/config"
"github.com/gohugoio/hugo/hugofs"
)
var logger = loggers.NewErrorLogger()
@@ -394,26 +391,22 @@ other = "{{ . }} miesiąca"
} {
c.Run(test.name, func(c *qt.C) {
cfg := getConfig()
cfg := config.New()
cfg.Set("enableMissingTranslationPlaceholders", true)
fs := hugofs.NewMem(cfg)
cfg.Set("publishDir", "public")
afs := afero.NewMemMapFs()
err := afero.WriteFile(fs.Source, filepath.Join("i18n", test.lang+".toml"), []byte(test.templ), 0755)
err := afero.WriteFile(afs, filepath.Join("i18n", test.lang+".toml"), []byte(test.templ), 0755)
c.Assert(err, qt.IsNil)
tp := NewTranslationProvider()
depsCfg := newDepsConfig(tp, cfg, fs)
depsCfg.Logger = loggers.NewWarningLogger()
d, err := deps.New(depsCfg)
c.Assert(err, qt.IsNil)
c.Assert(d.LoadResources(), qt.IsNil)
d, tp := prepareDeps(afs, cfg)
f := tp.t.Func(test.lang)
ctx := context.Background()
for _, variant := range test.variants {
c.Assert(f(ctx, test.id, variant.Key), qt.Equals, variant.Value, qt.Commentf("input: %v", variant.Key))
c.Assert(int(depsCfg.Logger.LogCounters().WarnCounter.Count()), qt.Equals, 0)
c.Assert(int(d.Log.LogCounters().WarnCounter.Count()), qt.Equals, 0)
}
})
@@ -471,52 +464,33 @@ func TestGetPluralCount(t *testing.T) {
func prepareTranslationProvider(t testing.TB, test i18nTest, cfg config.Provider) *TranslationProvider {
c := qt.New(t)
fs := hugofs.NewMem(cfg)
afs := afero.NewMemMapFs()
for file, content := range test.data {
err := afero.WriteFile(fs.Source, filepath.Join("i18n", file), []byte(content), 0755)
err := afero.WriteFile(afs, filepath.Join("i18n", file), []byte(content), 0755)
c.Assert(err, qt.IsNil)
}
tp := NewTranslationProvider()
depsCfg := newDepsConfig(tp, cfg, fs)
d, err := deps.New(depsCfg)
c.Assert(err, qt.IsNil)
c.Assert(d.LoadResources(), qt.IsNil)
_, tp := prepareDeps(afs, cfg)
return tp
}
func newDepsConfig(tp *TranslationProvider, cfg config.Provider, fs *hugofs.Fs) deps.DepsCfg {
l := langs.NewLanguage("en", cfg)
l.Set("i18nDir", "i18n")
return deps.DepsCfg{
Language: l,
Site: page.NewDummyHugoSite(cfg),
Cfg: cfg,
Fs: fs,
Logger: logger,
TemplateProvider: tplimpl.DefaultTemplateProvider,
TranslationProvider: tp,
}
}
func getConfig() config.Provider {
v := config.NewWithTestDefaults()
langs.LoadLanguageSettings(v, nil)
mod, err := modules.CreateProjectModule(v)
if err != nil {
func prepareDeps(afs afero.Fs, cfg config.Provider) (*deps.Deps, *TranslationProvider) {
d := testconfig.GetTestDeps(afs, cfg)
translationProvider := NewTranslationProvider()
d.TemplateProvider = tplimpl.DefaultTemplateProvider
d.TranslationProvider = translationProvider
d.Site = page.NewDummyHugoSite(cfg)
if err := d.Compile(nil); err != nil {
panic(err)
}
v.Set("allModules", modules.Modules{mod})
return v
return d, translationProvider
}
func TestI18nTranslate(t *testing.T) {
c := qt.New(t)
var actual, expected string
v := getConfig()
v := config.New()
// Test without and with placeholders
for _, enablePlaceholders := range []bool{false, true} {
@@ -537,7 +511,7 @@ func TestI18nTranslate(t *testing.T) {
}
func BenchmarkI18nTranslate(b *testing.B) {
v := getConfig()
v := config.New()
for _, test := range i18nTests {
b.Run(test.name, func(b *testing.B) {
tp := prepareTranslationProvider(b, test, v)

View File

@@ -45,10 +45,10 @@ func NewTranslationProvider() *TranslationProvider {
}
// Update updates the i18n func in the provided Deps.
func (tp *TranslationProvider) Update(d *deps.Deps) error {
spec := source.NewSourceSpec(d.PathSpec, nil, nil)
func (tp *TranslationProvider) NewResource(dst *deps.Deps) error {
spec := source.NewSourceSpec(dst.PathSpec, nil, nil)
var defaultLangTag, err = language.Parse(d.Cfg.GetString("defaultContentLanguage"))
var defaultLangTag, err = language.Parse(dst.Conf.DefaultContentLanguage())
if err != nil {
defaultLangTag = language.English
}
@@ -61,7 +61,7 @@ func (tp *TranslationProvider) Update(d *deps.Deps) error {
// The source dirs are ordered so the most important comes first. Since this is a
// last key win situation, we have to reverse the iteration order.
dirs := d.BaseFs.I18n.Dirs
dirs := dst.BaseFs.I18n.Dirs
for i := len(dirs) - 1; i >= 0; i-- {
dir := dirs[i]
src := spec.NewFilesystemFromFileMetaInfo(dir)
@@ -76,11 +76,12 @@ func (tp *TranslationProvider) Update(d *deps.Deps) error {
}
}
tp.t = NewTranslator(bundle, d.Cfg, d.Log)
tp.t = NewTranslator(bundle, dst.Conf, dst.Log)
d.Translate = tp.t.Func(d.Language.Lang)
dst.Translate = tp.t.Func(dst.Conf.Language().Lang)
return nil
}
const artificialLangTagPrefix = "art-x-"
@@ -123,9 +124,8 @@ func addTranslationFile(bundle *i18n.Bundle, r source.File) error {
}
// Clone sets the language func for the new language.
func (tp *TranslationProvider) Clone(d *deps.Deps) error {
d.Translate = tp.t.Func(d.Language.Lang)
func (tp *TranslationProvider) CloneResource(dst, src *deps.Deps) error {
dst.Translate = tp.t.Func(dst.Conf.Language().Lang)
return nil
}

View File

@@ -1,4 +1,4 @@
// Copyright 2018 The Hugo Authors. All rights reserved.
// Copyright 2023 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.
@@ -16,8 +16,6 @@ package langs
import (
"fmt"
"sort"
"strings"
"sync"
"time"
@@ -25,97 +23,32 @@ import (
"golang.org/x/text/language"
"github.com/gohugoio/hugo/common/htime"
"github.com/gohugoio/hugo/common/maps"
"github.com/gohugoio/hugo/config"
"github.com/gohugoio/locales"
translators "github.com/gohugoio/localescompressed"
)
// These are the settings that should only be looked up in the global Viper
// config and not per language.
// This list may not be complete, but contains only settings that we know
// will be looked up in both.
// This isn't perfect, but it is ultimately the user who shoots him/herself in
// the foot.
// See the pathSpec.
var globalOnlySettings = map[string]bool{
strings.ToLower("defaultContentLanguageInSubdir"): true,
strings.ToLower("defaultContentLanguage"): true,
strings.ToLower("multilingual"): true,
strings.ToLower("assetDir"): true,
strings.ToLower("resourceDir"): true,
strings.ToLower("build"): true,
}
// Language manages specific-language configuration.
type Language struct {
Lang string
LanguageName string
LanguageDirection string
Title string
Weight int
// The language code, e.g. "en" or "no".
// This is currently only settable as the key in the language map in the config.
Lang string
// For internal use.
Disabled bool
// If set per language, this tells Hugo that all content files without any
// language indicator (e.g. my-page.en.md) is in this language.
// This is usually a path relative to the working dir, but it can be an
// absolute directory reference. It is what we get.
// For internal use.
ContentDir string
// Global config.
// For internal use.
Cfg config.Provider
// Language specific config.
// For internal use.
LocalCfg config.Provider
// Composite config.
// For internal use.
config.Provider
// These are params declared in the [params] section of the language merged with the
// site's params, the most specific (language) wins on duplicate keys.
params map[string]any
paramsMu sync.Mutex
paramsSet bool
// Fields from the language config.
LanguageConfig
// Used for date formatting etc. We don't want these exported to the
// templates.
// TODO(bep) do the same for some of the others.
translator locales.Translator
timeFormatter htime.TimeFormatter
tag language.Tag
collator *Collator
location *time.Location
// Error during initialization. Will fail the build.
initErr error
}
// For internal use.
func (l *Language) String() string {
return l.Lang
}
// NewLanguage creates a new language.
func NewLanguage(lang string, cfg config.Provider) *Language {
// Note that language specific params will be overridden later.
// We should improve that, but we need to make a copy:
params := make(map[string]any)
for k, v := range cfg.GetStringMap("params") {
params[k] = v
}
maps.PrepareParams(params)
localCfg := config.New()
compositeConfig := config.NewCompositeConfig(cfg, localCfg)
func NewLanguage(lang, defaultContentLanguage, timeZone string, languageConfig LanguageConfig) (*Language, error) {
translator := translators.GetTranslator(lang)
if translator == nil {
translator = translators.GetTranslator(cfg.GetString("defaultContentLanguage"))
translator = translators.GetTranslator(defaultContentLanguage)
if translator == nil {
translator = translators.GetTranslator("en")
}
@@ -134,76 +67,31 @@ func NewLanguage(lang string, cfg config.Provider) *Language {
}
l := &Language{
Lang: lang,
ContentDir: cfg.GetString("contentDir"),
Cfg: cfg, LocalCfg: localCfg,
Provider: compositeConfig,
params: params,
translator: translator,
timeFormatter: htime.NewTimeFormatter(translator),
tag: tag,
collator: coll,
Lang: lang,
LanguageConfig: languageConfig,
translator: translator,
timeFormatter: htime.NewTimeFormatter(translator),
tag: tag,
collator: coll,
}
if err := l.loadLocation(cfg.GetString("timeZone")); err != nil {
l.initErr = err
}
return l, l.loadLocation(timeZone)
return l
}
// NewDefaultLanguage creates the default language for a config.Provider.
// If not otherwise specified the default is "en".
func NewDefaultLanguage(cfg config.Provider) *Language {
defaultLang := cfg.GetString("defaultContentLanguage")
if defaultLang == "" {
defaultLang = "en"
func (l *Language) loadLocation(tzStr string) error {
location, err := time.LoadLocation(tzStr)
if err != nil {
return fmt.Errorf("invalid timeZone for language %q: %w", l.Lang, err)
}
l.location = location
return NewLanguage(defaultLang, cfg)
return nil
}
// Languages is a sortable list of languages.
type Languages []*Language
// NewLanguages creates a sorted list of languages.
// NOTE: function is currently unused.
func NewLanguages(l ...*Language) Languages {
languages := make(Languages, len(l))
for i := 0; i < len(l); i++ {
languages[i] = l[i]
}
sort.Sort(languages)
return languages
}
func (l Languages) Len() int { return len(l) }
func (l Languages) Less(i, j int) bool {
wi, wj := l[i].Weight, l[j].Weight
if wi == wj {
return l[i].Lang < l[j].Lang
}
return wj == 0 || wi < wj
}
func (l Languages) Swap(i, j int) { l[i], l[j] = l[j], l[i] }
// Params returns language-specific params merged with the global params.
func (l *Language) Params() maps.Params {
// TODO(bep) this construct should not be needed. Create the
// language params in one go.
l.paramsMu.Lock()
defer l.paramsMu.Unlock()
if !l.paramsSet {
maps.PrepareParams(l.params)
l.paramsSet = true
}
return l.params
}
func (l Languages) AsSet() map[string]bool {
m := make(map[string]bool)
for _, lang := range l {
@@ -222,73 +110,6 @@ func (l Languages) AsOrdinalSet() map[string]int {
return m
}
// IsMultihost returns whether there are more than one language and at least one of
// the languages has baseURL specified on the language level.
func (l Languages) IsMultihost() bool {
if len(l) <= 1 {
return false
}
for _, lang := range l {
if lang.GetLocal("baseURL") != nil {
return true
}
}
return false
}
// SetParam sets a param with the given key and value.
// SetParam is case-insensitive.
// For internal use.
func (l *Language) SetParam(k string, v any) {
l.paramsMu.Lock()
defer l.paramsMu.Unlock()
if l.paramsSet {
panic("params cannot be changed once set")
}
l.params[k] = v
}
// GetLocal gets a configuration value set on language level. It will
// not fall back to any global value.
// It will return nil if a value with the given key cannot be found.
// For internal use.
func (l *Language) GetLocal(key string) any {
if l == nil {
panic("language not set")
}
key = strings.ToLower(key)
if !globalOnlySettings[key] {
return l.LocalCfg.Get(key)
}
return nil
}
// For internal use.
func (l *Language) Set(k string, v any) {
k = strings.ToLower(k)
if globalOnlySettings[k] {
return
}
l.Provider.Set(k, v)
}
// Merge is currently not supported for Language.
// For internal use.
func (l *Language) Merge(key string, value any) {
panic("Not supported")
}
// IsSet checks whether the key is set in the language or the related config store.
// For internal use.
func (l *Language) IsSet(key string) bool {
key = strings.ToLower(key)
if !globalOnlySettings[key] {
return l.Provider.IsSet(key)
}
return l.Cfg.IsSet(key)
}
// Internal access to unexported Language fields.
// This construct is to prevent them from leaking to the templates.
@@ -308,16 +129,6 @@ func GetCollator(l *Language) *Collator {
return l.collator
}
func (l *Language) loadLocation(tzStr string) error {
location, err := time.LoadLocation(tzStr)
if err != nil {
return fmt.Errorf("invalid timeZone for language %q: %w", l.Lang, err)
}
l.location = location
return nil
}
type Collator struct {
sync.Mutex
c *collate.Collator

View File

@@ -18,39 +18,10 @@ import (
"testing"
qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/config"
"golang.org/x/text/collate"
"golang.org/x/text/language"
)
func TestGetGlobalOnlySetting(t *testing.T) {
c := qt.New(t)
v := config.NewWithTestDefaults()
v.Set("defaultContentLanguageInSubdir", true)
v.Set("contentDir", "content")
v.Set("paginatePath", "page")
lang := NewDefaultLanguage(v)
lang.Set("defaultContentLanguageInSubdir", false)
lang.Set("paginatePath", "side")
c.Assert(lang.GetBool("defaultContentLanguageInSubdir"), qt.Equals, true)
c.Assert(lang.GetString("paginatePath"), qt.Equals, "side")
}
func TestLanguageParams(t *testing.T) {
c := qt.New(t)
v := config.NewWithTestDefaults()
v.Set("p1", "p1cfg")
v.Set("contentDir", "content")
lang := NewDefaultLanguage(v)
lang.SetParam("p1", "p1p")
c.Assert(lang.Params()["p1"], qt.Equals, "p1p")
c.Assert(lang.Get("p1"), qt.Equals, "p1cfg")
}
func TestCollator(t *testing.T) {
c := qt.New(t)