]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
Make Page.Aliases more useful in multidimensional setups (note)
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Fri, 23 Jan 2026 17:52:35 +0000 (18:52 +0100)
committerGitHub <noreply@github.com>
Fri, 23 Jan 2026 17:52:35 +0000 (18:52 +0100)
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

common/paths/path.go
common/paths/path_test.go
hugolib/alias_test.go
hugolib/disableKinds_test.go
hugolib/page__meta.go
hugolib/page__output.go
hugolib/site.go
hugolib/site_render.go
resources/page/page.go

index 06f573faac9ce46954825f46058fbd034fe8e95e..e9b9108f514ecc23652f4f01c7cf8f3bee27e372 100644 (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:])
-}
index f64fb4f6a40a3609e9955cb1481a88823bceb440..3fa4c03b2805d266faa50056a5856cebd4c8e91c 100644 (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)
-               })
-       }
-}
index f72f5f3b4fdd80fdd7b7aad66a4b775a0b5dc321..320ca7dd74d4831cd90c20b1aa77aa2d2aedf33e 100644 (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/|",
+       )
+}
index 25a8c94f6b6b9be603f19861e0f927b4aa49bf06..9b02dd4c5f39e322ccebf89bbb181bdbe12a2413 100644 (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")
        })
 
index 3776cb3abdc729ae70376814aae95c4773f12bac..35dcee90909236ac3d9fe395f155e09e6972840c 100644 (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:
index 2c5e1ca042e8ab066c697a058eaec8bc9f31f341..d21c6860dc7ca7249f3cf0f85b9918059afb90a5 100644 (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
index 0e3660c867a89a9b9ee1db4c599f9b691bf196de..cf585613c077d30289d1ada3a67f61b464bf68ec 100644 (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
        }
index 42d6ebdb4e063f2e1fc41ebd283347ed4ebbb813..c482e61ceaef02641f2da904200f7a3ae14b38f2 100644 (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,
index 40684328379660f42661181df18eea500e1b1ee5..74400d7ed8d42b6f63ac4d1ebe4a15dacd5dd2e9 100644 (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