feat: expose mobile localization contracts
This commit is contained in:
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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.");
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user