Repository: gohugoio/hugo (language: go)

Issue:
Make `GroupByDate`, `GroupByExpiryDate`, `GroupByExpiryDate`, `GroupByLastmod`, and  `GroupByParamDate` use of Localization
<!-- Describe this new feature. Think about if it really belongs in the Hugo core module; you may want to discuss it on https://discourse.gohugo.io/ first.  -->

For example `{{ time.Format "02 January" .Date }}` return a localized string, if my config is in French it will return `04 avril` for the current date.

the GroupByDate doesn't use localization, for example something like this:
 `{{ range (where site.RegularPages "Type" "in" site.Params.mainSections).GroupByDate "January, 2006" -}}` 
will order my post in English. For the current month it will display `April, 2022`. It should be expected to use the same setting as `Time.Format` and return the month in the local format `Avril, 2022`.


Discussion / hints:
I agree, but the fix isn't obvious. It's doable, but these are methods on the `Pages` slice, so you would have to look in the first page to get the template function namespace (which isn't there at the moment, but it would be ...). Oh well, we'll find a way, thanks for the issue report.

Fix patch:
diff --git a/hugolib/hugo_sites.go b/hugolib/hugo_sites.go
index 9b27ae5e376..09e1a331a53 100644
--- a/hugolib/hugo_sites.go
+++ b/hugolib/hugo_sites.go
@@ -73,6 +73,9 @@ type HugoSites struct {
 	// Render output formats for all sites.
 	renderFormats output.Formats
 
+	// The currently rendered Site.
+	currentSite *Site
+
 	*deps.Deps
 
 	gitInfo       *gitInfo
diff --git a/hugolib/hugo_sites_build.go b/hugolib/hugo_sites_build.go
index 6f3955b80a7..bf52277a987 100644
--- a/hugolib/hugo_sites_build.go
+++ b/hugolib/hugo_sites_build.go
@@ -289,6 +289,7 @@ func (h *HugoSites) render(config *BuildCfg) error {
 
 	i := 0
 	for _, s := range h.Sites {
+		h.currentSite = s
 		for siteOutIdx, renderFormat := range s.renderFormats {
 			siteRenderContext.outIdx = siteOutIdx
 			siteRenderContext.sitesOutIdx = i
diff --git a/hugolib/site.go b/hugolib/site.go
index 59326cab61f..efa9368306a 100644
--- a/hugolib/site.go
+++ b/hugolib/site.go
@@ -738,6 +738,11 @@ func (s *SiteInfo) Sites() page.Sites {
 	return s.s.h.siteInfos()
 }
 
+// Current returns the currently rendered Site.
+func (s *SiteInfo) Current() page.Site {
+	return s.s.h.currentSite.Info
+}
+
 func (s *SiteInfo) String() string {
 	return fmt.Sprintf("Site(%q)", s.title)
 }
diff --git a/langs/language.go b/langs/language.go
index 3bb131be124..0df2914a169 100644
--- a/langs/language.go
+++ b/langs/language.go
@@ -21,6 +21,7 @@ import (
 
 	"github.com/pkg/errors"
 
+	"github.com/gohugoio/hugo/common/htime"
 	"github.com/gohugoio/hugo/common/maps"
 	"github.com/gohugoio/hugo/config"
 	"github.com/gohugoio/locales"
@@ -77,7 +78,8 @@ type Language struct {
 	// 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
+	translator    locales.Translator
+	timeFormatter htime.TimeFormatter
 
 	location *time.Location
 
@@ -113,9 +115,10 @@ func NewLanguage(lang string, cfg config.Provider) *Language {
 		Lang:       lang,
 		ContentDir: cfg.GetString("contentDir"),
 		Cfg:        cfg, LocalCfg: localCfg,
-		Provider:   compositeConfig,
-		params:     params,
-		translator: translator,
+		Provider:      compositeConfig,
+		params:        params,
+		translator:    translator,
+		timeFormatter: htime.NewTimeFormatter(translator),
 	}
 
 	if err := l.loadLocation(cfg.GetString("timeZone")); err != nil {
@@ -260,6 +263,10 @@ func (l *Language) IsSet(key string) bool {
 // Internal access to unexported Language fields.
 // This construct is to prevent them from leaking to the templates.
 
+func GetTimeFormatter(l *Language) htime.TimeFormatter {
+	return l.timeFormatter
+}
+
 func GetTranslator(l *Language) locales.Translator {
 	return l.translator
 }
diff --git a/resources/page/pagegroup.go b/resources/page/pagegroup.go
index 18c98e70e64..601af606ab4 100644
--- a/resources/page/pagegroup.go
+++ b/resources/page/pagegroup.go
@@ -26,6 +26,7 @@ import (
 	"github.com/gohugoio/hugo/common/collections"
 	"github.com/gohugoio/hugo/common/hreflect"
 	"github.com/gohugoio/hugo/compare"
+	"github.com/gohugoio/hugo/langs"
 
 	"github.com/gohugoio/hugo/resources/resource"
 )
@@ -219,7 +220,7 @@ func (p Pages) GroupByParam(key string, order ...string) (PagesGroup, error) {
 	return r, nil
 }
 
-func (p Pages) groupByDateField(sorter func(p Pages) Pages, formatter func(p Page) string, order ...string) (PagesGroup, error) {
+func (p Pages) groupByDateField(format string, sorter func(p Pages) Pages, getDate func(p Page) time.Time, order ...string) (PagesGroup, error) {
 	if len(p) < 1 {
 		return nil, nil
 	}
@@ -234,16 +235,24 @@ func (p Pages) groupByDateField(sorter func(p Pages) Pages, formatter func(p Pag
 		return nil, nil
 	}
 
-	date := formatter(sp[0].(Page))
+	firstPage := sp[0].(Page)
+	date := getDate(firstPage)
+
+	// Pages may be a mix of multiple languages, so we need to use the language
+	// for the currently rendered Site.
+	currentSite := firstPage.Site().Current()
+	formatter := langs.GetTimeFormatter(currentSite.Language())
+	formatted := formatter.Format(date, format)
 	var r []PageGroup
-	r = append(r, PageGroup{Key: date, Pages: make(Pages, 0)})
+	r = append(r, PageGroup{Key: formatted, Pages: make(Pages, 0)})
 	r[0].Pages = append(r[0].Pages, sp[0])
 
 	i := 0
 	for _, e := range sp[1:] {
-		date = formatter(e.(Page))
-		if r[i].Key.(string) != date {
-			r = append(r, PageGroup{Key: date})
+		date = getDate(e.(Page))
+		formatted := formatter.Format(date, format)
+		if r[i].Key.(string) != formatted {
+			r = append(r, PageGroup{Key: formatted})
 			i++
 		}
 		r[i].Pages = append(r[i].Pages, e)
@@ -259,10 +268,10 @@ func (p Pages) GroupByDate(format string, order ...string) (PagesGroup, error) {
 	sorter := func(p Pages) Pages {
 		return p.ByDate()
 	}
-	formatter := func(p Page) string {
-		return p.Date().Format(format)
+	getDate := func(p Page) time.Time {
+		return p.Date()
 	}
-	return p.groupByDateField(sorter, formatter, order...)
+	return p.groupByDateField(format, sorter, getDate, order...)
 }
 
 // GroupByPublishDate groups by the given page's PublishDate value in
@@ -273,10 +282,10 @@ func (p Pages) GroupByPublishDate(format string, order ...string) (PagesGroup, e
 	sorter := func(p Pages) Pages {
 		return p.ByPublishDate()
 	}
-	formatter := func(p Page) string {
-		return p.PublishDate().Format(format)
+	getDate := func(p Page) time.Time {
+		return p.PublishDate()
 	}
-	return p.groupByDateField(sorter, formatter, order...)
+	return p.groupByDateField(format, sorter, getDate, order...)
 }
 
 // GroupByExpiryDate groups by the given page's ExpireDate value in
@@ -287,10 +296,10 @@ func (p Pages) GroupByExpiryDate(format string, order ...string) (PagesGroup, er
 	sorter := func(p Pages) Pages {
 		return p.ByExpiryDate()
 	}
-	formatter := func(p Page) string {
-		return p.ExpiryDate().Format(format)
+	getDate := func(p Page) time.Time {
+		return p.ExpiryDate()
 	}
-	return p.groupByDateField(sorter, formatter, order...)
+	return p.groupByDateField(format, sorter, getDate, order...)
 }
 
 // GroupByLastmod groups by the given page's Lastmod value in
@@ -301,10 +310,10 @@ func (p Pages) GroupByLastmod(format string, order ...string) (PagesGroup, error
 	sorter := func(p Pages) Pages {
 		return p.ByLastmod()
 	}
-	formatter := func(p Page) string {
-		return p.Lastmod().Format(format)
+	getDate := func(p Page) time.Time {
+		return p.Lastmod()
 	}
-	return p.groupByDateField(sorter, formatter, order...)
+	return p.groupByDateField(format, sorter, getDate, order...)
 }
 
 // GroupByParamDate groups by a date set as a param on the page in
@@ -340,10 +349,10 @@ func (p Pages) GroupByParamDate(key string, format string, order ...string) (Pag
 		pageBy(pdate).Sort(r)
 		return r
 	}
-	formatter := func(p Page) string {
-		return dates[p].Format(format)
+	getDate := func(p Page) time.Time {
+		return dates[p]
 	}
-	return p.groupByDateField(sorter, formatter, order...)
+	return p.groupByDateField(format, sorter, getDate, order...)
 }
 
 // ProbablyEq wraps compare.ProbablyEqer
diff --git a/resources/page/site.go b/resources/page/site.go
index 3cc9989ee12..b84f179142c 100644
--- a/resources/page/site.go
+++ b/resources/page/site.go
@@ -37,6 +37,7 @@ type Site interface {
 	ServerPort() int
 	Title() string
 	Sites() Sites
+	Current() Site
 	Hugo() hugo.Info
 	BaseURL() template.URL
 	Taxonomies() any
@@ -82,6 +83,10 @@ func (t testSite) Sites() Sites {
 	return nil
 }
 
+func (t testSite) Current() Site {
+	return t
+}
+
 func (t testSite) IsServer() bool {
 	return false
 }
diff --git a/tpl/time/init.go b/tpl/time/init.go
index a76348b7a7c..4bb2ddf67bf 100644
--- a/tpl/time/init.go
+++ b/tpl/time/init.go
@@ -28,7 +28,7 @@ func init() {
 		if d.Language == nil {
 			panic("Language must be set")
 		}
-		ctx := New(langs.GetTranslator(d.Language), langs.GetLocation(d.Language))
+		ctx := New(langs.GetTimeFormatter(d.Language), langs.GetLocation(d.Language))
 
 		ns := &internal.TemplateFuncsNamespace{
 			Name: name,
diff --git a/tpl/time/time.go b/tpl/time/time.go
index 66ea721faee..f82d63c4402 100644
--- a/tpl/time/time.go
+++ b/tpl/time/time.go
@@ -21,15 +21,13 @@ import (
 
 	"github.com/gohugoio/hugo/common/htime"
 
-	"github.com/gohugoio/locales"
-
 	"github.com/spf13/cast"
 )
 
 // New returns a new instance of the time-namespaced template functions.
-func New(translator locales.Translator, location *time.Location) *Namespace {
+func New(timeFormatter htime.TimeFormatter, location *time.Location) *Namespace {
 	return &Namespace{
-		timeFormatter: htime.NewTimeFormatter(translator),
+		timeFormatter: timeFormatter,
 		location:      location,
 	}
 }


Test patch:
diff --git a/resources/page/integration_test.go b/resources/page/integration_test.go
new file mode 100644
index 00000000000..285b1434296
--- /dev/null
+++ b/resources/page/integration_test.go
@@ -0,0 +1,72 @@
+// Copyright 2021 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package page_test
+
+import (
+	"testing"
+
+	"github.com/gohugoio/hugo/hugolib"
+)
+
+func TestGroupByLocalizedDate(t *testing.T) {
+
+	files := `
+-- config.toml --
+defaultContentLanguage = 'en'
+defaultContentLanguageInSubdir = true
+[languages]
+[languages.en]
+title = 'My blog'
+weight = 1
+[languages.fr]
+title = 'Mon blogue'
+weight = 2
+[languages.nn]
+title = 'Bloggen min'
+weight = 3
+-- content/p1.md --
+---
+title: "Post 1"
+date: "2020-01-01"
+---
+-- content/p2.md --
+---
+title: "Post 2"
+date: "2020-02-01"
+---
+-- content/p1.fr.md --
+---
+title: "Post 1"
+date: "2020-01-01"
+---
+-- content/p2.fr.md --
+---
+title: "Post 2"
+date: "2020-02-01"
+---
+-- layouts/index.html --
+{{ range $k, $v := site.RegularPages.GroupByDate "January, 2006" }}{{ $k }}|{{ $v.Key }}|{{ $v.Pages }}{{ end }}
+
+	`
+
+	b := hugolib.NewIntegrationTestBuilder(
+		hugolib.IntegrationTestConfig{
+			T:           t,
+			TxtarString: files,
+			NeedsOsFS:   true,
+		}).Build()
+
+	b.AssertFileContent("public/en/index.html", "0|February, 2020|Pages(1)1|January, 2020|Pages(1)")
+	b.AssertFileContent("public/fr/index.html", "0|février, 2020|Pages(1)1|janvier, 2020|Pages(1)")
+}
diff --git a/resources/page/page_matcher_test.go b/resources/page/page_matcher_test.go
index 846ab2c0341..4a59dc50232 100644
--- a/resources/page/page_matcher_test.go
+++ b/resources/page/page_matcher_test.go
@@ -14,10 +14,11 @@
 package page
 
 import (
-	"github.com/gohugoio/hugo/common/hugo"
 	"path/filepath"
 	"testing"
 
+	"github.com/gohugoio/hugo/common/hugo"
+
 	qt "github.com/frankban/quicktest"
 )
 
diff --git a/resources/page/pages_sort_test.go b/resources/page/pages_sort_test.go
index 85a1cda1585..cf4e339eec4 100644
--- a/resources/page/pages_sort_test.go
+++ b/resources/page/pages_sort_test.go
@@ -18,18 +18,17 @@ import (
 	"testing"
 	"time"
 
-	"github.com/gohugoio/hugo/htesting/hqt"
-	"github.com/gohugoio/hugo/source"
-
 	"github.com/gohugoio/hugo/resources/resource"
+	"github.com/google/go-cmp/cmp"
 
 	qt "github.com/frankban/quicktest"
 )
 
-var eq = qt.CmpEquals(hqt.DeepAllowUnexported(
-	&testPage{},
-	&source.FileInfo{},
-))
+var eq = qt.CmpEquals(
+	cmp.Comparer(func(p1, p2 testPage) bool {
+		return p1.path == p2.path && p1.weight == p2.weight
+	}),
+)
 
 func TestDefaultSort(t *testing.T) {
 	t.Parallel()
diff --git a/resources/page/testhelpers_test.go b/resources/page/testhelpers_test.go
index cee1f99e52e..30b8e4dff1d 100644
--- a/resources/page/testhelpers_test.go
+++ b/resources/page/testhelpers_test.go
@@ -64,6 +64,7 @@ func newTestPageWithFile(filename string) *testPage {
 		currentSection: &testPage{
 			sectionEntries: []string{"a", "b", "c"},
 		},
+		site: testSite{l: langs.NewDefaultLanguage(config.New())},
 	}
 }
 
diff --git a/tpl/time/time_test.go b/tpl/time/time_test.go
index f368ea43f7f..9001f6b6b4b 100644
--- a/tpl/time/time_test.go
+++ b/tpl/time/time_test.go
@@ -20,6 +20,7 @@ import (
 
 	qt "github.com/frankban/quicktest"
 
+	"github.com/gohugoio/hugo/common/htime"
 	translators "github.com/gohugoio/localescompressed"
 )
 
@@ -27,7 +28,7 @@ func TestTimeLocation(t *testing.T) {
 	t.Parallel()
 
 	loc, _ := time.LoadLocation("America/Antigua")
-	ns := New(translators.GetTranslator("en"), loc)
+	ns := New(htime.NewTimeFormatter(translators.GetTranslator("en")), loc)
 
 	for i, test := range []struct {
 		name     string
@@ -86,7 +87,7 @@ func TestFormat(t *testing.T) {
 
 	c.Run("UTC", func(c *qt.C) {
 		c.Parallel()
-		ns := New(translators.GetTranslator("en"), time.UTC)
+		ns := New(htime.NewTimeFormatter(translators.GetTranslator("en")), time.UTC)
 
 		for i, test := range []struct {
 			layout string
@@ -129,7 +130,7 @@ func TestFormat(t *testing.T) {
 
 		loc, err := time.LoadLocation("America/Los_Angeles")
 		c.Assert(err, qt.IsNil)
-		ns := New(translators.GetTranslator("en"), loc)
+		ns := New(htime.NewTimeFormatter(translators.GetTranslator("en")), loc)
 
 		d, err := ns.Format(":time_full", "2020-03-09T11:00:00")
 
@@ -143,7 +144,7 @@ func TestFormat(t *testing.T) {
 func TestDuration(t *testing.T) {
 	t.Parallel()
 
-	ns := New(translators.GetTranslator("en"), time.UTC)
+	ns := New(htime.NewTimeFormatter(translators.GetTranslator("en")), time.UTC)
 
 	for i, test := range []struct {
 		unit   any