package i18n import ( "context" "strconv" "strings" ) // Default is the fallback UI/API locale when Accept-Language is missing or unsupported. const Default = "en" // Supported UI/API locales for public error/validation copy. // Keep aligned with apps/web/src/lib/i18n/locales.ts (UI_LOCALES). var Supported = []string{"en", "es", "fr", "de", "it", "pt", "nl", "pl", "ja"} var supportedSet map[string]struct{} func init() { supportedSet = make(map[string]struct{}, len(Supported)) for _, code := range Supported { supportedSet[code] = struct{}{} } } type ctxKey struct{} // WithLocale stores a resolved locale on ctx. func WithLocale(ctx context.Context, locale string) context.Context { return context.WithValue(ctx, ctxKey{}, Normalize(locale)) } // FromContext returns the locale stored by middleware, or Default. func FromContext(ctx context.Context) string { if ctx == nil { return Default } if v, ok := ctx.Value(ctxKey{}).(string); ok && v != "" { return v } return Default } // Normalize lowercases/trims and maps to a supported primary language tag, or Default. func Normalize(raw string) string { code := strings.ToLower(strings.TrimSpace(raw)) if code == "" || code == "*" { return Default } if i := strings.IndexByte(code, '-'); i > 0 { code = code[:i] } if i := strings.IndexByte(code, '_'); i > 0 { code = code[:i] } if _, ok := supportedSet[code]; ok { return code } return Default } // IsSupported reports whether the primary language tag is in Supported. func IsSupported(raw string) bool { code := strings.ToLower(strings.TrimSpace(raw)) if code == "" { return false } if i := strings.IndexByte(code, '-'); i > 0 { code = code[:i] } if i := strings.IndexByte(code, '_'); i > 0 { code = code[:i] } _, ok := supportedSet[code] return ok } // Resolve picks the best supported locale from an Accept-Language header value. // Quality values are respected; unsupported tags are skipped; empty → Default. func Resolve(acceptLanguage string) string { header := strings.TrimSpace(acceptLanguage) if header == "" { return Default } bestTag := "" bestQ := -1.0 for _, part := range strings.Split(header, ",") { part = strings.TrimSpace(part) if part == "" { continue } tag := part q := 1.0 if i := strings.IndexByte(part, ';'); i >= 0 { tag = strings.TrimSpace(part[:i]) for _, p := range strings.Split(part[i+1:], ";") { p = strings.TrimSpace(p) if len(p) >= 2 && (p[0] == 'q' || p[0] == 'Q') && p[1] == '=' { if parsed, err := strconv.ParseFloat(strings.TrimSpace(p[2:]), 64); err == nil { q = parsed } } } } primary := strings.ToLower(strings.TrimSpace(tag)) if primary == "*" { if q > bestQ { bestQ = q bestTag = Default } continue } if !IsSupported(primary) { continue } norm := Normalize(primary) if q > bestQ { bestQ = q bestTag = norm } } if bestTag == "" { return Default } return bestTag }