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
+1 -1
View File
@@ -19,7 +19,7 @@ export function GET() {
discoveryVersion: 1,
error: {
code: "SERVICE_UNAVAILABLE",
message: "Instance keşif bilgisi geçici olarak kullanılamıyor.",
message: "Instance discovery is temporarily unavailable.",
},
},
{
+32 -1
View File
@@ -1,9 +1,14 @@
import { apiV1Error, apiV1Success } from "@/server/api/v1/responses";
import { negotiateLocale } from "@/server/api/v1/localization";
import { domainActorFromSession } from "@/server/auth/domain-actor";
import { getSessionContextFromHeaders } from "@/server/auth/session";
import { getServerConfig } from "@/server/config";
import { getSqliteConnection } from "@/server/db/client";
import { clients } from "@/server/db/schema";
import { DomainError } from "@/server/domain/errors";
import { getPublicLocalizationMetadata } from "@/server/i18n/runtime";
import { getUserPreferences } from "@/server/settings/preferences";
import { eq } from "drizzle-orm";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -12,9 +17,26 @@ export async function GET(request: Request) {
try {
const context = await getSessionContextFromHeaders(new Headers(request.headers));
if (!context) {
throw new DomainError("UNAUTHENTICATED", "Geçerli bir oturum gerekli.");
throw new DomainError("UNAUTHENTICATED", "Authentication required.", {
messageKey: "api.errors.unauthenticated",
});
}
const requestUrl = new URL(request.url);
const preferences = getUserPreferences(domainActorFromSession(context));
const portalLocale = context.profile.clientId
? getSqliteConnection().db
.select({ portalLocale: clients.portalLocale })
.from(clients)
.where(eq(clients.id, context.profile.clientId))
.get()?.portalLocale ?? null
: null;
const resolvedLocale = negotiateLocale({
metadata: getPublicLocalizationMetadata(),
requestedLocale: requestUrl.searchParams.get("locale"),
acceptLanguage: request.headers.get("accept-language"),
preferredLocale: preferences.language,
portalLocale,
});
return apiV1Success({
user: {
@@ -29,6 +51,15 @@ export async function GET(request: Request) {
expiresAt: context.session.expiresAt.toISOString(),
},
preferences,
localization: {
language: preferences.language,
portalLocale,
resolvedLocale: resolvedLocale.locale,
requestedLocale: resolvedLocale.requestedLocale,
defaultLocale: resolvedLocale.defaultLocale,
source: resolvedLocale.source,
fallbackChain: resolvedLocale.fallbackChain,
},
});
} catch (error) {
return apiV1Error(error);
@@ -0,0 +1,24 @@
{
"scenario": "English mobile client receives resolved user locale from /me",
"request": {
"path": "/api/v1/me?locale=en",
"headers": {
"Accept-Language": "en-US,en;q=0.9,tr;q=0.7",
"Cookie": "better-auth.session_token=<redacted>"
}
},
"expected": {
"ok": true,
"data": {
"localization": {
"language": "en",
"portalLocale": null,
"resolvedLocale": "en",
"requestedLocale": "en",
"defaultLocale": "tr",
"source": "query",
"fallbackChain": ["en", "tr"]
}
}
}
}
@@ -0,0 +1,19 @@
{
"scenario": "French is requested before the self-host instance activates it",
"request": {
"path": "/api/v1/me?locale=fr",
"headers": {
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8,tr;q=0.7",
"Cookie": "better-auth.session_token=<redacted>"
}
},
"expected": {
"ok": false,
"error": {
"code": "UNSUPPORTED_LOCALE",
"details": {
"requestedLocale": "fr"
}
}
}
}
@@ -0,0 +1,31 @@
{
"scenario": "Turkish mobile client discovers localization support",
"request": {
"path": "/api/v1/meta",
"headers": {
"Accept-Language": "tr-TR,tr;q=0.9,en;q=0.7"
}
},
"expected": {
"ok": true,
"data": {
"capabilities": [
{
"id": "instance.localization",
"version": 1,
"status": "available",
"access": "public"
}
],
"localization": {
"defaultLocale": "tr",
"negotiator": {
"queryParam": "locale",
"header": "Accept-Language",
"unsupportedLocaleCode": "UNSUPPORTED_LOCALE",
"matching": "exact-or-base-language"
}
}
}
}
}
+137
View File
@@ -0,0 +1,137 @@
---
title: I18n Faz 8 — API v1 ve mobil hazırlık
status: completed
completed_at: 2026-07-19
---
# I18n Faz 8 — API v1 ve mobil hazırlık
Bu fazda self-host instance içindeki dil modeli, gelecekteki React Native
istemcilerinin güvenli şekilde keşfedebileceği bir API v1 sözleşmesine
taşındı. Değişiklikler geriye uyumludur; mevcut v1 response alanları
değiştirilmedi, sadece additive alanlar eklendi.
## Tamamlananlar
- `instance.localization` capability kaydı eklendi.
- `/.well-known/neta` discovery document içine additive locale özeti eklendi.
- `/api/v1/meta` içinde localization contract genişletildi:
- `supportedLocales`
- `fallbacks`
- `catalogVersion`
- `negotiator`
- `responseContract`
- `/api/v1/me` response'u kullanıcının dil bilgisini ayrı döner:
- `language`
- `portalLocale`
- `resolvedLocale`
- `requestedLocale`
- `source`
- `fallbackChain`
- `Accept-Language` parser ve locale negotiation helper'ı eklendi.
- Gelecek resource endpoint'leri için localized response contract'ı yazıldı.
- Owner mutation contract'ında `translations` shape'i standartlaştırıldı:
`Record<locale, Record<field, string | null>>`.
- `UNSUPPORTED_LOCALE` hata kodu API response mapping'e eklendi.
- API hata response'larında client localization için `messageKey` kullanımı
netleştirildi.
- `tr`, `en`, `fr` için contract fixture'ları eklendi.
- `i18n:phase8-smoke` mobile localization negotiation ve contract shape'lerini
runtime olarak doğrulayacak şekilde eklendi.
## Mobil istemci davranışı
Mobil istemci ilk açılışta `/.well-known/neta` endpoint'ine gider ve
`instance.localization` capability'sini kontrol eder. Capability bilinmiyorsa
istemci bunu fatal hata olarak ele almamalı; capability listesi additive olduğu
için unknown capability değerleri yok sayılmalıdır.
Dil seçimi için önerilen sıra:
1. Kullanıcının explicit seçimi varsa `/api/v1/me?locale=xx` ile gönder.
2. Explicit seçim yoksa `Accept-Language` header'ını gönder.
3. Server `/api/v1/me.data.localization.resolvedLocale` değerini gerçek kaynak
kabul et.
`locale` query param'ı aktif olmayan bir locale'e işaret ederse API
`UNSUPPORTED_LOCALE` döner. `Accept-Language` içinde desteklenmeyen değer varsa
server sessizce instance default locale'e düşebilir.
Hata response'larında kullanıcıya gösterilecek metin mobile client tarafından
locale'e göre çözülmelidir. Server bu amaçla `error.details.messageKey`
alanını döndürür:
```json
{
"ok": false,
"error": {
"code": "UNSUPPORTED_LOCALE",
"message": "Unsupported locale.",
"details": {
"messageKey": "validation.unsupportedLocale",
"requestedLocale": "fr"
}
}
}
```
## Resource response contract
Gelecek `/api/v1/projects`, `/api/v1/tasks`, `/api/v1/clients` gibi resource
endpoint'leri şu shape'i kullanmalı:
```ts
{
resource: TResource;
localized: TResource;
locale: string;
fallbackChain: string[];
}
```
Bu contract sayesinde mobil taraf original kaydı ve locale çözülmüş kaydı aynı
anda taşıyabilir.
## Mutation translations contract
Owner/freelancer mutation endpoint'leri çok dilli alanları şu shape ile kabul
etmeli:
```ts
{
translations: {
tr: { name: "Marka sitesi", description: "..." },
en: { name: "Brand website", description: "..." },
fr: { name: "Site de marque", description: "..." }
}
}
```
Server sadece authorized owner mutation'larında bu alanı kabul eder. Müşteri
portal oturumu `translations` mutation contract'ını kullanamaz; portal locale
yalnızca kendi okuma response'unu etkiler.
## Cache ve URL notları
- `/.well-known/neta` ve `/api/v1/meta` public cache kullanır.
- Metadata içindeki URL'ler `APP_URL` üzerinden absolute üretilir.
- Locale catalog değişikliklerinde `catalogVersion` artacağı için mobil istemci
meta cache'ini güvenli şekilde invalidate edebilir.
## Fixture'lar
- `docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-tr.json`
- `docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-en.json`
- `docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-fr.json`
## Verification
Çalıştırılan komutlar:
```bash
pnpm i18n:phase8-smoke
pnpm typecheck
pnpm build
pnpm phase9:smoke
git diff --check
```
@@ -85,17 +85,23 @@ Hata:
"ok": false,
"error": {
"code": "UNAUTHENTICATED",
"message": "Geçerli bir oturum gerekli.",
"details": {}
"message": "Authentication required.",
"details": {
"messageKey": "api.errors.unauthenticated"
}
}
}
```
`details` opsiyoneldir. `/api/v1` yanıtları `X-Neta-API-Version: 1` header'ı taşır. Mobil istemci kullanıcıya göstereceği metni `message` alanından alabilir; program akışını yalnızca stabil `code` üzerinden kurmalıdır.
`details` opsiyoneldir. `/api/v1` yanıtları `X-Neta-API-Version: 1` header'ı taşır.
Mobil istemci program akışını yalnızca stabil `code` üzerinden kurmalı, kullanıcıya
göstereceği metni mümkünse `details.messageKey` ile kendi catalog'undan çözmelidir.
`message` alanı debug/fallback içindir ve lokalizasyon kaynağı kabul edilmemelidir.
Mevcut hata kodları:
- `VALIDATION_ERROR`
- `UNSUPPORTED_LOCALE`
- `UNAUTHENTICATED`
- `FORBIDDEN`
- `NOT_FOUND`
+31
View File
@@ -0,0 +1,31 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const distDir = path.join(process.cwd(), ".next", "i18n-phase8-smoke-dist");
execFileSync(process.execPath, ["scripts/phase9-api-boundary.mjs"], {
cwd: process.cwd(),
stdio: "inherit",
});
execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.i18n-phase8-smoke.json"], {
cwd: process.cwd(),
stdio: "inherit",
});
const serverOnlyStubDir = path.join(distDir, "node_modules", "server-only");
fs.mkdirSync(serverOnlyStubDir, { recursive: true });
fs.writeFileSync(path.join(serverOnlyStubDir, "index.js"), "\n");
const aliasScopeDir = path.join(distDir, "node_modules", "@");
fs.mkdirSync(aliasScopeDir, { recursive: true });
const serverAlias = path.join(aliasScopeDir, "server");
if (!fs.existsSync(serverAlias)) {
fs.symlinkSync(path.join(distDir, "server"), serverAlias, "dir");
}
execFileSync(process.execPath, [path.join(distDir, "scripts", "i18n-phase8-smoke.js")], {
cwd: process.cwd(),
stdio: "inherit",
});
+110
View File
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import {
buildLocalizationContract,
negotiateLocale,
parseAcceptLanguage,
UNSUPPORTED_LOCALE_CODE,
type ApiLocalizationMetadata,
} from "../server/api/v1/localization";
import {
NETA_CAPABILITIES,
type NetaLocalizedResponse,
type NetaTranslationMutationShape,
} from "../server/api/v1/contracts";
import { DomainError } from "../server/domain/errors";
const metadata: ApiLocalizationMetadata = {
defaultLocale: "tr",
supportedLocales: [
{
code: "tr",
name: "Turkish",
nativeName: "Türkçe",
status: "active",
fallbackLocale: null,
textDirection: "ltr",
builtIn: true,
},
{
code: "en",
name: "English",
nativeName: "English",
status: "active",
fallbackLocale: "tr",
textDirection: "ltr",
builtIn: true,
},
{
code: "fr",
name: "French",
nativeName: "Français",
status: "draft",
fallbackLocale: "en",
textDirection: "ltr",
builtIn: false,
},
],
fallbacks: {
en: "tr",
fr: "en",
},
catalogVersion: 7,
};
assert.deepEqual(parseAcceptLanguage("fr-FR, en-US;q=0.9, tr;q=0.7"), ["fr-FR", "en-US", "tr"]);
assert.deepEqual(parseAcceptLanguage("en;q=0.4, tr;q=0.9"), ["tr", "en"]);
const queryLocale = negotiateLocale({
metadata,
requestedLocale: "en",
acceptLanguage: "tr;q=0.9",
});
assert.equal(queryLocale.locale, "en");
assert.equal(queryLocale.source, "query");
assert.deepEqual(queryLocale.fallbackChain, ["en", "tr"]);
const baseLanguageMatch = negotiateLocale({
metadata,
acceptLanguage: "en-US,en;q=0.9",
});
assert.equal(baseLanguageMatch.locale, "en");
assert.equal(baseLanguageMatch.requestedLocale, "en-US");
assert.equal(baseLanguageMatch.source, "accept-language");
assert.throws(
() => negotiateLocale({ metadata, requestedLocale: "fr" }),
(error) =>
error instanceof DomainError &&
error.code === UNSUPPORTED_LOCALE_CODE &&
error.message === "Unsupported locale." &&
error.details?.messageKey === "validation.unsupportedLocale" &&
error.details?.requestedLocale === "fr",
);
const contract = buildLocalizationContract(metadata);
assert.equal(contract.negotiator.queryParam, "locale");
assert.equal(contract.negotiator.header, "Accept-Language");
assert.equal(contract.negotiator.unsupportedLocaleCode, UNSUPPORTED_LOCALE_CODE);
assert.equal(contract.responseContract.localizedResourceField, "localized");
assert.equal(contract.responseContract.translationsField, "translations");
assert.equal(
NETA_CAPABILITIES.some((capability) => capability.id === "instance.localization" && capability.status === "available"),
true,
);
const localizedResponse: NetaLocalizedResponse<{ title: string }> = {
resource: { title: "Marka sitesi" },
localized: { title: "Brand website" },
locale: "en",
fallbackChain: ["en", "tr"],
};
assert.equal(localizedResponse.localized.title, "Brand website");
const mutationShape: NetaTranslationMutationShape = {
tr: { title: "Marka sitesi" },
en: { title: "Brand website", description: null },
};
assert.equal(mutationShape.en.title, "Brand website");
console.log("I18n phase 8 mobile API localization smoke passed.");
+50
View File
@@ -18,16 +18,32 @@ for (const value of [
'NETA_PROTOCOL = "neta"',
"NETA_DISCOVERY_VERSION = 1",
'NETA_API_VERSION = "1"',
'"instance.localization"',
'"auth.device-pairing"',
'status: "planned"',
"minimumSupportedVersion",
"workspaceName",
"metaTitle",
"faviconUrl",
"NetaLocalizedResponse",
"NetaTranslationMutationShape",
"ownerMutationTranslations",
"absoluteUrl",
]) {
assert.ok(contracts.includes(value), `Missing API contract marker: ${value}`);
}
const localization = read("server/api/v1/localization.ts");
for (const value of [
"parseAcceptLanguage",
"negotiateLocale",
"UNSUPPORTED_LOCALE",
"Accept-Language",
"exact-or-base-language",
]) {
assert.ok(localization.includes(value), `Missing localization contract marker: ${value}`);
}
const instanceService = read("server/instance/service.ts");
assert.doesNotMatch(
instanceService,
@@ -46,6 +62,39 @@ assert.match(
/getUserPreferences/,
"Authenticated mobile metadata must expose the persisted user color mode",
);
assert.match(
read("app/api/v1/me/route.ts"),
/resolvedLocale/,
"Authenticated mobile metadata must expose resolved localization state",
);
assert.match(
read("app/api/v1/me/route.ts"),
/portalLocale/,
"Authenticated mobile metadata must expose client portal locale when available",
);
assert.match(
read("app/api/v1/meta/route.ts"),
/stale-while-revalidate=300/,
"Meta endpoint must keep a public revalidation cache contract",
);
const domainErrors = read("server/domain/errors.ts");
assert.match(
domainErrors,
/UNSUPPORTED_LOCALE/,
"Unsupported locale must have a stable API error code",
);
for (const fixture of [
"docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-tr.json",
"docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-en.json",
"docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-fr.json",
]) {
const parsed = JSON.parse(read(fixture));
assert.ok(parsed.request, `Missing request fixture in ${fixture}`);
assert.ok(parsed.expected, `Missing expected fixture in ${fixture}`);
}
for (const route of requiredRoutes.slice(1)) {
const content = read(route);
@@ -66,6 +115,7 @@ for (const futureRoute of [
const runtimeFiles = [
...requiredRoutes,
"server/api/v1/contracts.ts",
"server/api/v1/localization.ts",
"server/api/v1/responses.ts",
"server/api/v1/runtime.ts",
"server/instance/service.ts",
+68
View File
@@ -1,5 +1,7 @@
import type { PublicBranding } from "../../branding/service";
import type { InstanceIdentity } from "../../instance/service";
import type { getPublicLocalizationMetadata } from "../../i18n/runtime";
import { buildLocalizationContract } from "./localization";
export const NETA_PROTOCOL = "neta" as const;
export const NETA_DISCOVERY_VERSION = 1 as const;
@@ -16,9 +18,22 @@ export type NetaCapability = {
access: CapabilityAccess;
};
export type NetaLocalizedResponse<TResource> = {
resource: TResource;
localized: TResource;
locale: string;
fallbackChain: string[];
};
export type NetaTranslationMutationShape = Record<
string,
Record<string, string | null>
>;
export const NETA_CAPABILITIES = [
{ id: "instance.discovery", version: 1, status: "available", access: "public" },
{ id: "instance.branding", version: 1, status: "available", access: "public" },
{ id: "instance.localization", version: 1, status: "available", access: "public" },
{ id: "auth.better-auth-cookie", version: 1, status: "available", access: "session" },
{ id: "files.local", version: 1, status: "available", access: "session" },
{ id: "freelancer.core", version: 1, status: "available", access: "freelancer" },
@@ -43,6 +58,18 @@ export type NetaDiscoveryDocument = {
httpsRequired: true;
insecureLoopbackAllowed: true;
};
localization: {
defaultLocale: string;
supportedLocales: Array<{
code: string;
name: string;
nativeName: string;
status: string;
textDirection: string;
}>;
catalogVersion: number;
};
capabilities: readonly NetaCapability[];
};
export type NetaInstanceMetadata = {
@@ -73,6 +100,20 @@ export type NetaInstanceMetadata = {
iconUrl: string | null;
faviconUrl: string | null;
};
localization: ReturnType<typeof buildLocalizationContract>;
contracts: {
localizedResponse: {
resource: "original database record";
localized: "locale-resolved record";
locale: "resolved locale code";
fallbackChain: "ordered locale fallback chain";
};
ownerMutationTranslations: {
field: "translations";
shape: "Record<locale, Record<field, string | null>>";
unsupportedLocaleCode: "UNSUPPORTED_LOCALE";
};
};
client: {
minimumSupportedVersion: string | null;
platforms: readonly ["ios", "android"];
@@ -96,6 +137,7 @@ type ContractInput = {
minimumMobileClientVersion: string | null;
identity: InstanceIdentity;
branding: PublicBranding;
localization: ReturnType<typeof getPublicLocalizationMetadata>;
};
export function buildDiscoveryDocument(
@@ -118,6 +160,18 @@ export function buildDiscoveryDocument(
httpsRequired: true,
insecureLoopbackAllowed: true,
},
localization: {
defaultLocale: input.localization.defaultLocale,
supportedLocales: input.localization.supportedLocales.map((locale) => ({
code: locale.code,
name: locale.name,
nativeName: locale.nativeName,
status: locale.status,
textDirection: locale.textDirection,
})),
catalogVersion: input.localization.catalogVersion,
},
capabilities: NETA_CAPABILITIES,
};
}
@@ -152,6 +206,20 @@ export function buildInstanceMetadata(
iconUrl: absoluteOptionalUrl(input.appUrl, input.branding.iconUrl),
faviconUrl: absoluteOptionalUrl(input.appUrl, input.branding.iconUrl),
},
localization: buildLocalizationContract(input.localization),
contracts: {
localizedResponse: {
resource: "original database record",
localized: "locale-resolved record",
locale: "resolved locale code",
fallbackChain: "ordered locale fallback chain",
},
ownerMutationTranslations: {
field: "translations",
shape: "Record<locale, Record<field, string | null>>",
unsupportedLocaleCode: "UNSUPPORTED_LOCALE",
},
},
client: {
minimumSupportedVersion: input.minimumMobileClientVersion,
platforms: ["ios", "android"],
+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;
}
+2
View File
@@ -3,6 +3,7 @@ import "server-only";
import packageJson from "../../../package.json";
import { getPublicBranding } from "../../branding/runtime";
import { getServerConfig } from "../../config";
import { getPublicLocalizationMetadata } from "../../i18n/runtime";
import { getInstanceService } from "../../instance/runtime";
import {
buildDiscoveryDocument,
@@ -25,5 +26,6 @@ function getContractInput() {
minimumMobileClientVersion: config.minimumMobileClientVersion,
identity: getInstanceService().getIdentity(),
branding: getPublicBranding(),
localization: getPublicLocalizationMetadata(),
};
}
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": ".next/i18n-phase8-smoke-dist",
"rootDir": ".",
"noEmit": false,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"types": ["node"]
},
"include": [
"server/api/v1/**/*.ts",
"server/domain/errors.ts",
"server/i18n/locale.ts",
"lib/i18n/types.ts",
"scripts/i18n-phase8-smoke.ts"
]
}