Fix portal SEO, Korean copy, and favicon
Custom-domain base URLs included the request path, so canonical/hreflang doubled routes; emit SEO links only with an absolute origin. Rewrite Korean mismatch strings to avoid literal particle parentheses, rename HtmlLang to HTMLLang, and ship public favicons so Vite no longer 404s the fallback icon. Signed-off-by: Émile Ré <emile@probo.com>
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="{{if .HtmlLang}}{{.HtmlLang}}{{else}}en{{end}}">
|
||||
<html lang="{{if .HTMLLang}}{{.HTMLLang}}{{else}}en{{end}}">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
BIN
apps/compliance-portal/public/favicons/favicon-128.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
apps/compliance-portal/public/favicons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 625 B |
BIN
apps/compliance-portal/public/favicons/favicon-196x196.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
apps/compliance-portal/public/favicons/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
apps/compliance-portal/public/favicons/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
apps/compliance-portal/public/favicons/favicon.ico
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
apps/compliance-portal/public/favicons/mstile-144x144.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
apps/compliance-portal/public/favicons/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/compliance-portal/public/favicons/mstile-310x150.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
apps/compliance-portal/public/favicons/mstile-310x310.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
apps/compliance-portal/public/favicons/mstile-70x70.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
@@ -98,9 +98,9 @@
|
||||
"label": "언어",
|
||||
"updateFailed": "언어를 업데이트하지 못했습니다",
|
||||
"mismatch": {
|
||||
"message": "{{language}}(으)로 이 페이지를 보고 있습니다.",
|
||||
"switchToMine": "{{language}}(으)로 전환",
|
||||
"useThis": "{{language}}을(를) 내 언어로 사용",
|
||||
"message": "이 페이지의 표시 언어는 {{language}}입니다.",
|
||||
"switchToMine": "내 언어({{language}})로 전환",
|
||||
"useThis": "표시 언어({{language}})를 내 언어로 사용",
|
||||
"dismiss": "닫기"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ function goHtmlTemplateDevDefaults(): Plugin {
|
||||
handler(html) {
|
||||
return html
|
||||
.replace(
|
||||
/\{\{if \.HtmlLang\}\}\{\{\.HtmlLang\}\}\{\{else\}\}en\{\{end\}\}/g,
|
||||
/\{\{if \.HTMLLang\}\}\{\{\.HTMLLang\}\}\{\{else\}\}en\{\{end\}\}/g,
|
||||
"en",
|
||||
)
|
||||
.replace(
|
||||
|
||||
@@ -97,9 +97,11 @@ func NewSNIMiddleware(visitorSvc *visitor.Service) func(next http.Handler) http.
|
||||
}
|
||||
}
|
||||
|
||||
// Origin only — consumers append their own paths (SEO, sitemap,
|
||||
// robots, brand assets, OAuth). Including r.URL.Path here would
|
||||
// duplicate the route (e.g. /fr/documents/fr/documents).
|
||||
baseURL := &url.URL{
|
||||
Host: r.Host,
|
||||
Path: r.URL.Path,
|
||||
Scheme: "https",
|
||||
}
|
||||
baseURLString := baseURL.String()
|
||||
|
||||
@@ -134,8 +134,6 @@ func compliancePageHeadData() HeadDataFunc {
|
||||
}
|
||||
|
||||
compliancePageBaseURL := complianceportal.CompliancePortalBaseURLFromContext(r.Context())
|
||||
pageBase := ref.UnrefOrZero(compliancePageBaseURL)
|
||||
htmlLang, canonical, hreflang := SEOFromRequest(r, pageBase)
|
||||
|
||||
description := tc.Title
|
||||
if tc.Description != nil && *tc.Description != "" {
|
||||
@@ -143,12 +141,18 @@ func compliancePageHeadData() HeadDataFunc {
|
||||
}
|
||||
|
||||
headData := HeadData{
|
||||
Title: tc.Title,
|
||||
Description: description,
|
||||
OGURL: pageBase,
|
||||
HtmlLang: htmlLang,
|
||||
CanonicalURL: canonical,
|
||||
Hreflang: hreflang,
|
||||
Title: tc.Title,
|
||||
Description: description,
|
||||
}
|
||||
|
||||
// Canonical / hreflang / og:url must be absolute. Without a portal
|
||||
// origin in context, skip them rather than emit relative paths.
|
||||
if pageBase := ref.UnrefOrZero(compliancePageBaseURL); pageBase != "" {
|
||||
htmlLang, canonical, hreflang := SEOFromRequest(r, pageBase)
|
||||
headData.HTMLLang = htmlLang
|
||||
headData.OGURL = canonical
|
||||
headData.CanonicalURL = canonical
|
||||
headData.Hreflang = hreflang
|
||||
}
|
||||
|
||||
if tc.LogoFileID != nil && compliancePageBaseURL != nil {
|
||||
|
||||
@@ -34,7 +34,10 @@ 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
|
||||
// is already relative to the portal root.
|
||||
// is already relative to the portal root. pageBaseURL is normalized to its
|
||||
// origin so a stale path in the base cannot double the route. When pageBaseURL
|
||||
// has no usable origin, canonical and hreflang are left empty so callers do
|
||||
// not emit relative SEO links.
|
||||
func SEOFromRequest(r *http.Request, pageBaseURL string) (htmlLang, canonical string, hreflang []HreflangLink) {
|
||||
pathname := r.URL.Path
|
||||
if pathname == "" {
|
||||
@@ -42,9 +45,16 @@ func SEOFromRequest(r *http.Request, pageBaseURL string) (htmlLang, canonical st
|
||||
}
|
||||
|
||||
locale, rest := splitLocaleFromAppPath(pathname)
|
||||
|
||||
htmlLang = locale
|
||||
canonical = localizedPageURL(pageBaseURL, locale, rest)
|
||||
|
||||
origin := portalOrigin(pageBaseURL)
|
||||
if origin == "" {
|
||||
// No absolute origin — return lang only; callers must not emit
|
||||
// relative canonical/hreflang URLs.
|
||||
return htmlLang, "", nil
|
||||
}
|
||||
|
||||
canonical = localizedPageURL(origin, locale, rest)
|
||||
|
||||
locales := iam.SupportedIdentityLocales
|
||||
|
||||
@@ -52,18 +62,29 @@ func SEOFromRequest(r *http.Request, pageBaseURL string) (htmlLang, canonical st
|
||||
for _, loc := range locales {
|
||||
hreflang = append(hreflang, HreflangLink{
|
||||
Lang: loc,
|
||||
Href: localizedPageURL(pageBaseURL, loc, rest),
|
||||
Href: localizedPageURL(origin, loc, rest),
|
||||
})
|
||||
}
|
||||
|
||||
hreflang = append(hreflang, HreflangLink{
|
||||
Lang: "x-default",
|
||||
Href: localizedPageURL(pageBaseURL, defaultCompliancePortalLocale, rest),
|
||||
Href: localizedPageURL(origin, defaultCompliancePortalLocale, rest),
|
||||
})
|
||||
|
||||
return htmlLang, canonical, hreflang
|
||||
}
|
||||
|
||||
// portalOrigin returns scheme://host from pageBaseURL, dropping any path,
|
||||
// query, or fragment. Callers may pass a full request URL by mistake.
|
||||
func portalOrigin(pageBaseURL string) string {
|
||||
parsed, err := url.Parse(pageBaseURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return strings.TrimRight(pageBaseURL, "/")
|
||||
}
|
||||
|
||||
return (&url.URL{Scheme: parsed.Scheme, Host: parsed.Host}).String()
|
||||
}
|
||||
|
||||
func splitLocaleFromAppPath(appPath string) (locale, rest string) {
|
||||
segments := strings.Split(strings.Trim(appPath, "/"), "/")
|
||||
if len(segments) == 0 || segments[0] == "" {
|
||||
|
||||
@@ -82,3 +82,45 @@ func TestSEOFromRequest_EscapesPathSegments(t *testing.T) {
|
||||
)
|
||||
assert.Equal(t, "https://acme.probopage.localhost/en/docs/foo%20bar", canonical)
|
||||
}
|
||||
|
||||
func TestSEOFromRequest_StripsPathFromBaseURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodGet,
|
||||
"https://trust.acme.com/fr/documents",
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// SNI middleware once stored scheme+host+path; SEO must not double it.
|
||||
_, canonical, hreflang := complianceportal_v1.SEOFromRequest(
|
||||
req,
|
||||
"https://trust.acme.com/fr/documents",
|
||||
)
|
||||
assert.Equal(t, "https://trust.acme.com/fr/documents", canonical)
|
||||
|
||||
var enHref string
|
||||
for _, link := range hreflang {
|
||||
if link.Lang == "en" {
|
||||
enHref = link.Href
|
||||
}
|
||||
}
|
||||
assert.Equal(t, "https://trust.acme.com/en/documents", enHref)
|
||||
}
|
||||
|
||||
func TestSEOFromRequest_EmptyBaseURLOmitsLinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodGet,
|
||||
"https://trust.acme.com/fr/documents",
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
lang, canonical, hreflang := complianceportal_v1.SEOFromRequest(req, "")
|
||||
assert.Equal(t, "fr", lang)
|
||||
assert.Empty(t, canonical)
|
||||
assert.Nil(t, hreflang)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ type (
|
||||
Description string
|
||||
OGURL string
|
||||
FaviconURL string
|
||||
HtmlLang string
|
||||
HTMLLang string
|
||||
CanonicalURL string
|
||||
Hreflang []HreflangLink
|
||||
}
|
||||
|
||||