From 178f285191b245bc75194dced3fc02a25e3e00a3 Mon Sep 17 00:00:00 2001 From: poyrazavsever Date: Sun, 19 Jul 2026 03:07:54 +0300 Subject: [PATCH] feat: expose mobile localization contracts --- app/.well-known/neta/route.ts | 2 +- app/api/v1/me/route.ts | 33 +++- .../api-v1-locale-en.json | 24 +++ .../api-v1-locale-fr.json | 19 +++ .../api-v1-locale-tr.json | 31 ++++ docs/self-hosted-redesign/i18n-phase-8.md | 137 +++++++++++++++ .../phase-9-mobile-api.md | 12 +- scripts/i18n-phase8-smoke.mjs | 31 ++++ scripts/i18n-phase8-smoke.ts | 110 ++++++++++++ scripts/phase9-api-boundary.mjs | 50 ++++++ server/api/v1/contracts.ts | 68 ++++++++ server/api/v1/localization.ts | 159 ++++++++++++++++++ server/api/v1/runtime.ts | 2 + tsconfig.i18n-phase8-smoke.json | 19 +++ 14 files changed, 692 insertions(+), 5 deletions(-) create mode 100644 docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-en.json create mode 100644 docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-fr.json create mode 100644 docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-tr.json create mode 100644 docs/self-hosted-redesign/i18n-phase-8.md create mode 100644 scripts/i18n-phase8-smoke.mjs create mode 100644 scripts/i18n-phase8-smoke.ts create mode 100644 server/api/v1/localization.ts create mode 100644 tsconfig.i18n-phase8-smoke.json diff --git a/app/.well-known/neta/route.ts b/app/.well-known/neta/route.ts index 716f6ef..7b3cb56 100644 --- a/app/.well-known/neta/route.ts +++ b/app/.well-known/neta/route.ts @@ -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.", }, }, { diff --git a/app/api/v1/me/route.ts b/app/api/v1/me/route.ts index 8646924..91a18cf 100644 --- a/app/api/v1/me/route.ts +++ b/app/api/v1/me/route.ts @@ -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); diff --git a/docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-en.json b/docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-en.json new file mode 100644 index 0000000..2c2780a --- /dev/null +++ b/docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-en.json @@ -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=" + } + }, + "expected": { + "ok": true, + "data": { + "localization": { + "language": "en", + "portalLocale": null, + "resolvedLocale": "en", + "requestedLocale": "en", + "defaultLocale": "tr", + "source": "query", + "fallbackChain": ["en", "tr"] + } + } + } +} diff --git a/docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-fr.json b/docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-fr.json new file mode 100644 index 0000000..7dc21c1 --- /dev/null +++ b/docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-fr.json @@ -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=" + } + }, + "expected": { + "ok": false, + "error": { + "code": "UNSUPPORTED_LOCALE", + "details": { + "requestedLocale": "fr" + } + } + } +} diff --git a/docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-tr.json b/docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-tr.json new file mode 100644 index 0000000..3d8583b --- /dev/null +++ b/docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-tr.json @@ -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" + } + } + } + } +} diff --git a/docs/self-hosted-redesign/i18n-phase-8.md b/docs/self-hosted-redesign/i18n-phase-8.md new file mode 100644 index 0000000..485e7b3 --- /dev/null +++ b/docs/self-hosted-redesign/i18n-phase-8.md @@ -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>`. +- `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 +``` diff --git a/docs/self-hosted-redesign/phase-9-mobile-api.md b/docs/self-hosted-redesign/phase-9-mobile-api.md index 3e10116..0c05179 100644 --- a/docs/self-hosted-redesign/phase-9-mobile-api.md +++ b/docs/self-hosted-redesign/phase-9-mobile-api.md @@ -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` diff --git a/scripts/i18n-phase8-smoke.mjs b/scripts/i18n-phase8-smoke.mjs new file mode 100644 index 0000000..f0ffbcc --- /dev/null +++ b/scripts/i18n-phase8-smoke.mjs @@ -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", +}); diff --git a/scripts/i18n-phase8-smoke.ts b/scripts/i18n-phase8-smoke.ts new file mode 100644 index 0000000..a4ae67d --- /dev/null +++ b/scripts/i18n-phase8-smoke.ts @@ -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."); diff --git a/scripts/phase9-api-boundary.mjs b/scripts/phase9-api-boundary.mjs index 85f85d7..3aea14f 100644 --- a/scripts/phase9-api-boundary.mjs +++ b/scripts/phase9-api-boundary.mjs @@ -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", diff --git a/server/api/v1/contracts.ts b/server/api/v1/contracts.ts index 084c52f..ddeab8a 100644 --- a/server/api/v1/contracts.ts +++ b/server/api/v1/contracts.ts @@ -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 = { + resource: TResource; + localized: TResource; + locale: string; + fallbackChain: string[]; +}; + +export type NetaTranslationMutationShape = Record< + string, + Record +>; + 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; + contracts: { + localizedResponse: { + resource: "original database record"; + localized: "locale-resolved record"; + locale: "resolved locale code"; + fallbackChain: "ordered locale fallback chain"; + }; + ownerMutationTranslations: { + field: "translations"; + shape: "Record>"; + 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; }; 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>", + unsupportedLocaleCode: "UNSUPPORTED_LOCALE", + }, + }, client: { minimumSupportedVersion: input.minimumMobileClientVersion, platforms: ["ios", "android"], diff --git a/server/api/v1/localization.ts b/server/api/v1/localization.ts new file mode 100644 index 0000000..f8f8e3c --- /dev/null +++ b/server/api/v1/localization.ts @@ -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; + +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 | 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; +} diff --git a/server/api/v1/runtime.ts b/server/api/v1/runtime.ts index 19ea4e3..29cf26d 100644 --- a/server/api/v1/runtime.ts +++ b/server/api/v1/runtime.ts @@ -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(), }; } diff --git a/tsconfig.i18n-phase8-smoke.json b/tsconfig.i18n-phase8-smoke.json new file mode 100644 index 0000000..35adf96 --- /dev/null +++ b/tsconfig.i18n-phase8-smoke.json @@ -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" + ] +}