From 2d80b8a7414294983e622cbc15cc434f5a43758f Mon Sep 17 00:00:00 2001 From: =?utf8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Fri, 9 Jan 2026 10:47:41 +0100 Subject: [PATCH] For multiple dimensions setups, fix alias handling and multihost publish path * The alias handling was left as is when we added new dimensions in v0.152.0, which meant that if you had `defaultContentVersionInSubDir=true` set, the alias page in the root would not be correctly created. * Also, if you had multihost setup with multiple dimensions, the publish would be incorrect: The language key was added last instead of first. * The home page alias handling is reworked and made more robust, which also fixes some subtle issues: * It now supports redircects for multiple HTML output formats for the home page. * We skip creating aliases for disabled home pages. * With potentially multiple output formats with one of them canonical, we added a new `Canonical` method to the `Page.OutputFormats`, which allows you to do this in a template: ```handlebars {{ with .OutputFormats.Canonical }}{{ end }} ``` Fixes #14354 Fixes #14356 --- config/allconfig/allconfig.go | 5 + hugolib/alias.go | 4 + hugolib/page__paths.go | 10 +- hugolib/site.go | 6 +- hugolib/site_render.go | 70 +++++++---- .../sitematrix_integration_test.go | 112 ++++++++++++++++++ resources/page/page_outputformat.go | 28 ++++- resources/page/page_paths.go | 1 + tpl/tplimpl/embedded/templates/alias.html | 6 +- 9 files changed, 209 insertions(+), 33 deletions(-) diff --git a/config/allconfig/allconfig.go b/config/allconfig/allconfig.go index a249b7e43..01be23fc6 100644 --- a/config/allconfig/allconfig.go +++ b/config/allconfig/allconfig.go @@ -607,8 +607,13 @@ type RootConfig struct { DefaultOutputFormat string // Disable generation of redirect to the default language when DefaultContentLanguageInSubdir is enabled. + // Note that this currently is an alias for DisableDefaultDimensionRedirect introduced in v0.154.4. + // It's not obvious how a more fine grained setup would work. DisableDefaultLanguageRedirect bool + // Disable generation of redirect to the default dimension when DefaultContentRoleInSubdir or DefaultContentVersionInSubdir or DefaultContentLanguageInSubdir is enabled. + DisableDefaultDimensionRedirect bool + // Disable creation of alias redirect pages. DisableAliases bool diff --git a/hugolib/alias.go b/hugolib/alias.go index 213a34c79..58d908ce6 100644 --- a/hugolib/alias.go +++ b/hugolib/alias.go @@ -70,6 +70,10 @@ func (a aliasHandler) renderAlias(permalink string, p page.Page, matrix sitesmat return nil, errors.New("no alias template found") } + if p == nil { + p = page.NopPage + } + data := aliasPage{ permalink, p, diff --git a/hugolib/page__paths.go b/hugolib/page__paths.go index 85b79238d..b669127fa 100644 --- a/hugolib/page__paths.go +++ b/hugolib/page__paths.go @@ -160,11 +160,19 @@ func createTargetPathDescriptor(p *pageState) (page.TargetPathDescriptor, error) // Add path prefixes. // Add any role first, as that is a natural candidate for external ACL checks. + // For multihost sites, the file path needs to start with the language code. + if s.h.Conf.IsMultihost() { + addPrefix(s.getLanguageTargetPathLang(true), "") + } rolePrefix := s.getPrefixRole() addPrefix(rolePrefix, rolePrefix) versionPrefix := s.getPrefixVersion() addPrefix(versionPrefix, versionPrefix) - addPrefix(s.getLanguageTargetPathLang(alwaysInSubDir), s.getLanguagePermalinkLang(alwaysInSubDir)) + if s.h.Conf.IsMultihost() { + addPrefix("", s.getLanguagePermalinkLang(alwaysInSubDir)) + } else { + addPrefix(s.getLanguageTargetPathLang(alwaysInSubDir), s.getLanguagePermalinkLang(alwaysInSubDir)) + } if desc.URL != "" && strings.IndexByte(desc.URL, ':') >= 0 { // Attempt to parse and expand an url diff --git a/hugolib/site.go b/hugolib/site.go index b4db8c8ed..6dbe307bf 100644 --- a/hugolib/site.go +++ b/hugolib/site.go @@ -1056,7 +1056,7 @@ func (s *siteRefLinker) refLink(ref string, source any, relative bool, outputFor if outputFormat != "" { o := target.OutputFormats().Get(outputFormat) - if o == nil { + if o.IsZero() { s.logNotFound(refURL.Path, fmt.Sprintf("output format %q", outputFormat), p, pos) return s.notFoundURL, nil } @@ -1770,10 +1770,6 @@ func (s *Site) render(ctx *siteRenderContext) (err error) { return } - if err = s.renderMainLanguageRedirect(); err != nil { - return - } - return } diff --git a/hugolib/site_render.go b/hugolib/site_render.go index 3279be355..45f24a624 100644 --- a/hugolib/site_render.go +++ b/hugolib/site_render.go @@ -22,6 +22,7 @@ import ( "github.com/bep/logg" "github.com/gohugoio/go-radix" + "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/hugolib/doctree" "github.com/gohugoio/hugo/tpl/tplimpl" @@ -202,6 +203,16 @@ func pageRenderer( } } + if p.IsHome() && p.outputFormat().IsHTML && s.siteVector.IsFirst() { + if err = s.renderDefaultDimensionRedirect(p); err != nil { + if sendErr(err) { + continue + } else { + return + } + } + } + if p.paginator != nil && p.paginator.current != nil { if err := s.renderPaginator(p, templ); err != nil { if sendErr(err) { @@ -357,32 +368,51 @@ func (s *Site) renderAliases() error { return w.Walk(context.TODO()) } -// renderMainLanguageRedirect creates a redirect to the main language home, +// renderDefaultDimensionRedirect creates a redirect to the main dimension's home, // depending on if it lives in sub folder (e.g. /en) or not. -func (s *Site) renderMainLanguageRedirect() error { - if s.conf.DisableDefaultLanguageRedirect { +func (s *Site) renderDefaultDimensionRedirect(home *pageState) error { + if s.conf.DisableDefaultLanguageRedirect || s.conf.DisableDefaultDimensionRedirect { return nil } - if s.h.Conf.IsMultihost() || !(s.h.Conf.DefaultContentLanguageInSubdir() || s.h.Conf.IsMultilingual()) { - // No need for a redirect + + shouldAdd := s.conf.DefaultContentLanguageInSubdir && !s.Conf.IsMultihost() + shouldAdd = shouldAdd || s.conf.DefaultContentVersionInSubdir || s.conf.DefaultContentRoleInSubdir + if !shouldAdd { return nil } - html, found := s.conf.OutputFormats.Config.GetByName("html") - if found { - mainLang := s.conf.DefaultContentLanguage - if s.conf.DefaultContentLanguageInSubdir { - mainLangURL := s.PathSpec.AbsURL(mainLang+"/", false) - s.Log.Debugf("Write redirect to main language %s: %s", mainLang, mainLangURL) - if err := s.publishDestAlias(true, "/", mainLangURL, html, nil); err != nil { - return err - } - } else { - mainLangURL := s.PathSpec.AbsURL("", false) - s.Log.Debugf("Write redirect to main language %s: %s", mainLang, mainLangURL) - if err := s.publishDestAlias(true, mainLang, mainLangURL, html, nil); err != nil { - return err - } + homeLink := home.pageOutput.targetPaths().Link // This doesn't have any baseURL paths in it. + + of := home.outputFormat() + homePermalink := home.Permalink() + + var ps []string + if of.Path != "" { + // For OutputFormats with a path, creating more than one alias will easily create path clashes without much value. + ps = []string{paths.AddLeadingAndTrailingSlash(of.Path)} + } else { + ps = []string{"/"} + // /guest/v1.0.0/en/ + // /,/guest/,/guest/v1.0.0 = /guest/v1.0.0/en/ + // /guest/v1.0.0/ + // /,/guest/ = /guest/v1.0.0/ + parts := strings.Split(strings.Trim(homeLink, "/"), "/") + + for i := 0; i < len(parts)-1; i++ { + ps = append(ps, paths.AddLeadingAndTrailingSlash(strings.Join(parts[0:i+1], "/"))) + } + } + + if s.h.Configs.IsMultihost { + prefix := "/" + s.LanguagePrefix() + for i, p := range ps { + ps[i] = path.Join(prefix, p) + } + } + + for _, p := range ps { + if err := s.publishDestAlias(true, p, homePermalink, of, home); err != nil { + return err } } diff --git a/hugolib/sitesmatrix/sitematrix_integration_test.go b/hugolib/sitesmatrix/sitematrix_integration_test.go index 5890d32d1..c7b6a237e 100644 --- a/hugolib/sitesmatrix/sitematrix_integration_test.go +++ b/hugolib/sitesmatrix/sitematrix_integration_test.go @@ -1272,3 +1272,115 @@ disableKinds = ["rss", "sitemap", "taxonomy", "term"] b.AssertFileContent("public/s1/index.html", "section|/s1/|") } } + +const defaultContentDimensionRedirectsFiletemplate = ` +-- hugo.toml -- +baseURL = "BASE_URL_PLACEHOLDER" +disableKinds = ["sitemap", "taxonomy", "term"] +defaultContentLanguageInSubDir = true +defaultContentVersionInSubDir = true +defaultContentRoleInSubDir = true +disableDefaultLanguageRedirect = false +disableDefaultDimensionRedirect = false +defaultContentLanguage = "en" +[outputs] +home = [ 'rss', 'amp', 'html'] +-- layouts/home.html -- +Home. +` + +// Issue 14354 +func TestSitesMatrixRedirects(t *testing.T) { + t.Parallel() + + files := strings.ReplaceAll(defaultContentDimensionRedirectsFiletemplate, "BASE_URL_PLACEHOLDER", "https://example.org/") + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", "url=https://example.org/guest/v1.0.0/en/\"", ``) + b.AssertFileContent("public/amp/index.html", "url=https://example.org/guest/v1.0.0/en/amp/\"", ``) + b.AssertFileContent("public/guest/index.html", "url=https://example.org/guest/v1.0.0/en/\"") + b.AssertFileContent("public/guest/v1.0.0/index.html", "url=https://example.org/guest/v1.0.0/en/\"") + b.AssertFileContent("public/guest/v1.0.0/en/index.html", "Home.") + b.AssertFileContent("public/guest/v1.0.0/en/amp/index.html", "Home.") +} + +func TestSitesMatrixRedirectsBasePathInBaseURL(t *testing.T) { + t.Parallel() + + files := strings.ReplaceAll(defaultContentDimensionRedirectsFiletemplate, "BASE_URL_PLACEHOLDER", "https://example.org/docs/") + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", "url=https://example.org/docs/guest/v1.0.0/en/\"") + b.AssertFileContent("public/amp/index.html", "url=https://example.org/docs/guest/v1.0.0/en/amp/\"", ``) + b.AssertFileContent("public/guest/index.html", "url=https://example.org/docs/guest/v1.0.0/en/\"") + b.AssertFileContent("public/guest/v1.0.0/index.html", "url=https://example.org/docs/guest/v1.0.0/en/\"") + b.AssertFileContent("public/guest/v1.0.0/en/index.html", "Home.") +} + +func TestSitesMatrixRedirectsDisableDefaultLanguageRedirect(t *testing.T) { + t.Parallel() + + files := strings.ReplaceAll(defaultContentDimensionRedirectsFiletemplate, "BASE_URL_PLACEHOLDER", "https://example.org/") + + runTest := func(files string) { + b := hugolib.Test(t, files) + + b.AssertFileExists("public/index.html", false) + b.AssertFileExists("public/guest/index.html", false) + b.AssertFileExists("public/guest/v1.0.0/index.html", false) + b.AssertFileContent("public/guest/v1.0.0/en/index.html", "Home.") + } + + runTest(strings.ReplaceAll(files, "disableDefaultLanguageRedirect = false", "disableDefaultLanguageRedirect = true")) + runTest(strings.ReplaceAll(files, "disableDefaultDimensionRedirect = false", "disableDefaultDimensionRedirect = true")) +} + +func TestSitesMatrixRedirectsDefaultContentVersionInBase(t *testing.T) { + t.Parallel() + + files := strings.ReplaceAll(defaultContentDimensionRedirectsFiletemplate, "BASE_URL_PLACEHOLDER", "https://example.org/") + files = strings.ReplaceAll(files, "defaultContentVersionInSubDir = true", "defaultContentVersionInSubDir = false") + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", "url=https://example.org/guest/en/\"") + b.AssertFileContent("public/guest/index.html", "url=https://example.org/guest/en/\"") + b.AssertFileExists("public/guest/v1.0.0/index.html", false) + b.AssertFileContent("public/guest/en/index.html", "Home.") +} + +func TestSitesMatrixRedirectsMultiHost(t *testing.T) { + t.Parallel() + // + files := ` +-- hugo.toml -- +disableKinds = ["sitemap", "taxonomy", "term"] +defaultContentLanguageInSubDir = true +defaultContentVersionInSubDir = true +defaultContentRoleInSubDir = true +disableDefaultLanguageRedirect = false +disableDefaultDimensionRedirect = false +defaultContentLanguage = "en" +[languages] +[languages.en] +weight = 1 +baseURL = "https://en.example.org/" +[languages.nn] +weight = 2 +baseURL = "https://nn.example.org/" +[outputs] +home = [ 'rss', 'amp', 'html'] +-- layouts/home.html -- +Home. +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/en/index.html", "url=https://en.example.org/guest/v1.0.0/\"", ``) + b.AssertFileContent("public/en/amp/index.html", "url=https://en.example.org/guest/v1.0.0/amp/\"", ``) + b.AssertFileContent("public/en/guest/index.html", "url=https://en.example.org/guest/v1.0.0/\"") + b.AssertFileContent("public/en/guest/v1.0.0/index.html", "Home.") + b.AssertFileContent("public/en/guest/v1.0.0/amp/index.html", "Home.") +} diff --git a/resources/page/page_outputformat.go b/resources/page/page_outputformat.go index 44f290025..60e370a51 100644 --- a/resources/page/page_outputformat.go +++ b/resources/page/page_outputformat.go @@ -18,6 +18,7 @@ package page import ( "strings" + "github.com/gohugoio/hugo/common/types" "github.com/gohugoio/hugo/media" "github.com/gohugoio/hugo/output" ) @@ -25,6 +26,8 @@ import ( // OutputFormats holds a list of the relevant output formats for a given page. type OutputFormats []OutputFormat +var _ types.Zeroer = OutputFormat{} + // OutputFormat links to a representation of a resource. type OutputFormat struct { // Rel contains a value that can be used to construct a rel link. @@ -65,6 +68,11 @@ func (o OutputFormat) RelPermalink() string { return o.relPermalink } +// IsZero checks whether this OutputFormat is the zero value. +func (o OutputFormat) IsZero() bool { + return o.Format.Name == "" +} + func NewOutputFormat(relPermalink, permalink string, isCanonical bool, f output.Format) OutputFormat { isUserConfigured := true for _, d := range output.DefaultFormats { @@ -84,12 +92,24 @@ func NewOutputFormat(relPermalink, permalink string, isCanonical bool, f output. } // Get gets a OutputFormat given its name, i.e. json, html etc. -// It returns nil if none found. -func (o OutputFormats) Get(name string) *OutputFormat { +// It returns a zero OutputFormat if not found. +func (o OutputFormats) Get(name string) OutputFormat { for _, f := range o { if strings.EqualFold(f.Format.Name, name) { - return &f + return f + } + } + return OutputFormat{} +} + +// Canonical returns the first canonical OutputFormat for this page, +// or a zero OutputFormat if not found. +func (o OutputFormats) Canonical() OutputFormat { + const canonical = "canonical" + for _, f := range o { + if strings.EqualFold(f.Rel, canonical) { + return f } } - return nil + return OutputFormat{} } diff --git a/resources/page/page_paths.go b/resources/page/page_paths.go index 3bc3e802e..912d6036f 100644 --- a/resources/page/page_paths.go +++ b/resources/page/page_paths.go @@ -432,6 +432,7 @@ func (p *pagePathBuilder) PathDirBase() string { func (p *pagePathBuilder) PathFile() string { dir := p.Path(0) + if p.prefixPath != "" { dir = "/" + p.prefixPath + dir } diff --git a/tpl/tplimpl/embedded/templates/alias.html b/tpl/tplimpl/embedded/templates/alias.html index 45ae9b631..f87b287fc 100644 --- a/tpl/tplimpl/embedded/templates/alias.html +++ b/tpl/tplimpl/embedded/templates/alias.html @@ -1,9 +1,9 @@ - + {{ .Permalink }} - - + {{ with .OutputFormats.Canonical }}{{ end }} + -- 2.39.5