From ee91c707eed62dd008c1d99fed1b65f6cd2a3974 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Fri, 23 Jan 2026 18:52:35 +0100 Subject: [PATCH] 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 --- common/paths/path.go | 35 ------------- common/paths/path_test.go | 97 ------------------------------------ hugolib/alias_test.go | 31 ++++++++++++ hugolib/disableKinds_test.go | 2 +- hugolib/page__meta.go | 4 -- hugolib/page__output.go | 36 +++++++++++++ hugolib/site.go | 15 ------ hugolib/site_render.go | 88 +++++++++----------------------- resources/page/page.go | 6 +-- 9 files changed, 93 insertions(+), 221 deletions(-) diff --git a/common/paths/path.go b/common/paths/path.go index 06f573faa..e9b9108f5 100644 --- a/common/paths/path.go +++ b/common/paths/path.go @@ -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:]) -} diff --git a/common/paths/path_test.go b/common/paths/path_test.go index f64fb4f6a..3fa4c03b2 100644 --- a/common/paths/path_test.go +++ b/common/paths/path_test.go @@ -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) - }) - } -} diff --git a/hugolib/alias_test.go b/hugolib/alias_test.go index f72f5f3b4..320ca7dd7 100644 --- a/hugolib/alias_test.go +++ b/hugolib/alias_test.go @@ -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] ``, ) } + +// 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/|", + ) +} diff --git a/hugolib/disableKinds_test.go b/hugolib/disableKinds_test.go index 25a8c94f6..9b02dd4c5 100644 --- a/hugolib/disableKinds_test.go +++ b/hugolib/disableKinds_test.go @@ -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") }) diff --git a/hugolib/page__meta.go b/hugolib/page__meta.go index 3776cb3ab..35dcee909 100644 --- a/hugolib/page__meta.go +++ b/hugolib/page__meta.go @@ -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: diff --git a/hugolib/page__output.go b/hugolib/page__output.go index 2c5e1ca04..d21c6860d 100644 --- a/hugolib/page__output.go +++ b/hugolib/page__output.go @@ -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 diff --git a/hugolib/site.go b/hugolib/site.go index 0e3660c86..cf585613c 100644 --- a/hugolib/site.go +++ b/hugolib/site.go @@ -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 } diff --git a/hugolib/site_render.go b/hugolib/site_render.go index 42d6ebdb4..c482e61ce 100644 --- a/hugolib/site_render.go +++ b/hugolib/site_render.go @@ -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, diff --git a/resources/page/page.go b/resources/page/page.go index 406843283..74400d7ed 100644 --- a/resources/page/page.go +++ b/resources/page/page.go @@ -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 -- 2.39.5