diff --git a/pkg/server/api/complianceportal/v1/locale.go b/pkg/server/api/complianceportal/v1/locale.go new file mode 100644 index 000000000..357e793b0 --- /dev/null +++ b/pkg/server/api/complianceportal/v1/locale.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package complianceportal_v1 + +import ( + "net/url" + "slices" + "strings" + + "go.probo.inc/probo/pkg/iam" +) + +const defaultCompliancePortalLocale = "en" + +func isCompliancePortalLocale(value string) bool { + return slices.Contains(iam.SupportedIdentityLocales, value) +} + +func splitLocaleFromAppPath(appPath string) (locale, rest string) { + segments := strings.Split(strings.Trim(appPath, "/"), "/") + if len(segments) == 0 || segments[0] == "" { + return defaultCompliancePortalLocale, "/" + } + + if isCompliancePortalLocale(segments[0]) { + locale = segments[0] + if len(segments) == 1 { + return locale, "/" + } + + return locale, "/" + strings.Join(segments[1:], "/") + } + + // Unprefixed path — treat content path as-is; default lang for tags. + return defaultCompliancePortalLocale, appPath +} + +func localizedPageURL(pageBaseURL, locale, rest string) string { + base := strings.TrimRight(pageBaseURL, "/") + segments := []string{locale} + + if rest != "/" && rest != "" { + trimmed := strings.Trim(rest, "/") + if trimmed != "" { + segments = append(segments, strings.Split(trimmed, "/")...) + } + } + + escaped := make([]string, len(segments)) + for i, segment := range segments { + escaped[i] = url.PathEscape(segment) + } + + joined, err := url.JoinPath(base, escaped...) + if err != nil { + return base + "/" + strings.Join(escaped, "/") + } + + return joined +} + +// rewriteContinueURLLocale swaps the leading locale segment of continueURL's +// path for locale (a supported short tag). Relative and absolute URLs are +// accepted; query and fragment are preserved. Unprefixed paths get the locale +// prepended. Returns continueURL unchanged when locale is unsupported or the +// URL cannot be parsed. +func rewriteContinueURLLocale(continueURL, locale string) string { + if !isCompliancePortalLocale(locale) { + return continueURL + } + + u, err := url.Parse(continueURL) + if err != nil { + return continueURL + } + + path := u.Path + if path == "" { + path = "/" + } + + _, rest := splitLocaleFromAppPath(path) + if rest == "/" || rest == "" { + u.Path = "/" + locale + } else { + u.Path = "/" + locale + rest + } + + return u.String() +} diff --git a/pkg/server/api/complianceportal/v1/seo_locale_test.go b/pkg/server/api/complianceportal/v1/locale_test.go similarity index 100% rename from pkg/server/api/complianceportal/v1/seo_locale_test.go rename to pkg/server/api/complianceportal/v1/locale_test.go diff --git a/pkg/server/api/complianceportal/v1/seo.go b/pkg/server/api/complianceportal/v1/seo.go index 4b113cf6a..0868427cf 100644 --- a/pkg/server/api/complianceportal/v1/seo.go +++ b/pkg/server/api/complianceportal/v1/seo.go @@ -22,17 +22,12 @@ package complianceportal_v1 import ( "net/http" - "net/url" - "slices" - "strings" "go.gearno.de/x/ref" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/server/api/complianceportal" ) -const defaultCompliancePortalLocale = "en" - // SEOFromRequest derives html lang, a self-referencing canonical URL, and // hreflang alternates (including x-default → English) for the SPA shell. // Portals are host-routed (slug subdomain / custom domain); the request path @@ -73,80 +68,3 @@ func SEOFromRequest(r *http.Request) (htmlLang, canonical string, hreflang []Hre return htmlLang, canonical, hreflang } - -func splitLocaleFromAppPath(appPath string) (locale, rest string) { - segments := strings.Split(strings.Trim(appPath, "/"), "/") - if len(segments) == 0 || segments[0] == "" { - return defaultCompliancePortalLocale, "/" - } - - if isCompliancePortalLocale(segments[0]) { - locale = segments[0] - if len(segments) == 1 { - return locale, "/" - } - - return locale, "/" + strings.Join(segments[1:], "/") - } - - // Unprefixed path — treat content path as-is; default lang for tags. - return defaultCompliancePortalLocale, appPath -} - -func isCompliancePortalLocale(value string) bool { - return slices.Contains(iam.SupportedIdentityLocales, value) -} - -// rewriteContinueURLLocale swaps the leading locale segment of continueURL's -// path for locale (a supported short tag). Relative and absolute URLs are -// accepted; query and fragment are preserved. Unprefixed paths get the locale -// prepended. Returns continueURL unchanged when locale is unsupported or the -// URL cannot be parsed. -func rewriteContinueURLLocale(continueURL, locale string) string { - if !isCompliancePortalLocale(locale) { - return continueURL - } - - u, err := url.Parse(continueURL) - if err != nil { - return continueURL - } - - path := u.Path - if path == "" { - path = "/" - } - - _, rest := splitLocaleFromAppPath(path) - if rest == "/" || rest == "" { - u.Path = "/" + locale - } else { - u.Path = "/" + locale + rest - } - - return u.String() -} - -func localizedPageURL(pageBaseURL, locale, rest string) string { - base := strings.TrimRight(pageBaseURL, "/") - segments := []string{locale} - - if rest != "/" && rest != "" { - trimmed := strings.Trim(rest, "/") - if trimmed != "" { - segments = append(segments, strings.Split(trimmed, "/")...) - } - } - - escaped := make([]string, len(segments)) - for i, segment := range segments { - escaped[i] = url.PathEscape(segment) - } - - joined, err := url.JoinPath(base, escaped...) - if err != nil { - return base + "/" + strings.Join(escaped, "/") - } - - return joined -}