feat(i18n): implement new locale resolution logic and service layer

This commit is contained in:
poyrazavsever
2026-07-20 10:25:40 +03:00
parent 9365dfd215
commit 7805a9a19a
9 changed files with 384 additions and 118 deletions
+6 -20
View File
@@ -1,28 +1,14 @@
import type { TextDirection } from "../../lib/i18n";
import {
directionForLocale as inferDirectionForLocale,
normalizeLocaleCode,
} from "../../lib/i18n/locale-resolution";
export const LOCALE_COOKIE = "neta_locale";
export const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
export const DEFAULT_TEXT_DIRECTION: TextDirection = "ltr";
export function normalizeLocaleCode(value: string | null | undefined): string | null {
if (!value) return null;
const normalized = value.trim();
return /^[a-z]{2}(?:-[A-Z]{2}[0-9]?)?$/.test(normalized) ? normalized : null;
}
export { normalizeLocaleCode };
export function directionForLocale(locale: string, explicit?: TextDirection | null): TextDirection {
if (explicit) return explicit;
return /^(ar|fa|he|ur)(-|$)/.test(locale) ? "rtl" : DEFAULT_TEXT_DIRECTION;
}
export function buildLocaleCookie(value: string) {
return {
name: LOCALE_COOKIE,
value,
maxAge: LOCALE_COOKIE_MAX_AGE,
sameSite: "lax" as const,
path: "/",
httpOnly: false,
secure: process.env.NODE_ENV === "production",
};
return inferDirectionForLocale(locale) ?? DEFAULT_TEXT_DIRECTION;
}
+105 -69
View File
@@ -1,82 +1,118 @@
import { cookies, headers } from "next/headers";
import { cache } from "react";
import { DEFAULT_LOCALE } from "../../lib/i18n";
import { getSessionContextFromHeaders } from "../auth/session";
import { getSqliteConnection } from "../db/client";
import { clients, instanceI18nSettings, instanceLocales, userPreferences } from "../db/schema";
import "server-only";
import { eq } from "drizzle-orm";
import { directionForLocale, LOCALE_COOKIE, normalizeLocaleCode } from "./locale";
import { cache } from "react";
import {
resolveLocalePolicy,
type LocaleResolution,
} from "../../lib/i18n/locale-resolution";
import type { SessionContext } from "../auth/session";
import { getSessionContext } from "../auth/session";
import { getSqliteConnection } from "../db/client";
import {
clients,
instanceI18nSettings,
instanceLocales,
userPreferences,
} from "../db/schema";
export type ResolvedLocale = {
locale: string;
requestedLocale: string | null;
defaultLocale: string;
direction: "ltr" | "rtl";
source: "user" | "client" | "cookie" | "instance" | "fallback";
};
export type ResolvedLocale = LocaleResolution;
export const resolveRequestLocale = cache(async (): Promise<ResolvedLocale> => {
const requestHeaders = await headers();
const cookieStore = await cookies();
const context = await getSessionContextFromHeaders(requestHeaders);
type LocaleContext = ReturnType<typeof readLocaleContext>;
export const resolvePublicLocale = cache(async (): Promise<ResolvedLocale> => {
return resolveFromContext(readLocaleContext(), []);
});
export async function resolveInvitationLocale(
invitationLocale: string | null | undefined,
): Promise<ResolvedLocale> {
return resolveFromContext(readLocaleContext(), [
{ locale: invitationLocale, source: "invitation" },
]);
}
export async function resolveFreelancerLocale(
providedContext?: SessionContext,
): Promise<ResolvedLocale> {
const context = providedContext ?? await getSessionContext();
const localeContext = readLocaleContext();
const preference = context
? readUserPreference(localeContext, context.profile.authUserId)
: null;
return resolveFromContext(localeContext, [
{ locale: preference, source: "user" },
]);
}
export async function resolvePortalLocale(
providedContext?: SessionContext,
): Promise<ResolvedLocale> {
const context = providedContext ?? await getSessionContext();
const localeContext = readLocaleContext();
const preference = context
? readUserPreference(localeContext, context.profile.authUserId)
: null;
const clientDefault = context?.profile.clientId
? localeContext.db
.select({ portalLocale: clients.portalLocale })
.from(clients)
.where(eq(clients.id, context.profile.clientId))
.get()?.portalLocale
: null;
return resolveFromContext(localeContext, [
{ locale: preference, source: "user" },
{ locale: clientDefault, source: "client" },
]);
}
export const resolveRootLocale = cache(async (): Promise<ResolvedLocale> => {
const context = await getSessionContext();
if (!context) return resolvePublicLocale();
if (context.profile.role === "client") return resolvePortalLocale(context);
return resolveFreelancerLocale(context);
});
function readLocaleContext() {
const { db } = getSqliteConnection();
const settings = db.select().from(instanceI18nSettings).where(eq(instanceI18nSettings.key, "default")).get();
const defaultLocale = settings?.defaultLocale ?? DEFAULT_LOCALE;
const cookieLocale = normalizeLocaleCode(cookieStore.get(LOCALE_COOKIE)?.value);
let requestedLocale: string | null = null;
let source: ResolvedLocale["source"] = "fallback";
if (context?.profile.role === "client" && context.profile.clientId) {
requestedLocale = normalizeLocaleCode(
db
.select({ portalLocale: clients.portalLocale })
.from(clients)
.where(eq(clients.id, context.profile.clientId))
.get()?.portalLocale,
);
source = requestedLocale ? "client" : source;
}
if (!requestedLocale && context) {
requestedLocale = normalizeLocaleCode(
db
.select({ language: userPreferences.language })
.from(userPreferences)
.where(eq(userPreferences.ownerUserId, context.profile.authUserId))
.get()?.language,
);
source = requestedLocale ? "user" : source;
}
if (!requestedLocale && cookieLocale) {
requestedLocale = cookieLocale;
source = "cookie";
}
if (!requestedLocale) {
requestedLocale = defaultLocale;
source = "instance";
}
const localeRow = db
const settings = db
.select({ defaultLocale: instanceI18nSettings.defaultLocale })
.from(instanceI18nSettings)
.where(eq(instanceI18nSettings.key, "default"))
.get();
const locales = db
.select({
code: instanceLocales.code,
status: instanceLocales.status,
textDirection: instanceLocales.textDirection,
})
.from(instanceLocales)
.where(eq(instanceLocales.code, requestedLocale))
.get();
const locale = localeRow && localeRow.status !== "archived"
? localeRow.code
: defaultLocale || DEFAULT_LOCALE;
.all();
return {
locale,
requestedLocale,
defaultLocale,
direction: directionForLocale(locale, localeRow?.textDirection),
source,
db,
defaultLocale: settings?.defaultLocale ?? "tr",
locales,
};
});
}
function readUserPreference(context: LocaleContext, authUserId: string) {
return context.db
.select({ language: userPreferences.language })
.from(userPreferences)
.where(eq(userPreferences.ownerUserId, authUserId))
.get()?.language ?? null;
}
function resolveFromContext(
context: LocaleContext,
candidates: Parameters<typeof resolveLocalePolicy>[0]["candidates"],
) {
return resolveLocalePolicy({
activeLocales: context.locales,
defaultLocale: context.defaultLocale,
candidates,
});
}
+155 -5
View File
@@ -72,8 +72,53 @@ export type TranslationCompletion = {
missingKeys: string[];
};
export type NamespaceCompletion = {
namespace: I18nNamespace;
translated: number;
total: number;
percent: number;
missingKeys: string[];
};
export type LocaleUsage = {
defaultSettings: number;
fallbacks: number;
userPreferences: number;
clients: number;
portalInvitations: number;
contentTranslations: number;
};
export type LocaleReadiness = {
canActivate: boolean;
canArchive: boolean;
canSetDefault: boolean;
missingCriticalKeys: string[];
archiveReferences: number;
};
const LOCALE_CODE_PATTERN = /^[a-z]{2}(?:-[A-Z]{2}[0-9]?)?$/;
const BUILT_IN_LOCALES = new Set(["tr", "en"]);
export const ACTIVATION_CRITICAL_KEYS = [
"common.error.title",
"common.error.description",
"common.actions.save",
"common.actions.cancel",
"auth.login.title",
"auth.login.email",
"auth.login.password",
"auth.login.submit",
"auth.messages.invalidCredentials",
"navigation.items.dashboard",
"navigation.items.settings",
"navigation.account.signOut",
"portal.dashboard.title",
"portal.projects.title",
"portal.tasks.title",
"portal.revisions.title",
"validation.required",
"validation.invalidLocale",
] as const;
export class I18nService {
private readonly repository;
@@ -115,7 +160,7 @@ export class I18nService {
nativeName: normalizeRequiredText(input.nativeName ?? input.name, "Yerel dil adı zorunludur."),
fallbackLocale,
textDirection: input.textDirection ?? "ltr",
status: input.status ?? "draft",
status: "draft",
builtIn: false,
sortOrder: input.sortOrder ?? 100,
});
@@ -138,6 +183,13 @@ export class I18nService {
this.assertValidFallback(locale.code, nextFallback);
const nextStatus = input.status ?? locale.status;
if (
nextStatus === "active"
&& locale.status !== "active"
&& !locale.builtIn
) {
this.assertLocaleCanBeActivated(locale.code);
}
if (nextStatus === "archived") {
this.assertLocaleCanBeArchived(locale.code);
}
@@ -252,6 +304,61 @@ export class I18nService {
});
}
getNamespaceCompletion(actor: DomainActor, code: string): NamespaceCompletion[] {
requireOwnerScope(actor);
this.ensureBootstrap();
const locale = this.getExistingLocale(code);
const translations = this.translationKeysForLocale(locale.code);
return I18N_NAMESPACES.map((namespace) => {
const referenceKeys = Object.keys(flattenCatalog(trCatalog, [namespace]));
const missingKeys = referenceKeys.filter((key) => !translations.has(key));
const translated = referenceKeys.length - missingKeys.length;
return {
namespace,
translated,
total: referenceKeys.length,
percent: referenceKeys.length
? Math.round((translated / referenceKeys.length) * 100)
: 100,
missingKeys,
};
});
}
getLocaleUsage(actor: DomainActor, code: string): LocaleUsage {
requireOwnerScope(actor);
this.ensureBootstrap();
const locale = this.getExistingLocale(code);
return this.repository.countLocaleReferences(locale.code);
}
getLocaleReadiness(actor: DomainActor, code: string): LocaleReadiness {
requireOwnerScope(actor);
this.ensureBootstrap();
const locale = this.getExistingLocale(code);
const settings = this.getSettings(actor);
const usage = this.getLocaleUsage(actor, locale.code);
const archiveReferences = Object.entries(usage)
.filter(([key]) => key !== "contentTranslations")
.reduce((total, [, value]) => total + value, 0);
const missingCriticalKeys = locale.builtIn
? []
: ACTIVATION_CRITICAL_KEYS.filter(
(key) => !this.translationKeysForLocale(locale.code).has(key),
);
return {
canActivate: locale.status === "active" || missingCriticalKeys.length === 0,
canArchive: !locale.builtIn
&& settings.defaultLocale !== locale.code
&& archiveReferences === 0,
canSetDefault: locale.status === "active",
missingCriticalKeys,
archiveReferences,
};
}
exportPackage(actor: DomainActor): I18nExportPackage {
requireOwnerScope(actor);
this.ensureBootstrap();
@@ -331,12 +438,20 @@ export class I18nService {
private assertValidFallback(code: string, fallbackLocale: string | null): void {
if (!fallbackLocale) return;
if (fallbackLocale === code) {
throw new DomainError("VALIDATION_ERROR", "Dil kendi kendine fallback olamaz.");
throw new DomainError(
"VALIDATION_ERROR",
"Dil kendi kendine fallback olamaz.",
{ reason: "self_fallback" },
);
}
const fallback = this.repository.getLocale(fallbackLocale);
if (!fallback || fallback.status === "archived") {
throw new DomainError("VALIDATION_ERROR", "Fallback dili aktif veya taslak bir dil olmalıdır.");
throw new DomainError(
"VALIDATION_ERROR",
"Fallback dili aktif veya taslak bir dil olmalıdır.",
{ reason: "invalid_fallback" },
);
}
const graph = new Map(
@@ -350,7 +465,11 @@ export class I18nService {
let cursor: string | null = fallbackLocale;
while (cursor) {
if (cursor === code || seen.has(cursor)) {
throw new DomainError("VALIDATION_ERROR", "Fallback zinciri döngü oluşturamaz.");
throw new DomainError(
"VALIDATION_ERROR",
"Fallback zinciri döngü oluşturamaz.",
{ reason: "fallback_loop" },
);
}
seen.add(cursor);
cursor = graph.get(cursor) ?? null;
@@ -359,11 +478,42 @@ export class I18nService {
private assertLocaleCanBeArchived(code: string): void {
const references = this.repository.countLocaleReferences(code);
const referenceCount = Object.values(references).reduce((total, value) => total + value, 0);
const referenceCount = Object.entries(references)
.filter(([key]) => key !== "contentTranslations")
.reduce((total, [, value]) => total + value, 0);
if (referenceCount > 0) {
throw new DomainError("CONFLICT", "Kullanımda olan dil arşivlenemez.", references);
}
}
private assertLocaleCanBeActivated(code: string): void {
const locale = this.getExistingLocale(code);
if (locale.builtIn) return;
const translatedKeys = this.translationKeysForLocale(locale.code);
const missingCriticalKeys = ACTIVATION_CRITICAL_KEYS.filter(
(key) => !translatedKeys.has(key),
);
if (missingCriticalKeys.length > 0) {
throw new DomainError(
"VALIDATION_ERROR",
"Kritik arayüz çevirileri tamamlanmadan dil aktifleştirilemez.",
{ missingCriticalKeys },
);
}
}
private translationKeysForLocale(code: string): Set<string> {
const builtIn = getBuiltInCatalog(code);
const keys = builtIn
? Object.keys(flattenCatalog(builtIn, I18N_NAMESPACES))
: [];
for (const row of this.repository.listAllUiTranslations()) {
if (row.locale === code && row.value.trim()) {
keys.push(`${row.namespace}.${row.translationKey}`);
}
}
return new Set(keys);
}
}
export function getReferenceTranslationKeys(namespace: I18nNamespace | "all" = "all") {