Make Page.Aliases more useful in multidimensional setups (note)

There are 2 fundamental changes related to alias handling in this PR:

1. the `Page.Aliases`` method returns a ready-to-use list of aliases, not the raw input data. This means that it's prefixed with correct dimension values (e.g. language code) and output format paths, and ready to use in e.g. Netlify's _redirects file.
2. We only render aliases for output formats that is both defined as `IsHTML` and `Permalinkable`.

See #14402
This commit is contained in:
Bjørn Erik Pedersen
2026-01-23 18:52:35 +01:00
committed by GitHub
parent 11f7f39913
commit ee91c707ee
9 changed files with 93 additions and 221 deletions
-35
View File
@@ -428,38 +428,3 @@ func ToSlashPreserveLeading(s string) string {
func IsSameFilePath(s1, s2 string) bool {
return path.Clean(ToSlashTrim(s1)) == path.Clean(ToSlashTrim(s2))
}
// InjectSegment returns a clean path with forward slashes by inserting segment
// into s immediately following prefix. The prefix must be a complete path
// component match (e.g., "en" matches "en/docs" but not "entertainment").
// If prefix is empty, segment is prepended to s. If prefix is not found or
// is a partial match, the cleaned version of s is returned.
func InjectSegment(s, prefix, segment string) string {
if s == "" && prefix == "" && segment == "" {
return ""
}
s = path.Clean(filepath.ToSlash(s))
prefix = path.Clean(filepath.ToSlash(prefix))
segment = path.Clean(filepath.ToSlash(segment))
if prefix == "." {
return path.Join(segment, s)
}
if !strings.HasPrefix(s, prefix) {
return s
}
lp := len(prefix)
ls := len(s)
// Ensure prefix matches a full component.
if ls > lp && prefix != "/" {
if s[lp] != '/' {
return s
}
}
return path.Join(prefix, segment, s[lp:])
}
-97
View File
@@ -369,100 +369,3 @@ func TestPathEscape(t *testing.T) {
}
}
}
func TestInjectSegment(t *testing.T) {
c := qt.New(t)
tests := []struct {
name string
s string
prefix string
segment string
expected string
}{
{
name: "rooted match",
s: "/abc/def",
prefix: "/abc",
segment: "seg",
expected: "/abc/seg/def",
},
{
name: "relative match",
s: "abc/def",
prefix: "abc",
segment: "seg",
expected: "abc/seg/def",
},
{
name: "rootedness mismatch - s rooted",
s: "/abc/def",
prefix: "abc",
segment: "seg",
expected: "/abc/def",
},
{
name: "rootedness mismatch - prefix rooted",
s: "abc/def",
prefix: "/abc",
segment: "seg",
expected: "abc/def",
},
{
name: "partial component mismatch",
s: "/abcdef",
prefix: "/abc",
segment: "seg",
expected: "/abcdef",
},
{
name: "root slash prefix",
s: "/abc",
prefix: "/",
segment: "seg",
expected: "/seg/abc",
},
{
name: "all empty",
s: "",
prefix: "",
segment: "",
expected: "",
},
{
name: "empty s and prefix",
s: "",
prefix: "",
segment: "seg",
expected: "seg",
},
{
name: "empty segment",
s: "/abc/def",
prefix: "/abc",
segment: "",
expected: "/abc/def",
},
{
name: "empty prefix with relative s",
s: "abc",
prefix: "",
segment: "seg",
expected: "seg/abc",
},
{
name: "empty prefix with rooted s",
s: "/abc",
prefix: "",
segment: "seg",
expected: "seg/abc",
},
}
for _, tt := range tests {
c.Run(tt.name, func(c *qt.C) {
actual := InjectSegment(tt.s, tt.prefix, tt.segment)
c.Assert(actual, qt.Equals, tt.expected)
})
}
}
+31
View File
@@ -203,6 +203,7 @@ disableKinds = ['home', 'rss', 'sitemap', 'taxonomy', 'term']
[outputFormats.print]
isHTML = IS_HTML
permalinkable = true
mediaType = 'text/html'
path = 'print'
@@ -416,6 +417,7 @@ defaultContentVersionInSubdir = true
isHTML = true
mediaType = 'text/html'
path = 'print'
permalinkable = true
[outputs]
page = ['html', 'print']
@@ -652,3 +654,32 @@ aliases: [/p2-alias]
`<meta http-equiv="refresh" content="0; url=https://en.example.org/guest/v1.0.0/print/foo/s2/p2/">`,
)
}
// Issue #14402.
func TestComprehensiveAliasesRedirectsFile(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
disableAliases = true
defaultContentLanguageInSubdir = true
defaultContentRoleInSubdir = true
defaultContentVersionInSubdir = true
baseURL = "https://example.org/"
-- content/foo/p1.md --
---
aliases: ["/foo/p2/", "../p3/"]
---
-- content/foo/p2.md --
-- content/p3.md --
-- layouts/home.html --
Home.
{{ range $p := site.RegularPages }}{{ range .Aliases }}{{ . | printf "%-35s" }}=>{{ $p.RelPermalink -}}|{{ end -}}{{ end }}
`
b := Test(t, files)
b.AssertFileContent("public/guest/v1.0.0/en/index.html",
"/guest/v1.0.0/en/foo/p2 =>/guest/v1.0.0/en/foo/p1/|",
"/guest/v1.0.0/en/p3 =>/guest/v1.0.0/en/foo/p1/|",
)
}
+1 -1
View File
@@ -190,7 +190,7 @@ DATA
t.Run("Build config, no render link", func(t *testing.T) {
files := filesForDisabledKind("")
b := Test(t, files)
b.AssertFileExists("public/sect/no-render/index.html", false)
b.AssertFileExists("public/sect/no-render-link/index.html", false)
b.AssertFileContent("public/link-alias/index.html", "refresh")
})
-4
View File
@@ -384,10 +384,6 @@ func (m *pageMeta) prepareRebuild() {
m.pageConfig.Dates = m.datesOriginal
}
func (m *pageMeta) Aliases() []string {
return m.pageConfig.Aliases
}
func (m *pageMeta) BundleType() string {
switch m.pathInfo.Type() {
case paths.TypeLeaf:
+36
View File
@@ -15,6 +15,8 @@ package hugolib
import (
"fmt"
"path"
"strings"
"github.com/gohugoio/hugo/identity"
"github.com/gohugoio/hugo/output"
@@ -113,6 +115,40 @@ type pageOutput struct {
renderOnce bool // To make sure we at least try to render it once.
}
func (po *pageOutput) Aliases() []string {
conf := po.p.s.conf
p := po.p
f := po.f
// This is relatively cheap to create, and the common case is to call this once.
// So avoid caching this value.
aliases := make([]string, len(po.p.m.pageConfig.Aliases))
for i, a := range po.p.m.pageConfig.Aliases {
isRelative := !strings.HasPrefix(a, "/")
var baseDir string
if isRelative {
// Form the baseDir by taking the resource's base target
// and moving up one level to the parent.
parentContext := path.Join(p.targetPaths().SubResourceBaseTarget, "..")
baseDir = parentContext
} else {
// Form the baseDir by prepending the content dimension
// prefixes with the Output Format path.
baseDir = path.Join("/", p.targetPathDescriptor.PrefixFilePath, f.Path)
}
a = path.Join(baseDir, a)
if conf.C.IsUglyURLSection(p.Section()) && !strings.HasSuffix(a, ".html") {
a += ".html"
}
aliases[i] = a
}
return aliases
}
func (po *pageOutput) incrRenderState() {
po.renderState++
po.renderOnce = true
-15
View File
@@ -1760,21 +1760,6 @@ func (s *Site) render(ctx *siteRenderContext) (err error) {
return err
}
if ctx.outIdx == 0 && s.h.buildCounter.Load() == 0 {
// Note that even if disableAliases is set, the aliases themselves are
// preserved on page. The motivation with this is to be able to generate
// 301 redirects in a .htaccess file and similar using a custom output format.
if !s.conf.DisableAliases {
// Aliases must be rendered before pages.
// Some sites, Hugo docs included, have faulty alias definitions that point
// to itself or another real page. These will be overwritten in the next
// step.
if err = s.renderAliases(); err != nil {
return
}
}
}
if err = s.renderPages(ctx); err != nil {
return
}
+24 -64
View File
@@ -154,6 +154,20 @@ func pageRenderer(
}
}
if !s.conf.DisableAliases && s.h.buildCounter.Load() == 0 {
of := p.outputFormat()
if of.IsHTML && of.Permalinkable {
// Render any aliases for this page.
if err := s.renderAliasesForPage(p); err != nil {
if sendErr(err) {
continue
} else {
return
}
}
}
}
if !p.render {
// Nothing more to do for this page.
continue
@@ -300,71 +314,17 @@ func (s *Site) renderPaginator(p *pageState, templ *tplimpl.TemplInfo) error {
return nil
}
// renderAliases renders shell pages that simply have a redirect in the header.
func (s *Site) renderAliases() error {
w := &doctree.NodeShiftTreeWalker[contentNode]{
Tree: s.pageMap.treePages,
Handle: func(key string, n contentNode) (radix.WalkFlag, error) {
p := n.(*pageState)
// We cannot alias a page that's not rendered.
if p.m.noLink() || p.skipRender() {
return radix.WalkContinue, nil
}
if len(p.Aliases()) == 0 {
return radix.WalkContinue, nil
}
pathSeen := make(map[string]bool)
for _, of := range p.OutputFormats() {
if !of.Format.IsHTML {
continue
}
f := of.Format
if pathSeen[f.Path] {
continue
}
pathSeen[f.Path] = true
plink := of.Permalink()
for _, a := range p.Aliases() {
isRelative := !strings.HasPrefix(a, "/")
prefix := path.Join("/", p.targetPathDescriptor.PrefixFilePath)
var baseDir string
if isRelative {
// Form the baseDir by taking the resource's base target
// and moving up one level to the parent. Then, inject
// the Output Format path between the content dimension
// prefixes and the remaining path.
parentContext := path.Join(p.targetPaths().SubResourceBaseTarget, "..")
baseDir = paths.InjectSegment(parentContext, prefix, f.Path)
} else {
// Form the baseDir by prepending the content dimension
// prefixes with the Output Format path.
baseDir = path.Join(prefix, f.Path)
}
a = path.Join(baseDir, a)
if s.conf.C.IsUglyURLSection(p.Section()) && !strings.HasSuffix(a, ".html") {
a += ".html"
}
err := s.writeDestAlias(a, plink, f, p)
if err != nil {
return radix.WalkStop, err
}
}
}
return radix.WalkContinue, nil
},
func (s *Site) renderAliasesForPage(p *pageState) error {
po := p.pageOutput
f := po.f
plink := p.Permalink()
for _, a := range p.Aliases() {
err := s.writeDestAlias(a, plink, f, p)
if err != nil {
return err
}
}
return w.Walk(context.TODO())
return nil
}
// renderDefaultSiteRedirect creates a redirect to the default site's home,
+1 -5
View File
@@ -197,9 +197,6 @@ type PageMetaProvider interface {
// The 4 page dates
resource.Dated
// Aliases forms the base for redirects generation.
Aliases() []string
// BundleType returns the bundle type: `leaf`, `branch` or an empty string.
BundleType() string
@@ -278,8 +275,6 @@ func NamedPageMetaValue(p PageMetaLanguageResource, nameLower string) (any, bool
v = p.Section()
case "lang":
v = p.Lang()
case "aliases":
v = p.Aliases()
case "name":
v = p.Name()
case "keywords":
@@ -347,6 +342,7 @@ type PageWithoutContent interface {
PageMetaProvider
Param(key any) (any, error)
Aliases() []string
PageMetaInternalProvider
resource.LanguageProvider