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>
This commit is contained in:
Émile Ré
2026-07-21 18:50:34 +02:00
parent e47091e5b5
commit 59f053e8f9
27 changed files with 89 additions and 20 deletions

View File

@@ -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" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -98,9 +98,9 @@
"label": "언어",
"updateFailed": "언어를 업데이트하지 못했습니다",
"mismatch": {
"message": "{{language}}(으)로 이 페이지를 보고 있습니다.",
"switchToMine": "{{language}}(으)로 전환",
"useThis": "{{language}}을(를) 내 언어로 사용",
"message": "이 페이지의 표시 언어는 {{language}}입니다.",
"switchToMine": "내 언어({{language}})로 전환",
"useThis": "표시 언어({{language}}) 내 언어로 사용",
"dismiss": "닫기"
}
}

View File

@@ -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(

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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] == "" {

View File

@@ -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)
}

View File

@@ -37,7 +37,7 @@ type (
Description string
OGURL string
FaviconURL string
HtmlLang string
HTMLLang string
CanonicalURL string
Hreflang []HreflangLink
}