From d4f2122dea17736e8deef30b40872c0e6894c88f Mon Sep 17 00:00:00 2001 From: Joe Mooring Date: Sat, 28 Feb 2026 01:36:28 -0800 Subject: [PATCH] refactor: Deprecate language configuration and template methods 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 --- commands/import.go | 2 +- config/allconfig/allconfig.go | 68 ++- config/allconfig/alldecoders.go | 24 +- create/skeletons/skeletons.go | 12 +- create/skeletons/theme/layouts/baseof.html | 2 +- docs/data/docs.yaml | 8 +- hugolib/config_test.go | 428 +++++++++++++++++- hugolib/site.go | 4 +- .../sitematrix_integration_test.go | 16 +- langs/config.go | 54 ++- langs/i18n/translationProvider.go | 6 +- langs/language.go | 50 +- langs/language_test.go | 27 ++ resources/page/site.go | 19 +- resources/page/testhelpers_test.go | 2 + .../templates/_partials/opengraph.html | 2 +- tpl/tplimpl/embedded/templates/alias.html | 2 +- tpl/tplimpl/embedded/templates/rss.xml | 2 +- tpl/tplimpl/embedded/templates/sitemap.xml | 4 +- 19 files changed, 662 insertions(+), 70 deletions(-) diff --git a/commands/import.go b/commands/import.go index a8b1b627a..8d36fb923 100644 --- a/commands/import.go +++ b/commands/import.go @@ -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, } diff --git a/config/allconfig/allconfig.go b/config/allconfig/allconfig.go index 91616490a..e7598236a 100644 --- a/config/allconfig/allconfig.go +++ b/config/allconfig/allconfig.go @@ -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 } diff --git a/config/allconfig/alldecoders.go b/config/allconfig/alldecoders.go index 2fb0276d2..835ec19d1 100644 --- a/config/allconfig/alldecoders.go +++ b/config/allconfig/alldecoders.go @@ -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) } diff --git a/create/skeletons/skeletons.go b/create/skeletons/skeletons.go index 1989ed202..835f30582 100644 --- a/create/skeletons/skeletons.go +++ b/create/skeletons/skeletons.go @@ -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) diff --git a/create/skeletons/theme/layouts/baseof.html b/create/skeletons/theme/layouts/baseof.html index 39dcbec61..7d17aa55c 100644 --- a/create/skeletons/theme/layouts/baseof.html +++ b/create/skeletons/theme/layouts/baseof.html @@ -1,5 +1,5 @@ - + {{ partial "head.html" . }} diff --git a/docs/data/docs.yaml b/docs/data/docs.yaml index 478ecafb1..d3015af25 100644 --- a/docs/data/docs.yaml +++ b/docs/data/docs.yaml @@ -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: diff --git a/hugolib/config_test.go b/hugolib/config_test.go index 8af31a632..0b3147bc3 100644 --- a/hugolib/config_test.go +++ b/hugolib/config_test.go @@ -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) + } + }) + } +} diff --git a/hugolib/site.go b/hugolib/site.go index 3ceccc440..8e0b2888f 100644 --- a/hugolib/site.go +++ b/hugolib/site.go @@ -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. diff --git a/hugolib/sitesmatrix/sitematrix_integration_test.go b/hugolib/sitesmatrix/sitematrix_integration_test.go index 8706a0a43..4e7bab846 100644 --- a/hugolib/sitesmatrix/sitematrix_integration_test.go +++ b/hugolib/sitesmatrix/sitematrix_integration_test.go @@ -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), }, }) diff --git a/langs/config.go b/langs/config.go index 0ba56ee6f..91ed2ac1c 100644 --- a/langs/config.go +++ b/langs/config.go @@ -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 diff --git a/langs/i18n/translationProvider.go b/langs/i18n/translationProvider.go index 490951f4a..85d7719c3 100644 --- a/langs/i18n/translationProvider.go +++ b/langs/i18n/translationProvider.go @@ -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 } } diff --git a/langs/language.go b/langs/language.go index 24616e79e..3560663ee 100644 --- a/langs/language.go +++ b/langs/language.go @@ -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 { diff --git a/langs/language_test.go b/langs/language_test.go index e8d319f04..9d66cbd6b 100644 --- a/langs/language_test.go +++ b/langs/language_test.go @@ -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() +} diff --git a/resources/page/site.go b/resources/page/site.go index 363c38cd1..ba4db50f2 100644 --- a/resources/page/site.go +++ b/resources/page/site.go @@ -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, } } diff --git a/resources/page/testhelpers_test.go b/resources/page/testhelpers_test.go index 6e4e919f4..e6bae5b3b 100644 --- a/resources/page/testhelpers_test.go +++ b/resources/page/testhelpers_test.go @@ -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) diff --git a/tpl/tplimpl/embedded/templates/_partials/opengraph.html b/tpl/tplimpl/embedded/templates/_partials/opengraph.html index 9f40aedf0..8705b53a3 100644 --- a/tpl/tplimpl/embedded/templates/_partials/opengraph.html +++ b/tpl/tplimpl/embedded/templates/_partials/opengraph.html @@ -12,7 +12,7 @@ {{- end }} -{{- with or .Params.locale site.Language.LanguageCode }} +{{- with or .Params.locale site.Language.Locale }} {{- end }} diff --git a/tpl/tplimpl/embedded/templates/alias.html b/tpl/tplimpl/embedded/templates/alias.html index 9d5db0962..ea1e4df11 100644 --- a/tpl/tplimpl/embedded/templates/alias.html +++ b/tpl/tplimpl/embedded/templates/alias.html @@ -1,5 +1,5 @@ - + {{ .Permalink }} {{ with .OutputFormats.Canonical }}{{ end }} diff --git a/tpl/tplimpl/embedded/templates/rss.xml b/tpl/tplimpl/embedded/templates/rss.xml index 2df2cae6e..d9e7815b1 100644 --- a/tpl/tplimpl/embedded/templates/rss.xml +++ b/tpl/tplimpl/embedded/templates/rss.xml @@ -37,7 +37,7 @@ {{ .Permalink }} Recent content {{ if ne .Title .Site.Title }}{{ with .Title }}in {{ . }} {{ end }}{{ end }}on {{ .Site.Title }} Hugo - {{ site.Language.LanguageCode }}{{ with $authorEmail }} + {{ site.Language.Locale }}{{ with $authorEmail }} {{.}}{{ with $authorName }} ({{ . }}){{ end }}{{ end }}{{ with $authorEmail }} {{ . }}{{ with $authorName }} ({{ . }}){{ end }}{{ end }}{{ with .Site.Copyright }} {{ . }}{{ end }}{{ if not .Date.IsZero }} diff --git a/tpl/tplimpl/embedded/templates/sitemap.xml b/tpl/tplimpl/embedded/templates/sitemap.xml index de1f467d1..98a5594e5 100644 --- a/tpl/tplimpl/embedded/templates/sitemap.xml +++ b/tpl/tplimpl/embedded/templates/sitemap.xml @@ -10,12 +10,12 @@ {{ .Sitemap.Priority }}{{ end }}{{ if .IsTranslated }}{{ range .Translations }} {{ end }} {{ end }} -- 2.39.5