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 }}<link rel="{{ .Rel }}" href="{{ .Permalink }}">{{ end }}
```

Fixes #14354
Fixes #14356
This commit is contained in:
Bjørn Erik Pedersen
2026-01-09 10:47:41 +01:00
parent 5ea3e13db6
commit 2d80b8a741
9 changed files with 211 additions and 35 deletions
+5
View File
@@ -607,8 +607,13 @@ type RootConfig struct {
DefaultOutputFormat string DefaultOutputFormat string
// Disable generation of redirect to the default language when DefaultContentLanguageInSubdir is enabled. // 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 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. // Disable creation of alias redirect pages.
DisableAliases bool DisableAliases bool
+4
View File
@@ -70,6 +70,10 @@ func (a aliasHandler) renderAlias(permalink string, p page.Page, matrix sitesmat
return nil, errors.New("no alias template found") return nil, errors.New("no alias template found")
} }
if p == nil {
p = page.NopPage
}
data := aliasPage{ data := aliasPage{
permalink, permalink,
p, p,
+9 -1
View File
@@ -160,11 +160,19 @@ func createTargetPathDescriptor(p *pageState) (page.TargetPathDescriptor, error)
// Add path prefixes. // Add path prefixes.
// Add any role first, as that is a natural candidate for external ACL checks. // 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() rolePrefix := s.getPrefixRole()
addPrefix(rolePrefix, rolePrefix) addPrefix(rolePrefix, rolePrefix)
versionPrefix := s.getPrefixVersion() versionPrefix := s.getPrefixVersion()
addPrefix(versionPrefix, versionPrefix) 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 { if desc.URL != "" && strings.IndexByte(desc.URL, ':') >= 0 {
// Attempt to parse and expand an url // Attempt to parse and expand an url
+1 -5
View File
@@ -1056,7 +1056,7 @@ func (s *siteRefLinker) refLink(ref string, source any, relative bool, outputFor
if outputFormat != "" { if outputFormat != "" {
o := target.OutputFormats().Get(outputFormat) o := target.OutputFormats().Get(outputFormat)
if o == nil { if o.IsZero() {
s.logNotFound(refURL.Path, fmt.Sprintf("output format %q", outputFormat), p, pos) s.logNotFound(refURL.Path, fmt.Sprintf("output format %q", outputFormat), p, pos)
return s.notFoundURL, nil return s.notFoundURL, nil
} }
@@ -1770,10 +1770,6 @@ func (s *Site) render(ctx *siteRenderContext) (err error) {
return return
} }
if err = s.renderMainLanguageRedirect(); err != nil {
return
}
return return
} }
+52 -22
View File
@@ -22,6 +22,7 @@ import (
"github.com/bep/logg" "github.com/bep/logg"
"github.com/gohugoio/go-radix" "github.com/gohugoio/go-radix"
"github.com/gohugoio/hugo/common/paths"
"github.com/gohugoio/hugo/hugolib/doctree" "github.com/gohugoio/hugo/hugolib/doctree"
"github.com/gohugoio/hugo/tpl/tplimpl" "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 p.paginator != nil && p.paginator.current != nil {
if err := s.renderPaginator(p, templ); err != nil { if err := s.renderPaginator(p, templ); err != nil {
if sendErr(err) { if sendErr(err) {
@@ -357,32 +368,51 @@ func (s *Site) renderAliases() error {
return w.Walk(context.TODO()) 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. // depending on if it lives in sub folder (e.g. /en) or not.
func (s *Site) renderMainLanguageRedirect() error { func (s *Site) renderDefaultDimensionRedirect(home *pageState) error {
if s.conf.DisableDefaultLanguageRedirect { 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
return nil return nil
} }
html, found := s.conf.OutputFormats.Config.GetByName("html") shouldAdd := s.conf.DefaultContentLanguageInSubdir && !s.Conf.IsMultihost()
if found { shouldAdd = shouldAdd || s.conf.DefaultContentVersionInSubdir || s.conf.DefaultContentRoleInSubdir
mainLang := s.conf.DefaultContentLanguage if !shouldAdd {
if s.conf.DefaultContentLanguageInSubdir { return nil
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 { homeLink := home.pageOutput.targetPaths().Link // This doesn't have any baseURL paths in it.
return err
} of := home.outputFormat()
} else { homePermalink := home.Permalink()
mainLangURL := s.PathSpec.AbsURL("", false)
s.Log.Debugf("Write redirect to main language %s: %s", mainLang, mainLangURL) var ps []string
if err := s.publishDestAlias(true, mainLang, mainLangURL, html, nil); err != nil { if of.Path != "" {
return err // 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
} }
} }
@@ -1272,3 +1272,115 @@ disableKinds = ["rss", "sitemap", "taxonomy", "term"]
b.AssertFileContent("public/s1/index.html", "section|/s1/|") 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.")
}
+24 -4
View File
@@ -18,6 +18,7 @@ package page
import ( import (
"strings" "strings"
"github.com/gohugoio/hugo/common/types"
"github.com/gohugoio/hugo/media" "github.com/gohugoio/hugo/media"
"github.com/gohugoio/hugo/output" "github.com/gohugoio/hugo/output"
) )
@@ -25,6 +26,8 @@ import (
// OutputFormats holds a list of the relevant output formats for a given page. // OutputFormats holds a list of the relevant output formats for a given page.
type OutputFormats []OutputFormat type OutputFormats []OutputFormat
var _ types.Zeroer = OutputFormat{}
// OutputFormat links to a representation of a resource. // OutputFormat links to a representation of a resource.
type OutputFormat struct { type OutputFormat struct {
// Rel contains a value that can be used to construct a rel link. // 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 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 { func NewOutputFormat(relPermalink, permalink string, isCanonical bool, f output.Format) OutputFormat {
isUserConfigured := true isUserConfigured := true
for _, d := range output.DefaultFormats { 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. // Get gets a OutputFormat given its name, i.e. json, html etc.
// It returns nil if none found. // It returns a zero OutputFormat if not found.
func (o OutputFormats) Get(name string) *OutputFormat { func (o OutputFormats) Get(name string) OutputFormat {
for _, f := range o { for _, f := range o {
if strings.EqualFold(f.Format.Name, name) { if strings.EqualFold(f.Format.Name, name) {
return &f return f
} }
} }
return nil 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 OutputFormat{}
} }
+1
View File
@@ -432,6 +432,7 @@ func (p *pagePathBuilder) PathDirBase() string {
func (p *pagePathBuilder) PathFile() string { func (p *pagePathBuilder) PathFile() string {
dir := p.Path(0) dir := p.Path(0)
if p.prefixPath != "" { if p.prefixPath != "" {
dir = "/" + p.prefixPath + dir dir = "/" + p.prefixPath + dir
} }
+3 -3
View File
@@ -1,9 +1,9 @@
<!DOCTYPE html> <!doctype html>
<html lang="{{ site.Language.LanguageCode }}"> <html lang="{{ site.Language.LanguageCode }}">
<head> <head>
<title>{{ .Permalink }}</title> <title>{{ .Permalink }}</title>
<link rel="canonical" href="{{ .Permalink }}"> {{ with .OutputFormats.Canonical }}<link rel="{{ .Rel }}" href="{{ .Permalink }}">{{ end }}
<meta charset="utf-8"> <meta charset="utf-8" />
<meta http-equiv="refresh" content="0; url={{ .Permalink }}"> <meta http-equiv="refresh" content="0; url={{ .Permalink }}">
</head> </head>
</html> </html>