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
+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(),
};
}