From: Joe Mooring Date: Sat, 17 Jan 2026 21:56:33 +0000 (-0800) Subject: hugolib: Fix multilingual alias generation X-Git-Url: http://git.maquefel.me/?a=commitdiff_plain;h=5ba03bf657ab22a890149f933511c10caf87c085;p=brevno-suite%2Fhugo hugolib: Fix multilingual alias generation Closes #14388 --- diff --git a/common/paths/path.go b/common/paths/path.go index e9b9108f5..06f573faa 100644 --- a/common/paths/path.go +++ b/common/paths/path.go @@ -428,3 +428,38 @@ 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 3fa4c03b2..f64fb4f6a 100644 --- a/common/paths/path_test.go +++ b/common/paths/path_test.go @@ -369,3 +369,100 @@ 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 0fc75523d..f72f5f3b4 100644 --- a/hugolib/alias_test.go +++ b/hugolib/alias_test.go @@ -232,10 +232,10 @@ aliases: [/p2-alias] -- layouts/all.html -- {{ .Title }} ` + // ------------------------------------------------------------------------ + // Test 1: Create aliases for the html output format only + // ------------------------------------------------------------------------ - // Test 1 - // Expected site structure: - // // public/ // ├── foo/ // │ ├── s1/ @@ -270,6 +270,7 @@ aliases: [/p2-alias] f := strings.ReplaceAll(files, "IS_HTML", "false") b := Test(t, f) + // output format: html b.AssertFileContent("public/foo/s1-alias/index.html", `https://example.org/foo/s1/`, ``, @@ -291,14 +292,16 @@ aliases: [/p2-alias] ``, ) + // output format: print b.AssertFileExists("public/print/foo/s1-alias/index.html", false) b.AssertFileExists("public/print/foo/s1/p1-alias/index.html", false) b.AssertFileExists("public/print/s2-alias/index.html", false) b.AssertFileExists("public/print/p2-alias/index.html", false) - // Test 2 - // Expected site structure: - // + // ------------------------------------------------------------------------ + // Test 2: Create aliases for the html and print output formats + // ------------------------------------------------------------------------ + // public/ // ├── foo/ // │ ├── s1/ @@ -341,6 +344,7 @@ aliases: [/p2-alias] f = strings.ReplaceAll(files, "IS_HTML", "true") b = Test(t, f) + // output format: html b.AssertFileContent("public/foo/s1-alias/index.html", `https://example.org/foo/s1/`, ``, @@ -361,6 +365,8 @@ aliases: [/p2-alias] ``, ``, ) + + // output format: print b.AssertFileContent("public/print/foo/s1-alias/index.html", `https://example.org/print/foo/s1/`, ``, @@ -382,3 +388,267 @@ aliases: [/p2-alias] ``, ) } + +// Issue 14388 +func TestIssue14388(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = 'https://example.org' +disableKinds = ['home', 'rss', 'sitemap', 'taxonomy', 'term'] + +defaultContentLanguage = 'en' + +defaultContentLanguageInSubdir = true +defaultContentRoleInSubdir = true +defaultContentVersionInSubdir = true + +[languages.en] + weight = 1 + # baseURL = 'https://en.example.org/' + +[languages.de] + weight = 2 + # baseURL = 'https://de.example.org/' + +[outputFormats.print] + isHTML = true + mediaType = 'text/html' + path = 'print' + +[outputs] + page = ['html', 'print'] + section = ['html', 'print'] +-- content/foo/s1/_index.de.md -- +--- +title: s1 de +aliases: [s1-alias] +--- +-- content/foo/s1/_index.en.md -- +--- +title: s1 en +aliases: [s1-alias] +--- +-- content/foo/s1/p1.de.md -- +--- +title: p1 de +aliases: [p1-alias] +--- +-- content/foo/s1/p1.en.md -- +--- +title: p1 en +aliases: [p1-alias] +--- +-- content/foo/s2/_index.de.md -- +--- +title: s2 de +aliases: [/s2-alias] +--- +-- content/foo/s2/_index.en.md -- +--- +title: s2 en +aliases: [/s2-alias] +--- +-- content/foo/s2/p2.de.md -- +--- +title: p2 de +aliases: [/p2-alias] +--- +-- content/foo/s2/p2.en.md -- +--- +title: p2 en +aliases: [/p2-alias] +--- +-- layouts/all.html -- +{{ .Title }} +` + // ------------------------------------------------------------------------ + // Test 1: Multilingual single-host + // ------------------------------------------------------------------------ + + b := Test(t, files) + + // language: de, output format: html + b.AssertFileContent("public/guest/v1.0.0/de/foo/s1-alias/index.html", + `https://example.org/guest/v1.0.0/de/foo/s1/`, + ``, + ``, + ) + b.AssertFileContent("public/guest/v1.0.0/de/foo/s1/p1-alias/index.html", + `https://example.org/guest/v1.0.0/de/foo/s1/p1/`, + ``, + ``, + ) + b.AssertFileContent("public/guest/v1.0.0/de/s2-alias/index.html", + `https://example.org/guest/v1.0.0/de/foo/s2/`, + ``, + ``, + ) + b.AssertFileContent("public/guest/v1.0.0/de/p2-alias/index.html", + `https://example.org/guest/v1.0.0/de/foo/s2/p2/`, + ``, + ``, + ) + + // language: de, output format: print + b.AssertFileContent("public/guest/v1.0.0/de/print/foo/s1-alias/index.html", + `https://example.org/guest/v1.0.0/de/print/foo/s1/`, + ``, + ``, + ) + b.AssertFileContent("public/guest/v1.0.0/de/print/foo/s1/p1-alias/index.html", + `https://example.org/guest/v1.0.0/de/print/foo/s1/p1/`, + ``, + ``, + ) + b.AssertFileContent("public/guest/v1.0.0/de/print/s2-alias/index.html", + `https://example.org/guest/v1.0.0/de/print/foo/s2/`, + ``, + ``, + ) + b.AssertFileContent("public/guest/v1.0.0/de/print/p2-alias/index.html", + `https://example.org/guest/v1.0.0/de/print/foo/s2/p2/`, + ``, + ``, + ) + + // language: en, output format: html + b.AssertFileContent("public/guest/v1.0.0/en/foo/s1-alias/index.html", + `https://example.org/guest/v1.0.0/en/foo/s1/`, + ``, + ``, + ) + b.AssertFileContent("public/guest/v1.0.0/en/foo/s1/p1-alias/index.html", + `https://example.org/guest/v1.0.0/en/foo/s1/p1/`, + ``, + ``, + ) + b.AssertFileContent("public/guest/v1.0.0/en/s2-alias/index.html", + `https://example.org/guest/v1.0.0/en/foo/s2/`, + ``, + ``, + ) + b.AssertFileContent("public/guest/v1.0.0/en/p2-alias/index.html", + `https://example.org/guest/v1.0.0/en/foo/s2/p2/`, + ``, + ``, + ) + + // language: en, output format: print + b.AssertFileContent("public/guest/v1.0.0/en/print/foo/s1-alias/index.html", + `https://example.org/guest/v1.0.0/en/print/foo/s1/`, + ``, + ``, + ) + b.AssertFileContent("public/guest/v1.0.0/en/print/foo/s1/p1-alias/index.html", + `https://example.org/guest/v1.0.0/en/print/foo/s1/p1/`, + ``, + ``, + ) + b.AssertFileContent("public/guest/v1.0.0/en/print/s2-alias/index.html", + `https://example.org/guest/v1.0.0/en/print/foo/s2/`, + ``, + ``, + ) + b.AssertFileContent("public/guest/v1.0.0/en/print/p2-alias/index.html", + `https://example.org/guest/v1.0.0/en/print/foo/s2/p2/`, + ``, + ``, + ) + + // ------------------------------------------------------------------------ + // Test 2: Multilingual multihost + // ------------------------------------------------------------------------ + + files = strings.ReplaceAll(files, "# baseURL", "baseURL") + b = Test(t, files) + + // language: de, output format: html + b.AssertFileContent("public/de/guest/v1.0.0/foo/s1-alias/index.html", + `https://de.example.org/guest/v1.0.0/foo/s1/`, + ``, + ``, + ) + b.AssertFileContent("public/de/guest/v1.0.0/foo/s1/p1-alias/index.html", + `https://de.example.org/guest/v1.0.0/foo/s1/p1/`, + ``, + ``, + ) + b.AssertFileContent("public/de/guest/v1.0.0/s2-alias/index.html", + `https://de.example.org/guest/v1.0.0/foo/s2/`, + ``, + ``, + ) + b.AssertFileContent("public/de/guest/v1.0.0/p2-alias/index.html", + `https://de.example.org/guest/v1.0.0/foo/s2/p2/`, + ``, + ``, + ) + + // language: de, output format: print + b.AssertFileContent("public/de/guest/v1.0.0/print/foo/s1-alias/index.html", + `https://de.example.org/guest/v1.0.0/print/foo/s1/`, + ``, + ``, + ) + b.AssertFileContent("public/de/guest/v1.0.0/print/foo/s1/p1-alias/index.html", + `https://de.example.org/guest/v1.0.0/print/foo/s1/p1/`, + ``, + ``, + ) + b.AssertFileContent("public/de/guest/v1.0.0/print/s2-alias/index.html", + `https://de.example.org/guest/v1.0.0/print/foo/s2/`, + ``, + ``, + ) + b.AssertFileContent("public/de/guest/v1.0.0/print/p2-alias/index.html", + `https://de.example.org/guest/v1.0.0/print/foo/s2/p2/`, + ``, + ``, + ) + + // language: en, output format: html + b.AssertFileContent("public/en/guest/v1.0.0/foo/s1-alias/index.html", + `https://en.example.org/guest/v1.0.0/foo/s1/`, + ``, + ``, + ) + b.AssertFileContent("public/en/guest/v1.0.0/foo/s1/p1-alias/index.html", + `https://en.example.org/guest/v1.0.0/foo/s1/p1/`, + ``, + ``, + ) + b.AssertFileContent("public/en/guest/v1.0.0/s2-alias/index.html", + `https://en.example.org/guest/v1.0.0/foo/s2/`, + ``, + ``, + ) + b.AssertFileContent("public/en/guest/v1.0.0/p2-alias/index.html", + `https://en.example.org/guest/v1.0.0/foo/s2/p2/`, + ``, + ``, + ) + + // language: en, output format: print + b.AssertFileContent("public/en/guest/v1.0.0/print/foo/s1-alias/index.html", + `https://en.example.org/guest/v1.0.0/print/foo/s1/`, + ``, + ``, + ) + b.AssertFileContent("public/en/guest/v1.0.0/print/foo/s1/p1-alias/index.html", + `https://en.example.org/guest/v1.0.0/print/foo/s1/p1/`, + ``, + ``, + ) + b.AssertFileContent("public/en/guest/v1.0.0/print/s2-alias/index.html", + `https://en.example.org/guest/v1.0.0/print/foo/s2/`, + ``, + ``, + ) + b.AssertFileContent("public/en/guest/v1.0.0/print/p2-alias/index.html", + `https://en.example.org/guest/v1.0.0/print/foo/s2/p2/`, + ``, + ``, + ) +} diff --git a/hugolib/site_render.go b/hugolib/site_render.go index ea16a0498..42d6ebdb4 100644 --- a/hugolib/site_render.go +++ b/hugolib/site_render.go @@ -333,31 +333,28 @@ func (s *Site) renderAliases() error { for _, a := range p.Aliases() { isRelative := !strings.HasPrefix(a, "/") + prefix := path.Join("/", p.targetPathDescriptor.PrefixFilePath) + var baseDir string if isRelative { - // Resolve the alias relative to the current page's - // directory level, prepended by the output format's - // "path" setting, which may be empty. - basePath := path.Join(p.targetPaths().SubResourceBaseLink, "..") - a = path.Join(f.Path, basePath, a) + // 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 { - // Resolve the alias relative to the site root, - // prepended by the output format's "path" setting, - // which may be empty. - a = path.Join(f.Path, a) + // 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" } - lang := p.Language().Lang - - if s.h.Configs.IsMultihost && !strings.HasPrefix(a, "/"+lang) { - // These need to be in its language root. - a = path.Join(lang, a) - } - err := s.writeDestAlias(a, plink, f, p) if err != nil { return radix.WalkStop, err diff --git a/resources/page/page_paths.go b/resources/page/page_paths.go index 912d6036f..4f24488c3 100644 --- a/resources/page/page_paths.go +++ b/resources/page/page_paths.go @@ -50,10 +50,18 @@ type TargetPathDescriptor struct { // 2) the file base name (TranslationBaseName). BaseName string - // Typically a language prefix added to file paths. + // PrefixFilePath contains zero or more content dimensions used as a file + // path prefix. Dimensions are ordered role/version/language for + // single-host and language/role/version for multihost. + // Example (single-host): guest/v1.0.0/en + // Example (multihost): en/guest/v1.0.0 PrefixFilePath string - // Typically a language prefix added to links. + // PrefixLink contains zero or more content dimensions used as a link + // prefix. Dimensions are ordered role/version/language for single-host, + // but the language dimension is excluded for multihost. + // Example (single-host): guest/v1.0.0/en + // Example (multihost): guest/v1.0.0 PrefixLink string // If in multihost mode etc., every link/path needs to be prefixed, even