]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
For multiple dimensions setups, fix alias handling and multihost publish path
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Fri, 9 Jan 2026 09:47:41 +0000 (10:47 +0100)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Sat, 10 Jan 2026 14:34:51 +0000 (15:34 +0100)
* 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 }}<link rel="{{ .Rel }}" href="{{ .Permalink }}">{{ end }}
```

Fixes #14354
Fixes #14356

config/allconfig/allconfig.go
hugolib/alias.go
hugolib/page__paths.go
hugolib/site.go
hugolib/site_render.go
hugolib/sitesmatrix/sitematrix_integration_test.go
resources/page/page_outputformat.go
resources/page/page_paths.go
tpl/tplimpl/embedded/templates/alias.html

index a249b7e437cc45ae5594de666d9b14f954bb47f9..01be23fc68503569319c872e710bceba17d37dd0 100644 (file)
@@ -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
 
index 213a34c7975d7b047b7de86edb6e08864263da45..58d908ce69bb4667312a42d86eaa4e68856e4518 100644 (file)
@@ -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,
index 85b79238dbb9cf75964f25b56b39ac1aa696d9d7..b669127fa9a4ee4f09774ee233c43ef5986b86f0 100644 (file)
@@ -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
index b4db8c8ed63644dd7786b56e2e29c75bbc5602a3..6dbe307bf77d0ee64b6184c1f765781f2f7a9226 100644 (file)
@@ -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
 }
 
index 3279be355edf5a37bccca9800d4068d4f6cfc0a8..45f24a624bc27fed7008f88815979320d25605c2 100644 (file)
@@ -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
                }
        }
 
index 5890d32d132ddea800eed7dff2085817e7ffc787..c7b6a237e650cb1394ed5dacecbb0fe119507acd 100644 (file)
@@ -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/\"", `<link rel="canonical" href="https://example.org/guest/v1.0.0/en/">`)
+       b.AssertFileContent("public/amp/index.html", "url=https://example.org/guest/v1.0.0/en/amp/\"", `<link rel="canonical" href="https://example.org/guest/v1.0.0/en/">`)
+       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/\"", `<link rel="canonical" href="https://example.org/docs/guest/v1.0.0/en/">`)
+       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/\"", `<link rel="canonical" href="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/\"", `<link rel="canonical" href="https://en.example.org/guest/v1.0.0/">`)
+       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.")
+}
index 44f290025170217e83d2e0f6345fa4b509b82e72..60e370a51e70bcd6fb674934a138a3a4e28c33c5 100644 (file)
@@ -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{}
 }
index 3bc3e802e27c54e07a617949de19015aeefc416b..912d6036fb83ccf7b3840c94356cf8e0167b1ade 100644 (file)
@@ -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
        }
index 45ae9b63130eb965b218e62fcc552fa27e50726c..f87b287fcc92ad54e5397ad7809fd85054081cfa 100644 (file)
@@ -1,9 +1,9 @@
-<!DOCTYPE html>
+<!doctype html>
 <html lang="{{ site.Language.LanguageCode }}">
   <head>
     <title>{{ .Permalink }}</title>
-    <link rel="canonical" href="{{ .Permalink }}">
-    <meta charset="utf-8">
+    {{ with .OutputFormats.Canonical }}<link rel="{{ .Rel }}" href="{{ .Permalink }}">{{ end }}
+    <meta charset="utf-8" />
     <meta http-equiv="refresh" content="0; url={{ .Permalink }}">
   </head>
 </html>