feat: expose mobile localization contracts

This commit is contained in:
poyrazavsever
2026-07-19 03:07:54 +03:00
parent c2eeb0ff92
commit 178f285191
14 changed files with 692 additions and 5 deletions
+159
View File
@@ -0,0 +1,159 @@
import "server-only";
import type { getPublicLocalizationMetadata } from "@/server/i18n/runtime";
import { normalizeLocaleCode } from "@/server/i18n/locale";
import { DomainError } from "@/server/domain/errors";
export const UNSUPPORTED_LOCALE_CODE = "UNSUPPORTED_LOCALE" as const;
export type ApiLocalizationMetadata = ReturnType<typeof getPublicLocalizationMetadata>;
export type LocaleNegotiationSource =
| "query"
| "accept-language"
| "preference"
| "portal"
| "instance";
export type LocaleNegotiationInput = {
metadata: ApiLocalizationMetadata;
requestedLocale?: string | null;
acceptLanguage?: string | null;
preferredLocale?: string | null;
portalLocale?: string | null;
};
export type LocaleNegotiationResult = {
locale: string;
requestedLocale: string | null;
defaultLocale: string;
source: LocaleNegotiationSource;
fallbackChain: string[];
};
export function parseAcceptLanguage(value: string | null | undefined): string[] {
if (!value) return [];
return value
.split(",")
.map((part, index) => {
const [rawLocale, ...params] = part.trim().split(";");
const qValue = params
.map((param) => param.trim())
.find((param) => param.startsWith("q="))
?.slice(2);
const q = qValue ? Number.parseFloat(qValue) : 1;
return {
locale: normalizeLocaleCode(rawLocale),
q: Number.isFinite(q) ? q : 0,
index,
};
})
.filter((item): item is { locale: string; q: number; index: number } => Boolean(item.locale) && item.q > 0)
.sort((a, b) => b.q - a.q || a.index - b.index)
.map((item) => item.locale);
}
export function negotiateLocale(input: LocaleNegotiationInput): LocaleNegotiationResult {
const activeLocales = new Set(
input.metadata.supportedLocales
.filter((locale) => locale.status === "active")
.map((locale) => locale.code),
);
const defaultLocale = activeLocales.has(input.metadata.defaultLocale)
? input.metadata.defaultLocale
: input.metadata.supportedLocales.find((locale) => locale.status === "active")?.code ?? input.metadata.defaultLocale;
const candidates: Array<{ locale: string | null; source: LocaleNegotiationSource; strict: boolean }> = [
{ locale: normalizeLocaleCode(input.requestedLocale), source: "query", strict: true },
{ locale: normalizeLocaleCode(input.portalLocale), source: "portal", strict: false },
{ locale: normalizeLocaleCode(input.preferredLocale), source: "preference", strict: false },
...parseAcceptLanguage(input.acceptLanguage).map((locale) => ({
locale,
source: "accept-language" as const,
strict: false,
})),
{ locale: defaultLocale, source: "instance", strict: false },
];
for (const candidate of candidates) {
if (!candidate.locale) continue;
const matched = matchSupportedLocale(candidate.locale, activeLocales);
if (matched) {
return {
locale: matched,
requestedLocale: candidate.locale,
defaultLocale,
source: candidate.source,
fallbackChain: buildFallbackChain(matched, input.metadata, defaultLocale),
};
}
if (candidate.strict) {
throw new DomainError(
UNSUPPORTED_LOCALE_CODE,
"Unsupported locale.",
{
messageKey: "validation.unsupportedLocale",
requestedLocale: candidate.locale,
supportedLocales: [...activeLocales],
},
);
}
}
return {
locale: defaultLocale,
requestedLocale: null,
defaultLocale,
source: "instance",
fallbackChain: buildFallbackChain(defaultLocale, input.metadata, defaultLocale),
};
}
export function buildLocalizationContract(metadata: ApiLocalizationMetadata) {
return {
...metadata,
negotiator: {
queryParam: "locale",
header: "Accept-Language",
unsupportedLocaleCode: UNSUPPORTED_LOCALE_CODE,
matching: "exact-or-base-language",
},
responseContract: {
localizedResourceField: "localized",
translationsField: "translations",
fallbackChainField: "fallbackChain",
},
};
}
function matchSupportedLocale(locale: string, activeLocales: Set<string>): string | null {
if (activeLocales.has(locale)) return locale;
const base = locale.split("-")[0];
if (base && activeLocales.has(base)) return base;
return null;
}
function buildFallbackChain(
locale: string,
metadata: ApiLocalizationMetadata,
defaultLocale: string,
): string[] {
const chain = [locale];
const seen = new Set(chain);
let cursor = metadata.fallbacks[locale] ?? null;
while (cursor && !seen.has(cursor)) {
chain.push(cursor);
seen.add(cursor);
cursor = metadata.fallbacks[cursor] ?? null;
}
if (!seen.has(defaultLocale)) {
chain.push(defaultLocale);
}
return chain;
}