]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
refactor: Deprecate language configuration and template methods
authorJoe Mooring <joe.mooring@veriphor.com>
Sat, 28 Feb 2026 09:36:28 +0000 (01:36 -0800)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Wed, 4 Mar 2026 10:45:52 +0000 (11:45 +0100)
Configuration:

- languageCode      -> locale (either in root or per-language)
- languageName      -> label
- languageDirection -> direction

Methods:

- .Language.LanguageCode      -> .Language.Locale
- .Language.LanguageName      -> .Language.Label
- .Language.LanguageDirection -> .Language.Direction
- .Site.LanguageCode          -> .Site.Language.Locale

Example configuration:

[languages.en]
  direction = 'ltr'
  label     = 'English'
  locale    = 'en-US'
  weight    = 1

Closes #14269

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
19 files changed:
commands/import.go
config/allconfig/allconfig.go
config/allconfig/alldecoders.go
create/skeletons/skeletons.go
create/skeletons/theme/layouts/baseof.html
docs/data/docs.yaml
hugolib/config_test.go
hugolib/site.go
hugolib/sitesmatrix/sitematrix_integration_test.go
langs/config.go
langs/i18n/translationProvider.go
langs/language.go
langs/language_test.go
resources/page/site.go
resources/page/testhelpers_test.go
tpl/tplimpl/embedded/templates/_partials/opengraph.html
tpl/tplimpl/embedded/templates/alias.html
tpl/tplimpl/embedded/templates/rss.xml
tpl/tplimpl/embedded/templates/sitemap.xml

index a8b1b627adc8dd61636449b9ebca0924ebcf5196..8d36fb92304d7831fcd21a9648f7244670f5aab6 100644 (file)
@@ -127,7 +127,7 @@ func (i *importCommand) createConfigFromJekyll(fs afero.Fs, inpath string, kind
        in := map[string]any{
                "baseURL":            baseURL,
                "title":              title,
-               "languageCode":       "en-us",
+               "locale":             "en-us",
                "disablePathToLower": true,
        }
 
index 91616490a5d0e6c140ef7b8eef1ca1b518f1b67a..e7598236ab99a9ebc1bbe82fabd9e6b6e16014e5 100644 (file)
@@ -399,6 +399,65 @@ func (c *Config) CompileConfig(logger loggers.Logger) error {
                return err
        }
 
+       // Legacy language values.
+       if c.LanguageCode != "" {
+               if !c.isLanguageClone {
+                       hugo.DeprecateWithLogger("project config key languageCode", "Use locale instead.", "v0.158.0", logger.Logger())
+               }
+               if c.Locale == "" {
+                       c.Locale = c.LanguageCode
+               }
+               c.LanguageCode = ""
+       }
+       for k, v := range c.Languages.Config.LanguageConfigs {
+               //lint:ignore SA1019 Keep as adapter for now.
+               if v.LanguageCode != "" {
+                       if !c.isLanguageClone {
+                               hugo.DeprecateWithLogger(fmt.Sprintf("project config key languages.%s.languageCode", k), fmt.Sprintf("Use languages.%s.locale instead.", k), "v0.158.0", logger.Logger())
+                       }
+                       if v.Locale == "" {
+                               //lint:ignore SA1019 Keep as adapter for now.
+                               v.Locale = v.LanguageCode
+                       }
+                       //lint:ignore SA1019 Keep as adapter for now.
+                       v.LanguageCode = ""
+               }
+               //lint:ignore SA1019 Keep as adapter for now.
+               if v.LanguageName != "" {
+                       if !c.isLanguageClone {
+                               hugo.DeprecateWithLogger(fmt.Sprintf("project config key languages.%s.languageName", k), fmt.Sprintf("Use languages.%s.label instead.", k), "v0.158.0", logger.Logger())
+                       }
+                       if v.Label == "" {
+                               //lint:ignore SA1019 Keep as adapter for now.
+                               v.Label = v.LanguageName
+                       }
+                       //lint:ignore SA1019 Keep as adapter for now.
+                       v.LanguageName = ""
+               }
+               //lint:ignore SA1019 Keep as adapter for now.
+               if v.LanguageDirection != "" {
+                       if !c.isLanguageClone {
+                               hugo.DeprecateWithLogger(fmt.Sprintf("project config key languages.%s.languageDirection", k), fmt.Sprintf("Use languages.%s.direction instead.", k), "v0.158.0", logger.Logger())
+                       }
+                       if v.Direction == "" {
+                               //lint:ignore SA1019 Keep as adapter for now.
+                               v.Direction = v.LanguageDirection
+                       }
+                       //lint:ignore SA1019 Keep as adapter for now.
+                       v.LanguageDirection = ""
+               }
+
+               c.Languages.Config.LanguageConfigs[k] = v
+               // Sorted is a snapshot of LanguageConfigs taken at decode time; keep it
+               // in sync so Configs.Init, which reads from Sorted, sees the migrated values.
+               for i, s := range c.Languages.Config.Sorted {
+                       if s.Name == k {
+                               c.Languages.Config.Sorted[i].LanguageConfig = v
+                               break
+                       }
+               }
+       }
+
        // Legacy privacy values.
        if c.Privacy.Twitter.Disable {
                hugo.DeprecateWithLogger("project config key privacy.twitter.disable", "Use privacy.x.disable instead.", "v0.141.0", logger.Logger())
@@ -671,8 +730,11 @@ type RootConfig struct {
        // The configured environment. Default is "development" for server and "production" for build.
        Environment string
 
-       // The default language code.
-       LanguageCode string
+       // Deprecated: Use Locale instead.
+       LanguageCode string `json:"-"`
+
+       // The default locale.
+       Locale string
 
        // Enable if the site content has CJK language (Chinese, Japanese, or Korean). This affects how Hugo counts words.
        HasCJKLanguage bool
@@ -843,7 +905,7 @@ func (c *Configs) Init(sourceFs afero.Fs, logger loggers.Logger) error {
                if !found {
                        return fmt.Errorf("invalid language configuration for %q", f.Name)
                }
-               language, err := langs.NewLanguage(f.Name, c.Base.DefaultContentLanguage, v.TimeZone, f.LanguageConfig)
+               language, err := langs.NewLanguage(f.Name, c.Base.DefaultContentLanguage, v.TimeZone, f.LanguageConfig, logger)
                if err != nil {
                        return err
                }
index 2fb0276d2f9fed33fc08bf5acad05704e63381e7..835ec19d1955ae9dccf20cf8cdebee67f6e02595 100644 (file)
@@ -226,27 +226,17 @@ var allDecoderSetups = map[string]decodeWeight{
                key: "languages",
                decode: func(d decodeWeight, p decodeConfig) error {
                        m := hmaps.CleanConfigStringMap(p.p.GetStringMap(d.key))
-                       if len(m) == 1 {
-                               // In v0.112.4 we moved this to the language config, but it's very commmon for mono language sites to have this at the top level.
-                               var first hmaps.Params
-                               var ok bool
-                               for _, v := range m {
-                                       first, ok = v.(hmaps.Params)
-                                       if ok {
-                                               break
-                                       }
-                               }
-                               if first != nil {
-                                       if _, found := first["languagecode"]; !found {
-                                               first["languagecode"] = p.p.GetString("languagecode")
-                                       }
-                               }
-                       }
+                       // Root-level locale/languageCode is passed to DecodeConfig so it can
+                       // be applied to the default content language inside langs.DecodeConfig.
+                       // They are passed separately so that an explicit per-lang languageCode
+                       // can override a root-level languageCode (but not a root-level locale).
+                       rootLocale := p.p.GetString("locale")
+                       rootLanguageCode := p.p.GetString("languagecode")
                        var (
                                err                    error
                                defaultContentLanguage string
                        )
-                       p.c.Languages, defaultContentLanguage, err = langs.DecodeConfig(p.c.RootConfig.DefaultContentLanguage, p.c.RootConfig.DisableLanguages, m)
+                       p.c.Languages, defaultContentLanguage, err = langs.DecodeConfig(p.c.RootConfig.DefaultContentLanguage, rootLocale, rootLanguageCode, p.c.RootConfig.DisableLanguages, m)
                        if err != nil {
                                return fmt.Errorf("failed to decode languages config: %w", err)
                        }
index 1989ed2024b47e9aa8c5cdde5b6729ab2e2f1fe4..835f305825db28c1c12d7f9a4d99d26717b87845 100644 (file)
@@ -42,9 +42,9 @@ func CreateTheme(createpath string, sourceFs afero.Fs, format string) error {
        format = strings.ToLower(format)
 
        projectConfig := map[string]any{
-               "baseURL":      "https://example.org/",
-               "languageCode": "en-US",
-               "title":        "My New Hugo Project",
+               "baseURL": "https://example.org/",
+               "locale":  "en-US",
+               "title":   "My New Hugo Project",
                "menus": map[string]any{
                        "main": []any{
                                map[string]any{
@@ -122,9 +122,9 @@ func CreateProject(createpath string, sourceFs afero.Fs, force bool, format stri
        }
 
        projectConfig := map[string]any{
-               "baseURL":      "https://example.org/",
-               "title":        "My New Hugo Project",
-               "languageCode": "en-us",
+               "baseURL": "https://example.org/",
+               "locale":  "en-us",
+               "title":   "My New Hugo Project",
        }
 
        err := createProjectConfig(sourceFs, createpath, projectConfig, format)
index 39dcbec61fc7176b6b73a802b29dcc4508775836..7d17aa55c30d0d6718e0f2c831c16aebceb86d02 100644 (file)
@@ -1,5 +1,5 @@
 <!DOCTYPE html>
-<html lang="{{ site.Language.LanguageCode }}" dir="{{ or site.Language.LanguageDirection `ltr` }}">
+<html lang="{{ site.Language.Locale }}" dir="{{ or site.Language.Direction `ltr` }}">
 <head>
   {{ partial "head.html" . }}
 </head>
index 478ecafb1de91787c64efe5c83d6c42c7192fad5..d3015af25d3a9b9153169f350af332d28e04985b 100644 (file)
@@ -1262,16 +1262,16 @@ config:
       hint: photo
       method: 2
       useSharpYuv: false
-  languageCode: ''
   languages:
     en:
+      direction: ''
       disabled: false
-      languageCode: ''
-      languageDirection: ''
-      languageName: ''
+      label: ''
+      locale: ''
       title: ''
       weight: 0
   layoutDir: layouts
+  locale: ''
   mainSections: null
   markup:
     asciiDocExt:
index 8af31a6329d82e22a34b786d04b1973181a857dc..0b3147bc33ca6b6cc4e9dc610c7dbed5c3974480 100644 (file)
@@ -1068,8 +1068,9 @@ LanguageCode: {{ .Site.LanguageCode }}|{{ site.Language.LanguageCode }}|
 
 
 `
-       b := Test(t, files)
+       b := Test(t, files, TestOptInfo())
 
+       b.AssertLogContains("deprecated")
        b.AssertFileContent("public/index.html", "LanguageCode: en-US|en-US|")
 }
 
@@ -1573,7 +1574,7 @@ languages:
   sv:
     weight: 3
     params: *params
-     
+
 -- layouts/all.html --
 Params: {{ site.Params }}|
 -- themes/mytheme/hugo.yaml --
@@ -1583,8 +1584,8 @@ definitions:
     p2: p2aliastheme
 
 params: *params
-  
+
+
 `
        b := Test(t, files)
 
@@ -1611,7 +1612,7 @@ languages:
   sv:
     weight: 2
     params: *params
-     
+
 -- layouts/all.html --
 Params: {{ site.Params }}|
 
@@ -1637,3 +1638,420 @@ func TestConfigYAMLNilMapIssue14074(t *testing.T) {
 
        Test(t, files)
 }
+
+// Issue 14269
+// When the legacy API deprecations are promoted to errors: flip Test() to TestE() and assert an error.
+func TestLanguageDeprecated(t *testing.T) {
+       t.Parallel()
+
+       const defaultConfig = `
+disableKinds = ['page','rss','section','sitemap','taxonomy','term']
+defaultContentLanguageInSubdir = true
+`
+
+       tests := []struct {
+               name   string
+               config string
+               layout string
+       }{
+               {
+                       name:   "config key languageCode",
+                       config: "languageCode = 'en-US'\n",
+                       layout: "-- layouts/home.html --\nhome\n",
+               },
+               {
+                       name:   "config key languages.en.languageCode",
+                       config: "[languages.en]\nlanguageCode = 'en-US'\n",
+                       layout: "-- layouts/home.html --\nhome\n",
+               },
+               {
+                       name:   "config key languages.en.languageDirection",
+                       config: "[languages.en]\nlanguageDirection = 'ltr'\n",
+                       layout: "-- layouts/home.html --\nhome\n",
+               },
+               {
+                       name:   "config key languages.en.languageName",
+                       config: "[languages.en]\nlanguageName = 'English'\n",
+                       layout: "-- layouts/home.html --\nhome\n",
+               },
+               {
+                       name:   "template Site.LanguageCode",
+                       config: "",
+                       layout: "-- layouts/home.html --\n{{ .Site.LanguageCode }}\n",
+               },
+               {
+                       name:   "template Language.LanguageCode",
+                       config: "",
+                       layout: "-- layouts/home.html --\n{{ .Site.Language.LanguageCode }}\n",
+               },
+               {
+                       name:   "template Language.LanguageDirection",
+                       config: "",
+                       layout: "-- layouts/home.html --\n{{ .Site.Language.LanguageDirection }}\n",
+               },
+               {
+                       name:   "template Language.LanguageName",
+                       config: "",
+                       layout: "-- layouts/home.html --\n{{ .Site.Language.LanguageName }}\n",
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       t.Parallel()
+                       files := "-- hugo.toml --\n" + defaultConfig + tt.config + tt.layout
+                       b := Test(t, files, TestOptInfo())
+                       b.AssertLogMatches("deprecated")
+               })
+       }
+}
+
+// Issue 14269
+// When the legacy API deprecations are promoted to errors: no changes needed.
+func TestLanguageNewAPI(t *testing.T) {
+       t.Parallel()
+
+       const (
+               defaultConfig = `
+disableKinds = ['page','rss','section','sitemap','taxonomy','term']
+defaultContentLanguageInSubdir = true
+`
+               layout = `
+-- layouts/home.html --
+{{ $l := .Site.Language -}}
+{{ printf "NA: %s|" $l.Name -}}
+{{ printf "LO: %s|" $l.Locale -}}
+{{ printf "DI: %s|" $l.Direction -}}
+{{ printf "LA: %s|" $l.Label -}}
+`
+       )
+
+       tests := []struct {
+               name   string
+               config string
+               want   map[string]string
+       }{
+               {
+                       name:   "Default config",
+                       config: "",
+                       want: map[string]string{
+                               "public/en/index.html": "NA: en|LO: en|DI: |LA: |",
+                       },
+               },
+               {
+                       name: "Multilingual per-lang new config keys",
+                       config: `
+[languages.en]
+locale = 'en-US'
+direction = 'ltr'
+label = 'English'
+[languages.fr]
+locale = 'fr-FR'
+direction = 'ltr'
+label = 'French'
+`,
+                       want: map[string]string{
+                               "public/en/index.html": "NA: en|LO: en-US|DI: ltr|LA: English|",
+                               "public/fr/index.html": "NA: fr|LO: fr-FR|DI: ltr|LA: French|",
+                       },
+               },
+               {
+                       name:   "Monolingual root locale",
+                       config: "locale = 'en-US'\n",
+                       want: map[string]string{
+                               "public/en/index.html": "NA: en|LO: en-US|DI: |LA: |",
+                       },
+               },
+               {
+                       name: "Multilingual root locale",
+                       config: `
+locale = 'en-US'
+[languages.en]
+[languages.fr]
+`,
+                       want: map[string]string{
+                               "public/en/index.html": "NA: en|LO: en-US|DI: |LA: |",
+                               "public/fr/index.html": "NA: fr|LO: fr|DI: |LA: |",
+                       },
+               },
+               {
+                       name: "Multilingual per-lang locale overrides root locale",
+                       config: `
+locale = 'en-NZ'
+[languages.en]
+locale = 'en-US'
+[languages.fr]
+locale = 'fr-FR'
+`,
+                       want: map[string]string{
+                               "public/en/index.html": "NA: en|LO: en-US|DI: |LA: |",
+                               "public/fr/index.html": "NA: fr|LO: fr-FR|DI: |LA: |",
+                       },
+               },
+               {
+                       name: "Multilingual non-default defaultContentLanguage root locale",
+                       config: `
+defaultContentLanguage = 'fr'
+locale = 'fr-FR'
+[languages.en]
+[languages.fr]
+`,
+                       want: map[string]string{
+                               "public/en/index.html": "NA: en|LO: en|DI: |LA: |",
+                               "public/fr/index.html": "NA: fr|LO: fr-FR|DI: |LA: |",
+                       },
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       t.Parallel()
+                       files := "-- hugo.toml --\n" + defaultConfig + tt.config + layout
+                       b := Test(t, files)
+                       for path, content := range tt.want {
+                               b.AssertFileContent(path, content)
+                       }
+               })
+       }
+}
+
+// Issue 14269
+// When the legacy API deprecations are promoted to errors: delete this function.
+func TestLanguageDeprecationMigration(t *testing.T) {
+       t.Parallel()
+
+       const (
+               defaultConfig = `
+disableKinds = ['page','rss','section','sitemap','taxonomy','term']
+defaultContentLanguageInSubdir = true
+`
+               layout = `
+-- layouts/home.html --
+{{ $l := .Site.Language -}}
+{{ printf "LO: %s|" $l.Locale -}}
+{{ printf "DI: %s|" $l.Direction -}}
+{{ printf "LA: %s|" $l.Label -}}
+`
+       )
+
+       tests := []struct {
+               name           string
+               config         string
+               want           map[string]string
+               wantLogMatches []string
+       }{
+               {
+                       name: "Monolingual root languageCode",
+                       config: `
+languageCode = 'en-US'
+`,
+                       want: map[string]string{
+                               "public/en/index.html": "LO: en-US|DI: |LA: |",
+                       },
+                       wantLogMatches: []string{
+                               `config key languageCode was deprecated`,
+                               `! config key languages.*\.languageCode was deprecated`,
+                               `! config key locale was deprecated`,
+                               `! config key languages.*\.locale was deprecated`,
+                       },
+               },
+               {
+                       name: "Monolingual locale wins over languageCode",
+                       config: `
+locale = 'en-US'
+languageCode = 'en-CA'
+`,
+                       want: map[string]string{
+                               "public/en/index.html": "LO: en-US|DI: |LA: |",
+                       },
+                       wantLogMatches: []string{
+                               `config key languageCode was deprecated`,
+                               `! config key languages.*\.languageCode was deprecated`,
+                               `! config key locale was deprecated`,
+                               `! config key languages.*\.locale was deprecated`,
+                       },
+               },
+               {
+                       name: "Multilingual root languageCode",
+                       config: `
+languageCode = 'en-US'
+[languages.en]
+[languages.fr]
+`,
+                       want: map[string]string{
+                               "public/en/index.html": "LO: en-US|DI: |LA: |",
+                               "public/fr/index.html": "LO: fr|DI: |LA: |",
+                       },
+                       wantLogMatches: []string{
+                               `config key languageCode was deprecated`,
+                               `! config key languages.*\.languageCode was deprecated`,
+                               `! config key locale was deprecated`,
+                               `! config key languages.*\.locale was deprecated`,
+                       },
+               },
+               {
+                       name: "Multilingual per-lang languageCode overrides root languageCode",
+                       config: `
+languageCode = 'en-NZ'
+[languages.en]
+languageCode = 'en-US'
+[languages.fr]
+languageCode = 'fr-FR'
+`,
+                       want: map[string]string{
+                               "public/en/index.html": "LO: en-US|DI: |LA: |",
+                               "public/fr/index.html": "LO: fr-FR|DI: |LA: |",
+                       },
+                       wantLogMatches: []string{
+                               `config key languageCode was deprecated`,
+                               `config key languages.en.languageCode was deprecated`,
+                               `config key languages.fr.languageCode was deprecated`,
+                               `! config key locale was deprecated`,
+                               `! config key languages.*\.locale was deprecated`,
+                       },
+               },
+               {
+                       name: "Multilingual per-lang locale overrides root languageCode",
+                       config: `
+languageCode = 'en-NZ'
+[languages.en]
+locale = 'en-US'
+[languages.fr]
+locale = 'fr-FR'
+`,
+                       want: map[string]string{
+                               "public/en/index.html": "LO: en-US|DI: |LA: |",
+                               "public/fr/index.html": "LO: fr-FR|DI: |LA: |",
+                       },
+                       wantLogMatches: []string{
+                               `config key languageCode was deprecated`,
+                               `! config key languages.*\.languageCode was deprecated`,
+                               `! config key locale was deprecated`,
+                               `! config key languages.*\.locale was deprecated`,
+                       },
+               },
+               {
+                       name: "Multilingual root locale overrides per-lang languageCode",
+                       config: `
+locale = 'en-NZ'
+[languages.en]
+languageCode = 'en-US'
+[languages.fr]
+languageCode = 'fr-FR'
+`,
+                       want: map[string]string{
+                               "public/en/index.html": "LO: en-NZ|DI: |LA: |",
+                               "public/fr/index.html": "LO: fr-FR|DI: |LA: |",
+                       },
+                       wantLogMatches: []string{
+                               `! config key languageCode was deprecated`,
+                               `config key languages.en.languageCode was deprecated`,
+                               `config key languages.fr.languageCode was deprecated`,
+                               `! config key locale was deprecated`,
+                               `! config key languages.*\.locale was deprecated`,
+                       },
+               },
+               {
+                       name: "Multilingual non-default defaultContentLanguage root languageCode",
+                       config: `
+defaultContentLanguage = 'fr'
+languageCode = 'fr-FR'
+[languages.en]
+[languages.fr]
+`,
+                       want: map[string]string{
+                               "public/en/index.html": "LO: en|DI: |LA: |",
+                               "public/fr/index.html": "LO: fr-FR|DI: |LA: |",
+                       },
+                       wantLogMatches: []string{
+                               `config key languageCode was deprecated`,
+                               `! config key languages.*\.languageCode was deprecated`,
+                               `! config key locale was deprecated`,
+                               `! config key languages.*\.locale was deprecated`,
+                       },
+               },
+               {
+                       name: "Multilingual per-lang old config keys",
+                       config: `
+[languages.en]
+languageCode = 'en-US'
+languageDirection = 'ltr'
+languageName = 'English'
+[languages.fr]
+languageCode = 'fr-FR'
+languageDirection = 'ltr'
+languageName = 'French'
+`,
+                       want: map[string]string{
+                               "public/en/index.html": "LO: en-US|DI: ltr|LA: English|",
+                               "public/fr/index.html": "LO: fr-FR|DI: ltr|LA: French|",
+                       },
+                       wantLogMatches: []string{
+                               `! config key languageCode was deprecated`,
+                               `config key languages.en.languageCode was deprecated`,
+                               `config key languages.fr.languageCode was deprecated`,
+                               `config key languages.en.languageDirection was deprecated`,
+                               `config key languages.fr.languageDirection was deprecated`,
+                               `config key languages.en.languageName was deprecated`,
+                               `config key languages.fr.languageName was deprecated`,
+                               `! config key locale was deprecated`,
+                               `! config key languages.*\.locale was deprecated`,
+                               `! config key languages.*\.direction was deprecated`,
+                               `! config key languages.*\.label was deprecated`,
+                       },
+               },
+               {
+                       name: "Multilingual per-lang new config keys override old",
+                       config: `
+[languages.en]
+locale = 'en-US'
+direction = 'ltr'
+label = 'English'
+
+languageCode = 'en-US'
+languageDirection = 'foo'
+languageName = 'English New Zealand'
+[languages.fr]
+locale = 'fr-FR'
+direction = 'ltr'
+label = 'French'
+
+languageCode = 'fr-CA'
+languageDirection = 'bar'
+languageName = 'French Canadian'
+`,
+                       want: map[string]string{
+                               "public/en/index.html": "LO: en-US|DI: ltr|LA: English|",
+                               "public/fr/index.html": "LO: fr-FR|DI: ltr|LA: French|",
+                       },
+                       wantLogMatches: []string{
+                               `! config key languageCode was deprecated`,
+                               `config key languages.en.languageCode was deprecated`,
+                               `config key languages.fr.languageCode was deprecated`,
+                               `config key languages.en.languageDirection was deprecated`,
+                               `config key languages.fr.languageDirection was deprecated`,
+                               `config key languages.en.languageName was deprecated`,
+                               `config key languages.fr.languageName was deprecated`,
+                               `! config key locale was deprecated`,
+                               `! config key languages.*\.locale was deprecated`,
+                               `! config key languages.*\.direction was deprecated`,
+                               `! config key languages.*\.label was deprecated`,
+                       },
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       t.Parallel()
+                       files := "-- hugo.toml --\n" + defaultConfig + "\n" + tt.config + "\n" + layout
+
+                       b := Test(t, files, TestOptInfo())
+                       for path, content := range tt.want {
+                               b.AssertFileContent(path, content)
+                       }
+                       for _, pattern := range tt.wantLogMatches {
+                               b.AssertLogMatches(pattern)
+                       }
+               })
+       }
+}
index 3ceccc440ae9ea73336fd6ae8a2e5dff3c7e57f8..8e0b2888feeb7c327717a4c962dbcf71f39cf143 100644 (file)
@@ -638,8 +638,10 @@ func (s *Site) Config() page.SiteConfig {
        }
 }
 
+// Deprecated: Use .Language.Locale instead.
 func (s *Site) LanguageCode() string {
-       return s.Language().LanguageCode()
+       hugo.DeprecateWithLogger(".Site.LanguageCode", "Use .Site.Language.Locale instead.", "v0.158.0", s.Language().Logger())
+       return s.Language().Locale()
 }
 
 // Returns all sites for all dimensions.
index 8706a0a434f26e79bf2efac83492c5a6e1b61d20..4e7bab8461a1db6ddc2b661b45b0d54db4d933d3 100644 (file)
@@ -912,7 +912,7 @@ weight = 4
 {{ .AddResource (dict "path" "hello.txt" "title" "Hello" "content" $contentTextEnglish "sites" (dict "matrix"  $en )) }}
 {{ .AddResource (dict "path" "hello.txt" "title" "Hello" "content" $contentTextSwedish "sites" (dict "matrix"  $sv )) }}
 {{ .AddResource (dict "path" "hello.txt" "title" "Hello" "content" $contentTextDanish "sites" (dict "matrix"  $da )) }}
+
 -- layouts/all.html --
 {{ $hello := .Resources.Get "hello.txt" }}
 All. {{ .Title }}|{{ .Site.Language.Name }}|hello: {{ with $hello }}{{ .RelPermalink }}: {{ .Content }}|{{ end }}|
@@ -1109,7 +1109,7 @@ Rotate(version): {{ with .Rotate "version" }}{{ range . }}{{ template "printp" .
 Rotate(role): {{ with .Rotate "role" }}{{ range . }}{{ template "printp" . }}|{{ end }}{{ end }}$
 {{ define "printp" }}{{ .RelPermalink }}:{{ with .Site }}{{ template "prints" . }}{{ end }}{{ end }}
 {{ define "prints" }}/l:{{ .Language.Name }}/v:{{ .Version.Name }}/r:{{ .Role.Name }}{{ end }}
+
 `
        }
 
@@ -1180,12 +1180,12 @@ target = 'content'
        b.Assert(toJSONAndMap(conf.Languages), qt.DeepEquals,
                map[string]any{
                        "en": map[string]any{
-                               "Disabled":          bool(false),
-                               "LanguageCode":      "",
-                               "LanguageDirection": "",
-                               "LanguageName":      "",
-                               "Title":             "",
-                               "Weight":            float64(0),
+                               "Direction": "",
+                               "Disabled":  bool(false),
+                               "Label":     "",
+                               "Locale":    "",
+                               "Title":     "",
+                               "Weight":    float64(0),
                        },
                })
 
index 0ba56ee6ff6451d539d8afcc640593d0870a6385..91ed2ac1c981dd6663801ec2ac6f74d30ec1082a 100644 (file)
@@ -29,18 +29,18 @@ import (
 // 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
+       // Deprecated: Use Label instead.
+       LanguageName string `json:"-"`
 
-       // The language code, e.g. "en-US".
-       LanguageCode string
+       // Deprecated: Use Locale instead.
+       LanguageCode string `json:"-"`
 
        // 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
+       // Deprecated: Use Direction instead.
+       LanguageDirection string `json:"-"`
 
        // The language weight. When set to a non-zero value, this will
        // be the main sort criteria for the language.
@@ -48,6 +48,15 @@ type LanguageConfig struct {
 
        // Set to true to disable this language.
        Disabled bool
+
+       // The language direction, e.g. "ltr" or "rtl".
+       Direction string
+
+       // The language name, e.g. "English".
+       Label string
+
+       // The locale, e.g. "en-US".
+       Locale string
 }
 
 type LanguageInternal struct {
@@ -120,7 +129,7 @@ func (ls LanguagesInternal) ForEachIndex() iter.Seq[int] {
        }
 }
 
-func (ls *LanguagesInternal) init(defaultContentLanguage string, disabledLanguages []string) (string, error) {
+func (ls *LanguagesInternal) init(defaultContentLanguage, rootLocale, rootLanguageCode string, disabledLanguages []string) (string, error) {
        const en = "en"
 
        if len(ls.LanguageConfigs) == 0 {
@@ -202,13 +211,40 @@ func (ls *LanguagesInternal) init(defaultContentLanguage string, disabledLanguag
                ls.LanguageConfigs[d.Name] = d.LanguageConfig
                ls.Sorted[defaultIdx] = d
                defaultContentLanguage = d.Name
+       }
 
+       // Apply root-level locale to the default content language if it has none.
+       // Done after defaultContentLanguage is resolved and Sorted is built so both
+       // are updated consistently. Other languages fall back to their Lang key.
+       // Priority: per-lang locale > root locale > per-lang languageCode > root
+       // languageCode. Root languageCode only applies when the default language has
+       // neither a per-lang locale nor a per-lang languageCode.
+       applyRootLocale := func(locale string) {
+               if v, ok := ls.LanguageConfigs[defaultContentLanguage]; ok {
+                       v.Locale = locale
+                       ls.LanguageConfigs[defaultContentLanguage] = v
+                       for i, s := range ls.Sorted {
+                               if s.Name == defaultContentLanguage {
+                                       ls.Sorted[i].LanguageConfig.Locale = locale
+                                       break
+                               }
+                       }
+               }
+       }
+       if rootLocale != "" {
+               if v, ok := ls.LanguageConfigs[defaultContentLanguage]; ok && v.Locale == "" {
+                       applyRootLocale(rootLocale)
+               }
+       } else if rootLanguageCode != "" {
+               if v, ok := ls.LanguageConfigs[defaultContentLanguage]; ok && v.Locale == "" && v.LanguageCode == "" {
+                       applyRootLocale(rootLanguageCode)
+               }
        }
 
        return defaultContentLanguage, nil
 }
 
-func DecodeConfig(defaultContentLanguage string, disabledLanguages []string, m map[string]any) (*config.ConfigNamespace[map[string]LanguageConfig, LanguagesInternal], string, error) {
+func DecodeConfig(defaultContentLanguage, rootLocale, rootLanguageCode string, disabledLanguages []string, m map[string]any) (*config.ConfigNamespace[map[string]LanguageConfig, LanguagesInternal], string, error) {
        v, err := config.DecodeNamespace[map[string]LanguageConfig](m, func(in any) (LanguagesInternal, any, error) {
                var languages LanguagesInternal
                var conf map[string]LanguageConfig
@@ -217,7 +253,7 @@ func DecodeConfig(defaultContentLanguage string, disabledLanguages []string, m m
                }
                languages.LanguageConfigs = conf
                var err error
-               if defaultContentLanguage, err = languages.init(defaultContentLanguage, disabledLanguages); err != nil {
+               if defaultContentLanguage, err = languages.init(defaultContentLanguage, rootLocale, rootLanguageCode, disabledLanguages); err != nil {
                        return languages, nil, err
                }
                return languages, languages.LanguageConfigs, nil
index 490951f4ae8694a9f5d5f25f41ec66312e0ae1dc..85d7719c37bea14ee1bdc8dc8ac8897c1882cbac 100644 (file)
@@ -133,11 +133,11 @@ func (tp *TranslationProvider) CloneResource(dst, src *deps.Deps) error {
 }
 
 // getTranslateFunc returns the translation function for the language in Deps.
-// We first try the language code (e.g. "en-US"), then the language key (e.g. "en").
+// We first try the locale (e.g. "en-US"), then the language key (e.g. "en").
 func (tp *TranslationProvider) getTranslateFunc(dst *deps.Deps) func(ctx context.Context, translationID string, templateData any) string {
        l := dst.Conf.Language().(*langs.Language)
-       if lc := l.LanguageCode(); lc != "" {
-               if fn, ok := tp.t.Lookup(strings.ToLower(lc)); ok {
+       if locale := l.Locale(); locale != "" {
+               if fn, ok := tp.t.Lookup(strings.ToLower(locale)); ok {
                        return fn
                }
        }
index 24616e79e293709db8ff8cb2d55b6438500de4e9..3560663eebd5a6d0ab2fe92d70e2bbb67db6800a 100644 (file)
@@ -22,8 +22,11 @@ import (
        "golang.org/x/text/collate"
        "golang.org/x/text/language"
 
+       "github.com/bep/logg"
        "github.com/gohugoio/hugo/common/hmaps"
        "github.com/gohugoio/hugo/common/htime"
+       "github.com/gohugoio/hugo/common/hugo"
+       "github.com/gohugoio/hugo/common/loggers"
        "github.com/gohugoio/hugo/hugolib/sitesmatrix"
        "github.com/gohugoio/locales"
        translators "github.com/gohugoio/localescompressed"
@@ -55,6 +58,8 @@ type Language struct {
 
        // This is just an alias of Site.Params.
        params hmaps.Params
+
+       logger loggers.Logger
 }
 
 // Name is an alias for Lang.
@@ -62,12 +67,19 @@ func (l *Language) Name() string {
        return l.Lang
 }
 
+func (l *Language) Logger() logg.Logger {
+       if l.logger == nil {
+               return loggers.Log().Logger()
+       }
+       return l.logger.Logger()
+}
+
 func (l *Language) IsDefault() bool {
        return l.isDefault
 }
 
 // NewLanguage creates a new language.
-func NewLanguage(lang, defaultContentLanguage, timeZone string, languageConfig LanguageConfig) (*Language, error) {
+func NewLanguage(lang, defaultContentLanguage, timeZone string, languageConfig LanguageConfig, logger loggers.Logger) (*Language, error) {
        translator := translators.GetTranslator(lang)
        if translator == nil {
                translator = translators.GetTranslator(defaultContentLanguage)
@@ -103,6 +115,7 @@ func NewLanguage(lang, defaultContentLanguage, timeZone string, languageConfig L
                collator1:      coll1,
                collator2:      coll2,
                isDefault:      lang == defaultContentLanguage,
+               logger:         logger,
        }
 
        return l, l.loadLocation(timeZone)
@@ -121,13 +134,48 @@ func (l *Language) Params() hmaps.Params {
        return l.params
 }
 
+// Deprecated: Use Locale instead.
 func (l *Language) LanguageCode() string {
+       hugo.DeprecateWithLogger(".Language.LanguageCode", "Use .Language.Locale instead.", "v0.158.0", l.Logger())
+       return l.Locale()
+}
+
+func (l *Language) Locale() string {
+       if l.LanguageConfig.Locale != "" {
+               return l.LanguageConfig.Locale
+       }
        if l.LanguageConfig.LanguageCode != "" {
                return l.LanguageConfig.LanguageCode
        }
        return l.Lang
 }
 
+// Deprecated: Use Direction instead.
+func (l *Language) LanguageDirection() string {
+       hugo.DeprecateWithLogger(".Language.LanguageDirection", "Use .Language.Direction instead.", "v0.158.0", l.Logger())
+       return l.Direction()
+}
+
+func (l *Language) Direction() string {
+       if l.LanguageConfig.Direction != "" {
+               return l.LanguageConfig.Direction
+       }
+       return l.LanguageConfig.LanguageDirection
+}
+
+// Deprecated: Use Label instead.
+func (l *Language) LanguageName() string {
+       hugo.DeprecateWithLogger(".Language.LanguageName", "Use .Language.Label instead.", "v0.158.0", l.Logger())
+       return l.Label()
+}
+
+func (l *Language) Label() string {
+       if l.LanguageConfig.Label != "" {
+               return l.LanguageConfig.Label
+       }
+       return l.LanguageConfig.LanguageName
+}
+
 func (l *Language) loadLocation(tzStr string) error {
        location, err := time.LoadLocation(tzStr)
        if err != nil {
index e8d319f040bd1a35306c120219d3848618081036..9d66cbd6b959ecde2a04512f11267bb656683a1f 100644 (file)
@@ -18,6 +18,7 @@ import (
        "testing"
 
        qt "github.com/frankban/quicktest"
+       "github.com/gohugoio/hugo/common/loggers"
        "golang.org/x/text/collate"
        "golang.org/x/text/language"
 )
@@ -74,3 +75,29 @@ func BenchmarkCollator(b *testing.B) {
                })
        })
 }
+
+// TestLanguageLegacyFieldFallbacks verifies that Locale(), Direction(), and
+// Label() fall back to legacy LanguageConfig fields when the canonical fields
+// are not set. This matters for programmatic construction that bypasses the
+// allconfig migration (which normally copies legacy→canonical and clears them).
+func TestLanguageLegacyFieldFallbacks(t *testing.T) {
+       c := qt.New(t)
+
+       l, err := NewLanguage("en", "en", "UTC", LanguageConfig{
+               LanguageCode:      "en-US",
+               LanguageName:      "English",
+               LanguageDirection: "ltr",
+       }, loggers.NewDefault())
+       c.Assert(err, qt.IsNil)
+       c.Assert(l.Locale(), qt.Equals, "en-US")
+       c.Assert(l.Label(), qt.Equals, "English")
+       c.Assert(l.Direction(), qt.Equals, "ltr")
+
+       // Deprecated methods must not panic when logger is nil (no logger provided
+       // at construction). They delegate through Logger() which has a nil guard.
+       lNoLogger, err := NewLanguage("en", "en", "UTC", LanguageConfig{}, nil)
+       c.Assert(err, qt.IsNil)
+       _ = lNoLogger.LanguageCode()
+       _ = lNoLogger.LanguageName()
+       _ = lNoLogger.LanguageDirection()
+}
index 363c38cd12a691a55b2ce8e276d618b19e05f5fd..ba4db50f298b53e4c53a711562a55f19596a2072 100644 (file)
@@ -19,12 +19,14 @@ import (
 
        "github.com/gohugoio/hugo/common/hmaps"
        "github.com/gohugoio/hugo/common/hstore"
+       "github.com/gohugoio/hugo/common/hugo"
        "github.com/gohugoio/hugo/config/privacy"
        "github.com/gohugoio/hugo/config/services"
        "github.com/gohugoio/hugo/hugolib/roles"
        "github.com/gohugoio/hugo/hugolib/versions"
        "github.com/gohugoio/hugo/identity"
 
+       "github.com/gohugoio/hugo/common/loggers"
        "github.com/gohugoio/hugo/config"
 
        "github.com/gohugoio/hugo/langs"
@@ -71,7 +73,7 @@ type Site interface {
        // Returns the configured title for this Site.
        Title() string
 
-       // Deprecated: Use .Language.LanguageCode instead.
+       // Deprecated: Use .Language.Locale instead.
        LanguageCode() string
 
        // Returns the configured copyright information for this Site.
@@ -224,7 +226,8 @@ func (s *siteWrapper) Title() string {
 }
 
 func (s *siteWrapper) LanguageCode() string {
-       return s.s.LanguageCode()
+       hugo.DeprecateWithLogger(".Site.LanguageCode", "Use .Site.Language.Locale instead.", "v0.158.0", s.s.Language().Logger())
+       return s.s.Language().Locale()
 }
 
 func (s *siteWrapper) Copyright() string {
@@ -330,8 +333,10 @@ func (t testSite) Title() string {
        return "foo"
 }
 
+// Deprecated: Use .Language.Locale instead.
 func (t testSite) LanguageCode() string {
-       return t.l.Lang
+       hugo.DeprecateWithLogger(".Site.LanguageCode", "Use .Site.Language.Locale instead.", "v0.158.0", t.l.Logger())
+       return t.l.Locale()
 }
 
 func (t testSite) Copyright() string {
@@ -450,11 +455,13 @@ func NewDummyHugoSite(conf config.AllProvider) Site {
        opts := HugoInfoOptions{
                Conf: conf,
        }
+       l, err := langs.NewLanguage("en", "en", "", langs.LanguageConfig{}, loggers.NewDefault())
+       if err != nil {
+               panic(err)
+       }
        return testSite{
                h: NewHugoInfo(opts),
-               l: &langs.Language{
-                       Lang: "en",
-               },
+               l: l,
        }
 }
 
index 6e4e919f4c80a5fea82b7cdd44aa92fb976f1a6a..e6bae5b3b912b5c41246c6c6730315fccff051b4 100644 (file)
@@ -29,6 +29,7 @@ import (
 
        "github.com/gohugoio/hugo/common/hmaps"
        "github.com/gohugoio/hugo/common/hstore"
+       "github.com/gohugoio/hugo/common/loggers"
        "github.com/gohugoio/hugo/common/paths"
        "github.com/gohugoio/hugo/config"
        "github.com/gohugoio/hugo/hugofs"
@@ -61,6 +62,7 @@ func newTestPageWithFile(filename string) *testPage {
                langs.LanguageConfig{
                        LanguageName: "English",
                },
+               loggers.NewDefault(),
        )
        if err != nil {
                panic(err)
index 9f40aedf08374803eae2b91749b2cbdefead4c98..8705b53a314f0b63c9d72f7228af445c25fc4fd9 100644 (file)
@@ -12,7 +12,7 @@
   <meta property="og:description" content="{{ trim . "\n\r\t " }}">
 {{- end }}
 
-{{- with or .Params.locale site.Language.LanguageCode }}
+{{- with or .Params.locale site.Language.Locale }}
   <meta property="og:locale" content="{{ replace . `-` `_` }}">
 {{- end }}
 
index 9d5db096262b340dfbe30d9e1e1b8442ae9dbc6d..ea1e4df11769b20e0afbe5587092f49fedd2635c 100644 (file)
@@ -1,5 +1,5 @@
 <!DOCTYPE html>
-<html lang="{{ site.Language.LanguageCode }}">
+<html lang="{{ site.Language.Locale }}">
   <head>
     <title>{{ .Permalink }}</title>
     {{ with .OutputFormats.Canonical }}<link rel="{{ .Rel }}" href="{{ .Permalink }}">{{ end }}
index 2df2cae6e570fd91e2e235915d983de923893809..d9e7815b1a5582e709b66825c0cc933605ba40d7 100644 (file)
@@ -37,7 +37,7 @@
     <link>{{ .Permalink }}</link>
     <description>Recent content {{ if ne .Title .Site.Title }}{{ with .Title }}in {{ . }} {{ end }}{{ end }}on {{ .Site.Title }}</description>
     <generator>Hugo</generator>
-    <language>{{ site.Language.LanguageCode }}</language>{{ with $authorEmail }}
+    <language>{{ site.Language.Locale }}</language>{{ with $authorEmail }}
     <managingEditor>{{.}}{{ with $authorName }} ({{ . }}){{ end }}</managingEditor>{{ end }}{{ with $authorEmail }}
     <webMaster>{{ . }}{{ with $authorName }} ({{ . }}){{ end }}</webMaster>{{ end }}{{ with .Site.Copyright }}
     <copyright>{{ . }}</copyright>{{ end }}{{ if not .Date.IsZero }}
index de1f467d13dadfaa719ac18f742432998fc368cf..98a5594e516261210601383cd7c57d5e54e7c1d8 100644 (file)
     <priority>{{ .Sitemap.Priority }}</priority>{{ end }}{{ if .IsTranslated }}{{ range .Translations }}
     <xhtml:link
                 rel="alternate"
-                hreflang="{{ .Language.LanguageCode }}"
+                hreflang="{{ .Language.Locale }}"
                 href="{{ .Permalink }}"
                 />{{ end }}
     <xhtml:link
                 rel="alternate"
-                hreflang="{{ .Language.LanguageCode }}"
+                hreflang="{{ .Language.Locale }}"
                 href="{{ .Permalink }}"
                 />{{ end }}
   </url>