feat: wire i18n services and shared UI

This commit is contained in:
poyrazavsever
2026-07-19 03:06:56 +03:00
parent 805beccf95
commit 826410f53e
11 changed files with 1330 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { getSqliteConnection } from "@/server/db/client";
import { instanceLocales } from "@/server/db/schema";
import { DomainError } from "@/server/domain/errors";
import { buildLocaleCookie, normalizeLocaleCode } from "@/server/i18n/locale";
import { eq } from "drizzle-orm";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
try {
const payload = await request.json();
const locale = normalizeLocaleCode(typeof payload.locale === "string" ? payload.locale : null);
if (!locale) {
throw new DomainError("VALIDATION_ERROR", "validation.invalidLocale", {
messageKey: "validation.invalidLocale",
});
}
const row = getSqliteConnection().db
.select({ code: instanceLocales.code, status: instanceLocales.status })
.from(instanceLocales)
.where(eq(instanceLocales.code, locale))
.get();
if (!row || row.status === "archived") {
throw new DomainError("UNSUPPORTED_LOCALE", "validation.unsupportedLocale", {
messageKey: "validation.unsupportedLocale",
});
}
(await cookies()).set(buildLocaleCookie(row.code));
return NextResponse.json({ ok: true, data: { locale: row.code } });
} catch (error) {
const normalized = error instanceof DomainError
? error
: new DomainError("VALIDATION_ERROR", "validation.unsupportedLocale", {
messageKey: "validation.unsupportedLocale",
});
return NextResponse.json(
{
ok: false,
error: {
code: normalized.code,
message: normalized.message,
messageKey: normalized.details?.messageKey,
},
},
{ status: normalized.status },
);
}
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import {
createContext,
useContext,
useMemo,
type ReactNode,
} from "react";
import {
createTranslatorFromMessages,
type Translator,
type TranslationValues,
} from "@/lib/i18n";
type I18nContextValue = Translator;
const I18nContext = createContext<I18nContextValue | null>(null);
export function I18nProvider({
children,
locale,
messages,
}: {
children: ReactNode;
locale: string;
messages: Record<string, string>;
}) {
const value = useMemo(
() => createTranslatorFromMessages(locale, messages),
[locale, messages],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useTranslations() {
const value = useContext(I18nContext);
if (!value) {
throw new Error("useTranslations must be used within I18nProvider.");
}
return (key: string, values?: TranslationValues) => value.t(key, values);
}
export function useI18n() {
const value = useContext(I18nContext);
if (!value) {
throw new Error("useI18n must be used within I18nProvider.");
}
return value;
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useState, useTransition } from "react";
import { Label } from "poyraz-ui/atoms";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "poyraz-ui/molecules";
type LocaleOption = {
code: string;
nativeName: string;
name: string;
};
type LocaleSelectFormProps = {
label: string;
value: string;
locales: LocaleOption[];
};
export function LocaleSelectForm({ label, value, locales }: LocaleSelectFormProps) {
const [currentLocale, setCurrentLocale] = useState(value);
const [pending, startTransition] = useTransition();
function handleChange(locale: string) {
setCurrentLocale(locale);
startTransition(async () => {
await fetch("/api/i18n/locale", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ locale }),
});
window.location.reload();
});
}
return (
<div className="space-y-2">
<Label htmlFor="auth-locale">{label}</Label>
<Select value={currentLocale} onValueChange={handleChange} disabled={pending}>
<SelectTrigger id="auth-locale" aria-label={label}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{locales.map((locale) => (
<SelectItem key={locale.code} value={locale.code}>
{locale.nativeName || locale.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { useMemo, useState } from "react";
import { Badge, Button, Input, Label, Textarea } from "poyraz-ui/atoms";
import { contentInputName, type ContentTranslationField } from "@/lib/i18n/content";
export type LocalizedFieldLocale = {
code: string;
nativeName: string;
status?: string;
};
export type LocalizedFieldValues = Record<string, Record<string, string | null | undefined>>;
type LocalizedFieldsProps = {
idPrefix: string;
defaultLocale: string;
locales: LocalizedFieldLocale[];
fields: ContentTranslationField[];
values?: LocalizedFieldValues | null;
fallbackValues?: Record<string, string | null | undefined>;
};
export function LocalizedFields({
idPrefix,
defaultLocale,
locales,
fields,
values,
fallbackValues,
}: LocalizedFieldsProps) {
const orderedLocales = useMemo(() => {
const localeMap = new Map(locales.map((locale) => [locale.code, locale]));
const defaultLocaleRecord = localeMap.get(defaultLocale);
return [
...(defaultLocaleRecord ? [defaultLocaleRecord] : []),
...locales.filter((locale) => locale.code !== defaultLocale),
];
}, [defaultLocale, locales]);
const [activeLocale, setActiveLocale] = useState(orderedLocales[0]?.code ?? defaultLocale);
const missingRequiredLocales = new Set(
orderedLocales
.filter((locale) =>
fields.some((field) => {
if (!field.required) return false;
const value = values?.[locale.code]?.[field.name] ?? (
locale.code === defaultLocale ? fallbackValues?.[field.name] : null
);
return !String(value ?? "").trim();
}),
)
.map((locale) => locale.code),
);
return (
<div className="grid gap-4 rounded-sm border border-border bg-muted/10 p-3">
<div className="tiny-scrollbar flex gap-2 overflow-x-auto pb-1">
{orderedLocales.map((locale) => (
<Button
key={locale.code}
type="button"
variant={activeLocale === locale.code ? "default" : "secondary"}
effect="shine"
size="sm"
className="shrink-0 gap-2"
onClick={() => setActiveLocale(locale.code)}
>
{locale.nativeName || locale.code}
{locale.code === defaultLocale ? <Badge variant="secondary">varsayılan</Badge> : null}
{missingRequiredLocales.has(locale.code) ? <span aria-hidden="true" className="text-amber-500"></span> : null}
</Button>
))}
</div>
{orderedLocales.map((locale) => (
<div key={locale.code} className={activeLocale === locale.code ? "grid gap-4" : "hidden"}>
{fields.map((field) => {
const id = `${idPrefix}-${locale.code}-${field.name}`;
const defaultValue =
values?.[locale.code]?.[field.name] ??
(locale.code === defaultLocale ? fallbackValues?.[field.name] : "") ??
"";
return (
<div key={field.name} className="grid gap-2">
<Label htmlFor={id}>
{field.label}
{field.required && locale.code === defaultLocale ? <span className="text-destructive"> *</span> : null}
</Label>
{field.kind === "textarea" ? (
<Textarea
id={id}
name={contentInputName(locale.code, field.name)}
defaultValue={String(defaultValue ?? "")}
maxLength={field.maxLength}
required={field.required && locale.code === defaultLocale}
placeholder={field.placeholder}
rows={field.name === "content" ? 8 : 3}
/>
) : (
<Input
id={id}
name={contentInputName(locale.code, field.name)}
defaultValue={String(defaultValue ?? "")}
maxLength={field.maxLength}
required={field.required && locale.code === defaultLocale}
placeholder={field.placeholder}
/>
)}
</div>
);
})}
</div>
))}
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { cache } from "react";
import {
DEFAULT_LOCALE,
flattenCatalog,
getBuiltInCatalog,
type I18nNamespace,
} from "../../lib/i18n";
import { getSqliteConnection } from "../db/client";
import { instanceI18nSettings } from "../db/schema";
import { createI18nRepository } from "../repositories/i18n";
export type ResolvedCatalog = {
locale: string;
requestedLocale: string;
namespaces: I18nNamespace[];
catalogVersion: number;
fallbackChain: string[];
messages: Record<string, string>;
};
export const getResolvedCatalog = cache(
(
requestedLocale: string,
namespaces: readonly I18nNamespace[],
catalogVersion?: number,
): ResolvedCatalog => {
const { db } = getSqliteConnection();
const repository = createI18nRepository(db);
const version = catalogVersion ?? ensureCatalogVersion(db);
const fallbackChain = resolveFallbackChain(repository, requestedLocale);
const messages: Record<string, string> = {};
for (const locale of [...fallbackChain].reverse()) {
const builtIn = getBuiltInCatalog(locale);
if (builtIn) {
Object.assign(messages, flattenCatalog(builtIn, namespaces));
}
for (const row of repository.listUiTranslations(locale, namespaces)) {
messages[`${row.namespace}.${row.translationKey}`] = row.value;
}
}
return {
locale: fallbackChain[0] ?? DEFAULT_LOCALE,
requestedLocale,
namespaces: [...namespaces],
catalogVersion: version,
fallbackChain,
messages,
};
},
);
export function getCatalogVersion(): number {
return ensureCatalogVersion(getSqliteConnection().db);
}
function ensureCatalogVersion(db: ReturnType<typeof getSqliteConnection>["db"]): number {
const repository = createI18nRepository(db);
repository.createSettingsIfMissing();
return db.select({ catalogVersion: instanceI18nSettings.catalogVersion }).from(instanceI18nSettings).get()
?.catalogVersion ?? 1;
}
function resolveFallbackChain(
repository: ReturnType<typeof createI18nRepository>,
requestedLocale: string,
): string[] {
const locales = new Map(repository.listLocales().map((locale) => [locale.code, locale]));
const settings = repository.getSettings();
const start = locales.has(requestedLocale)
? requestedLocale
: settings?.defaultLocale ?? DEFAULT_LOCALE;
const chain: string[] = [];
const seen = new Set<string>();
let cursor: string | null = start;
while (cursor && !seen.has(cursor)) {
const locale = locales.get(cursor);
if (!locale || locale.status === "archived") break;
chain.push(cursor);
seen.add(cursor);
cursor = locale.fallbackLocale;
}
if (!chain.includes(DEFAULT_LOCALE)) {
chain.push(DEFAULT_LOCALE);
}
return chain;
}
+275
View File
@@ -0,0 +1,275 @@
import "server-only";
import { and, eq, inArray } from "drizzle-orm";
import {
contentInputName,
contentTranslationRegistry,
type ContentTranslationInput,
} from "../../lib/i18n/content";
import {
contentTranslations,
instanceI18nSettings,
instanceLocales,
type TranslationEntityType,
} from "../db/schema";
import type { DomainActor } from "../domain/actor";
import type { DomainDatabase } from "../domain/database";
import { DomainError } from "../domain/errors";
import { I18nService, type LocaleRecord } from "./service";
export type ContentTranslationRow = {
entityType: TranslationEntityType;
entityId: string;
field: string;
locale: string;
value: string;
};
export type ContentLocalizationContext = {
defaultLocale: string;
locales: LocaleRecord[];
};
export class ContentTranslationService {
constructor(private readonly db: DomainDatabase) {}
getLocalizationContext(actor: DomainActor): ContentLocalizationContext {
const i18n = new I18nService(this.db);
const settings = i18n.getSettings(actor);
const locales = i18n
.listLocales(actor)
.filter((locale) => locale.status !== "archived")
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
return {
defaultLocale: settings.defaultLocale,
locales,
};
}
getPublicLocalizationContext(): ContentLocalizationContext {
const settings = this.db
.select()
.from(instanceI18nSettings)
.where(eq(instanceI18nSettings.key, "default"))
.get();
const locales = this.db
.select()
.from(instanceLocales)
.where(eq(instanceLocales.status, "active"))
.orderBy(instanceLocales.sortOrder, instanceLocales.code)
.all()
.map(toContentLocaleRecord);
return {
defaultLocale: settings?.defaultLocale ?? "tr",
locales,
};
}
listEntityTranslations(entityType: TranslationEntityType, entityId: string): ContentTranslationRow[] {
return this.db
.select()
.from(contentTranslations)
.where(and(eq(contentTranslations.entityType, entityType), eq(contentTranslations.entityId, entityId)))
.all()
.map(toContentTranslationRow);
}
listBatch(entityType: TranslationEntityType, entityIds: readonly string[]): Map<string, ContentTranslationRow[]> {
const uniqueIds = [...new Set(entityIds)].filter(Boolean);
const result = new Map<string, ContentTranslationRow[]>();
if (uniqueIds.length === 0) return result;
for (const row of this.db
.select()
.from(contentTranslations)
.where(and(eq(contentTranslations.entityType, entityType), inArray(contentTranslations.entityId, uniqueIds)))
.all()
.map(toContentTranslationRow)) {
const rows = result.get(row.entityId) ?? [];
rows.push(row);
result.set(row.entityId, rows);
}
return result;
}
upsertEntityTranslations(
entityType: TranslationEntityType,
entityId: string,
input: ContentTranslationInput | null | undefined,
) {
const fields = new Set(contentTranslationRegistry[entityType].map((field) => field.name));
if (!input) return;
for (const [locale, values] of Object.entries(input)) {
for (const [field, rawValue] of Object.entries(values)) {
if (!fields.has(field)) continue;
const value = normalizeOptionalText(rawValue);
if (!value) {
this.db
.delete(contentTranslations)
.where(
and(
eq(contentTranslations.entityType, entityType),
eq(contentTranslations.entityId, entityId),
eq(contentTranslations.field, field),
eq(contentTranslations.locale, locale),
),
)
.run();
continue;
}
this.db
.insert(contentTranslations)
.values({ entityType, entityId, field, locale, value })
.onConflictDoUpdate({
target: [
contentTranslations.entityType,
contentTranslations.entityId,
contentTranslations.field,
contentTranslations.locale,
],
set: {
value,
updatedAt: new Date(),
},
})
.run();
}
}
}
deleteEntityTranslations(entityType: TranslationEntityType, entityId: string) {
this.db
.delete(contentTranslations)
.where(and(eq(contentTranslations.entityType, entityType), eq(contentTranslations.entityId, entityId)))
.run();
}
resolveEntity<T extends Record<string, unknown>>(
entityType: TranslationEntityType,
entity: T,
options: {
locale: string;
fallbackLocale?: string | null;
defaultLocale: string;
translations?: ContentTranslationRow[];
},
): T {
const translations = options.translations ?? this.listEntityTranslations(entityType, String(entity.id ?? ""));
const resolved = { ...entity };
for (const field of contentTranslationRegistry[entityType]) {
const value = resolveFieldValue(translations, field.name, [
options.locale,
options.fallbackLocale,
options.defaultLocale,
]);
if (value != null) {
(resolved as Record<string, unknown>)[field.name] = value;
}
}
return resolved;
}
}
export function parseContentTranslationsFromFormData(
formData: FormData,
entityType: TranslationEntityType,
context: ContentLocalizationContext,
): ContentTranslationInput {
const fields = contentTranslationRegistry[entityType];
const translations: ContentTranslationInput = {};
for (const locale of context.locales) {
const values: Record<string, string | null> = {};
for (const field of fields) {
const raw = formData.get(contentInputName(locale.code, field.name));
values[field.name] = normalizeOptionalText(typeof raw === "string" ? raw : null);
}
translations[locale.code] = values;
}
assertDefaultLocaleRequiredFields(entityType, translations, context.defaultLocale);
return translations;
}
export function projectBaseFromTranslations<T extends Record<string, unknown>>(
entityType: TranslationEntityType,
payload: T,
translations: ContentTranslationInput,
defaultLocale: string,
): T {
const next = { ...payload };
const defaultValues = translations[defaultLocale] ?? {};
for (const field of contentTranslationRegistry[entityType]) {
const value = normalizeOptionalText(defaultValues[field.name]);
if (value != null) {
(next as Record<string, unknown>)[field.name] = value;
}
}
return next;
}
export function getContentFallbackLocale(
locale: string,
context: ContentLocalizationContext,
): string | null {
return context.locales.find((item) => item.code === locale)?.fallbackLocale ?? null;
}
function assertDefaultLocaleRequiredFields(
entityType: TranslationEntityType,
translations: ContentTranslationInput,
defaultLocale: string,
) {
const defaultValues = translations[defaultLocale] ?? {};
for (const field of contentTranslationRegistry[entityType]) {
if (!("required" in field) || !field.required) continue;
if (!normalizeOptionalText(defaultValues[field.name])) {
throw new DomainError("VALIDATION_ERROR", `${field.label} varsayılan dilde zorunludur.`);
}
}
}
function resolveFieldValue(rows: ContentTranslationRow[], field: string, localeChain: Array<string | null | undefined>) {
const uniqueLocales = [...new Set(localeChain.filter(Boolean) as string[])];
return rows.find((row) => row.field === field && uniqueLocales.includes(row.locale))?.value ?? null;
}
function normalizeOptionalText(value: string | null | undefined) {
if (typeof value !== "string") return null;
const normalized = value.trim();
return normalized.length > 0 ? normalized : null;
}
function toContentTranslationRow(row: typeof contentTranslations.$inferSelect): ContentTranslationRow {
return {
entityType: row.entityType,
entityId: row.entityId,
field: row.field,
locale: row.locale,
value: row.value,
};
}
function toContentLocaleRecord(row: typeof instanceLocales.$inferSelect): LocaleRecord {
return {
code: row.code,
name: row.name,
nativeName: row.nativeName,
status: row.status,
fallbackLocale: row.fallbackLocale,
textDirection: row.textDirection,
builtIn: row.builtIn,
sortOrder: row.sortOrder,
};
}
+28
View File
@@ -0,0 +1,28 @@
import type { TextDirection } from "../../lib/i18n";
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 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",
};
}
+82
View File
@@ -0,0 +1,82 @@
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 { eq } from "drizzle-orm";
import { directionForLocale, LOCALE_COOKIE, normalizeLocaleCode } from "./locale";
export type ResolvedLocale = {
locale: string;
requestedLocale: string | null;
defaultLocale: string;
direction: "ltr" | "rtl";
source: "user" | "client" | "cookie" | "instance" | "fallback";
};
export const resolveRequestLocale = cache(async (): Promise<ResolvedLocale> => {
const requestHeaders = await headers();
const cookieStore = await cookies();
const context = await getSessionContextFromHeaders(requestHeaders);
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
.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;
return {
locale,
requestedLocale,
defaultLocale,
direction: directionForLocale(locale, localeRow?.textDirection),
source,
};
});
+28
View File
@@ -0,0 +1,28 @@
import { getSqliteConnection } from "../db/client";
import { createI18nRepository } from "../repositories/i18n";
export function getPublicLocalizationMetadata() {
const repository = createI18nRepository(getSqliteConnection().db);
repository.createSettingsIfMissing();
const settings = repository.getSettings();
const locales = repository.listLocales().filter((locale) => locale.status !== "archived");
return {
defaultLocale: settings?.defaultLocale ?? "tr",
supportedLocales: locales.map((locale) => ({
code: locale.code,
name: locale.name,
nativeName: locale.nativeName,
status: locale.status,
fallbackLocale: locale.fallbackLocale,
textDirection: locale.textDirection,
builtIn: locale.builtIn,
})),
fallbacks: Object.fromEntries(
locales
.filter((locale) => locale.fallbackLocale)
.map((locale) => [locale.code, locale.fallbackLocale]),
),
catalogVersion: settings?.catalogVersion ?? 1,
};
}
+504
View File
@@ -0,0 +1,504 @@
import type { LocaleStatus, TextDirection } from "../db/schema";
import {
DEFAULT_LOCALE,
I18N_NAMESPACES,
compareCatalogKeys,
flattenCatalog,
getBuiltInCatalog,
type I18nNamespace,
} from "../../lib/i18n";
import { trCatalog } from "../../locales/tr";
import { requireOwnerScope, type DomainActor } from "../domain/actor";
import type { DomainDatabase } from "../domain/database";
import { DomainError, notFound } from "../domain/errors";
import { createI18nRepository } from "../repositories/i18n";
export type LocaleRecord = {
code: string;
name: string;
nativeName: string;
status: LocaleStatus;
fallbackLocale: string | null;
textDirection: TextDirection;
builtIn: boolean;
sortOrder: number;
};
export type I18nSettingsRecord = {
defaultLocale: string;
catalogVersion: number;
};
export type CreateLocaleInput = {
code: string;
name: string;
nativeName?: string;
fallbackLocale?: string | null;
textDirection?: TextDirection;
status?: LocaleStatus;
sortOrder?: number;
};
export type UpdateLocaleInput = Partial<Omit<CreateLocaleInput, "code">>;
export type UpsertUiTranslationInput = {
locale: string;
namespace: string;
key: string;
value: string;
};
export type UiTranslationRow = {
locale: string;
namespace: string;
key: string;
value: string;
};
export type I18nExportPackage = {
format: "neta-i18n";
version: 1;
exportedAt: string;
defaultLocale: string;
locales: LocaleRecord[];
translations: UiTranslationRow[];
};
export type TranslationCompletion = {
locale: string;
translated: number;
total: number;
percent: number;
missingKeys: string[];
};
const LOCALE_CODE_PATTERN = /^[a-z]{2}(?:-[A-Z]{2}[0-9]?)?$/;
const BUILT_IN_LOCALES = new Set(["tr", "en"]);
export class I18nService {
private readonly repository;
constructor(private readonly db: DomainDatabase) {
this.repository = createI18nRepository(db);
}
listLocales(actor: DomainActor): LocaleRecord[] {
requireOwnerScope(actor);
this.ensureBootstrap();
return this.repository.listLocales().map(toLocaleRecord);
}
getSettings(actor: DomainActor): I18nSettingsRecord {
requireOwnerScope(actor);
this.ensureBootstrap();
return toSettingsRecord(this.repository.getSettings());
}
createLocale(actor: DomainActor, input: CreateLocaleInput): LocaleRecord {
requireOwnerScope(actor);
this.ensureBootstrap();
const code = normalizeLocaleCode(input.code);
if (BUILT_IN_LOCALES.has(code)) {
throw new DomainError("CONFLICT", "Built-in dil zaten mevcut.");
}
if (this.repository.getLocale(code)) {
throw new DomainError("CONFLICT", "Bu dil zaten eklenmiş.");
}
const fallbackLocale = normalizeOptionalLocale(input.fallbackLocale ?? "en");
this.assertValidFallback(code, fallbackLocale);
const created = this.repository.createLocale({
code,
name: normalizeRequiredText(input.name, "Dil adı zorunludur."),
nativeName: normalizeRequiredText(input.nativeName ?? input.name, "Yerel dil adı zorunludur."),
fallbackLocale,
textDirection: input.textDirection ?? "ltr",
status: input.status ?? "draft",
builtIn: false,
sortOrder: input.sortOrder ?? 100,
});
return toLocaleRecord(created);
}
updateLocale(actor: DomainActor, code: string, input: UpdateLocaleInput): LocaleRecord {
requireOwnerScope(actor);
this.ensureBootstrap();
const locale = this.getExistingLocale(code);
if (locale.builtIn && input.status === "archived") {
throw new DomainError("CONFLICT", "Built-in diller arşivlenemez.");
}
const nextFallback = input.fallbackLocale === undefined
? locale.fallbackLocale
: normalizeOptionalLocale(input.fallbackLocale);
this.assertValidFallback(locale.code, nextFallback);
const nextStatus = input.status ?? locale.status;
if (nextStatus === "archived") {
this.assertLocaleCanBeArchived(locale.code);
}
const updated = this.repository.updateLocale(locale.code, {
name: input.name === undefined ? undefined : normalizeRequiredText(input.name, "Dil adı zorunludur."),
nativeName: input.nativeName === undefined
? undefined
: normalizeRequiredText(input.nativeName, "Yerel dil adı zorunludur."),
fallbackLocale: nextFallback,
textDirection: input.textDirection,
status: nextStatus,
sortOrder: input.sortOrder,
});
if (!updated) throw notFound("Dil");
return toLocaleRecord(updated);
}
archiveLocale(actor: DomainActor, code: string): LocaleRecord {
return this.updateLocale(actor, code, { status: "archived" });
}
setDefaultLocale(actor: DomainActor, code: string): I18nSettingsRecord {
requireOwnerScope(actor);
this.ensureBootstrap();
const locale = this.getExistingLocale(code);
if (locale.status !== "active") {
throw new DomainError("VALIDATION_ERROR", "Varsayılan dil yalnızca aktif bir dil olabilir.");
}
const updated = this.repository.updateDefaultLocale(locale.code);
return toSettingsRecord(updated);
}
upsertUiTranslation(actor: DomainActor, input: UpsertUiTranslationInput): void {
requireOwnerScope(actor);
this.ensureBootstrap();
const locale = this.getExistingLocale(input.locale);
if (locale.status === "archived") {
throw new DomainError("VALIDATION_ERROR", "Arşivlenmiş dile çeviri yazılamaz.");
}
const namespace = normalizeIdentifier(input.namespace, "Namespace geçersiz.", 64);
const translationKey = normalizeIdentifier(input.key, "Çeviri anahtarı geçersiz.", 160);
const value = input.value.trim();
if (!value) {
throw new DomainError("VALIDATION_ERROR", "Çeviri metni boş olamaz.");
}
this.repository.upsertUiTranslation({
locale: locale.code,
namespace,
translationKey,
value,
});
this.repository.bumpCatalogVersion();
}
resetUiTranslation(actor: DomainActor, input: Omit<UpsertUiTranslationInput, "value">): void {
requireOwnerScope(actor);
this.ensureBootstrap();
const locale = this.getExistingLocale(input.locale);
this.repository.deleteUiTranslation(
locale.code,
normalizeIdentifier(input.namespace, "Namespace geçersiz.", 64),
normalizeIdentifier(input.key, "Çeviri anahtarı geçersiz.", 160),
);
this.repository.bumpCatalogVersion();
}
listUiTranslations(actor: DomainActor): UiTranslationRow[] {
requireOwnerScope(actor);
this.ensureBootstrap();
return this.repository.listAllUiTranslations().map((row) => ({
locale: row.locale,
namespace: row.namespace,
key: row.translationKey,
value: row.value,
}));
}
getCompletion(actor: DomainActor): TranslationCompletion[] {
requireOwnerScope(actor);
this.ensureBootstrap();
const referenceKeys = Object.keys(flattenCatalog(trCatalog, I18N_NAMESPACES));
const overrideRows = this.repository.listAllUiTranslations();
const overrideKeysByLocale = new Map<string, Set<string>>();
for (const row of overrideRows) {
const keys = overrideKeysByLocale.get(row.locale) ?? new Set<string>();
keys.add(`${row.namespace}.${row.translationKey}`);
overrideKeysByLocale.set(row.locale, keys);
}
return this.repository.listLocales().map((locale) => {
const builtIn = getBuiltInCatalog(locale.code);
const builtInKeys = builtIn ? new Set(Object.keys(flattenCatalog(builtIn, I18N_NAMESPACES))) : new Set<string>();
const overrideKeys = overrideKeysByLocale.get(locale.code) ?? new Set<string>();
const missingKeys = referenceKeys.filter((key) => !builtInKeys.has(key) && !overrideKeys.has(key));
const translated = referenceKeys.length - missingKeys.length;
return {
locale: locale.code,
translated,
total: referenceKeys.length,
percent: referenceKeys.length ? Math.round((translated / referenceKeys.length) * 100) : 100,
missingKeys,
};
});
}
exportPackage(actor: DomainActor): I18nExportPackage {
requireOwnerScope(actor);
this.ensureBootstrap();
return {
format: "neta-i18n",
version: 1,
exportedAt: new Date().toISOString(),
defaultLocale: this.getSettings(actor).defaultLocale,
locales: this.listLocales(actor),
translations: this.listUiTranslations(actor),
};
}
importPackage(actor: DomainActor, input: unknown): I18nExportPackage {
requireOwnerScope(actor);
this.ensureBootstrap();
const parsed = parseImportPackage(input);
for (const locale of parsed.locales) {
if (!this.repository.getLocale(locale.code) && !BUILT_IN_LOCALES.has(locale.code)) {
this.createLocale(actor, {
code: locale.code,
name: locale.name,
nativeName: locale.nativeName,
fallbackLocale: locale.fallbackLocale ?? DEFAULT_LOCALE,
textDirection: locale.textDirection,
status: locale.status === "archived" ? "draft" : locale.status,
sortOrder: locale.sortOrder,
});
}
}
for (const translation of parsed.translations) {
this.upsertUiTranslation(actor, translation);
}
const defaultLocale = parsed.defaultLocale ? this.repository.getLocale(parsed.defaultLocale) : null;
if (defaultLocale?.status === "active") {
this.setDefaultLocale(actor, defaultLocale.code);
}
return this.exportPackage(actor);
}
private ensureBootstrap(): void {
this.repository.createSettingsIfMissing();
ensureBuiltInLocale(this.repository, {
code: "tr",
name: "Turkish",
nativeName: "Türkçe",
status: "active",
fallbackLocale: null,
textDirection: "ltr",
builtIn: true,
sortOrder: 10,
});
ensureBuiltInLocale(this.repository, {
code: "en",
name: "English",
nativeName: "English",
status: "active",
fallbackLocale: "tr",
textDirection: "ltr",
builtIn: true,
sortOrder: 20,
});
}
private getExistingLocale(code: string) {
const normalized = normalizeLocaleCode(code);
const locale = this.repository.getLocale(normalized);
if (!locale) throw notFound("Dil");
return locale;
}
private assertValidFallback(code: string, fallbackLocale: string | null): void {
if (!fallbackLocale) return;
if (fallbackLocale === code) {
throw new DomainError("VALIDATION_ERROR", "Dil kendi kendine fallback olamaz.");
}
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.");
}
const graph = new Map(
this.repository
.listLocales()
.map((locale) => [locale.code, locale.fallbackLocale] as const),
);
graph.set(code, fallbackLocale);
const seen = new Set<string>();
let cursor: string | null = fallbackLocale;
while (cursor) {
if (cursor === code || seen.has(cursor)) {
throw new DomainError("VALIDATION_ERROR", "Fallback zinciri döngü oluşturamaz.");
}
seen.add(cursor);
cursor = graph.get(cursor) ?? null;
}
}
private assertLocaleCanBeArchived(code: string): void {
const references = this.repository.countLocaleReferences(code);
const referenceCount = Object.values(references).reduce((total, value) => total + value, 0);
if (referenceCount > 0) {
throw new DomainError("CONFLICT", "Kullanımda olan dil arşivlenemez.", references);
}
}
}
export function getReferenceTranslationKeys(namespace: I18nNamespace | "all" = "all") {
const namespaces = namespace === "all" ? I18N_NAMESPACES : [namespace];
const tr = flattenCatalog(trCatalog, namespaces);
const en = flattenCatalog(getBuiltInCatalog("en") ?? trCatalog, namespaces);
const parity = compareCatalogKeys(trCatalog, getBuiltInCatalog("en") ?? trCatalog, I18N_NAMESPACES);
return Object.keys(tr).sort().map((key) => ({
key,
namespace: key.split(".")[0] as I18nNamespace,
translationKey: key.split(".").slice(1).join("."),
tr: tr[key] ?? "",
en: en[key] ?? "",
parityOk: parity.missingInLeft.length === 0 && parity.missingInRight.length === 0,
}));
}
function ensureBuiltInLocale(
repository: ReturnType<typeof createI18nRepository>,
value: Parameters<ReturnType<typeof createI18nRepository>["createLocale"]>[0],
): void {
const existing = repository.getLocale(value.code);
if (!existing) {
repository.createLocale(value);
return;
}
repository.updateLocale(value.code, {
name: value.name,
nativeName: value.nativeName,
status: value.status,
fallbackLocale: value.fallbackLocale,
textDirection: value.textDirection,
builtIn: true,
sortOrder: value.sortOrder,
});
}
function normalizeLocaleCode(code: string): string {
const normalized = code.trim();
if (!LOCALE_CODE_PATTERN.test(normalized)) {
throw new DomainError("VALIDATION_ERROR", "Dil kodu BCP47 kısa formatında olmalıdır. Örn: tr, en, fr veya ar-XB.");
}
return normalized;
}
function normalizeOptionalLocale(code: string | null | undefined): string | null {
if (!code) return null;
return normalizeLocaleCode(code);
}
function normalizeRequiredText(value: string, message: string): string {
const normalized = value.trim();
if (!normalized) throw new DomainError("VALIDATION_ERROR", message);
if (normalized.length > 80) {
throw new DomainError("VALIDATION_ERROR", "Dil adı 80 karakterden kısa olmalıdır.");
}
return normalized;
}
function normalizeIdentifier(value: string, message: string, maxLength: number): string {
const normalized = value.trim();
if (!normalized || normalized.length > maxLength || !/^[a-zA-Z0-9_.-]+$/.test(normalized)) {
throw new DomainError("VALIDATION_ERROR", message);
}
return normalized;
}
function toLocaleRecord(value: {
code: string;
name: string;
nativeName: string;
status: LocaleStatus;
fallbackLocale: string | null;
textDirection: TextDirection;
builtIn: boolean;
sortOrder: number;
}): LocaleRecord {
return {
code: value.code,
name: value.name,
nativeName: value.nativeName,
status: value.status,
fallbackLocale: value.fallbackLocale,
textDirection: value.textDirection,
builtIn: value.builtIn,
sortOrder: value.sortOrder,
};
}
function toSettingsRecord(value: {
defaultLocale: string;
catalogVersion: number;
} | undefined): I18nSettingsRecord {
if (!value) {
throw new DomainError("INVARIANT_VIOLATION", "I18n ayarları oluşturulamadı.");
}
return {
defaultLocale: value.defaultLocale,
catalogVersion: value.catalogVersion,
};
}
function parseImportPackage(input: unknown): I18nExportPackage {
if (!input || typeof input !== "object") {
throw new DomainError("VALIDATION_ERROR", "Import paketi geçersiz.");
}
const value = input as Partial<I18nExportPackage>;
if (value.format !== "neta-i18n" || value.version !== 1) {
throw new DomainError("VALIDATION_ERROR", "Import paketi desteklenmiyor.");
}
if (!Array.isArray(value.locales) || !Array.isArray(value.translations)) {
throw new DomainError("VALIDATION_ERROR", "Import paketinde locale veya çeviri listesi eksik.");
}
return {
format: "neta-i18n",
version: 1,
exportedAt: typeof value.exportedAt === "string" ? value.exportedAt : new Date().toISOString(),
defaultLocale: typeof value.defaultLocale === "string" ? value.defaultLocale : DEFAULT_LOCALE,
locales: value.locales.map((locale) => ({
code: normalizeLocaleCode(locale.code),
name: normalizeRequiredText(locale.name, "Dil adı zorunludur."),
nativeName: normalizeRequiredText(locale.nativeName, "Yerel dil adı zorunludur."),
status: locale.status,
fallbackLocale: normalizeOptionalLocale(locale.fallbackLocale),
textDirection: locale.textDirection === "rtl" ? "rtl" : "ltr",
builtIn: Boolean(locale.builtIn),
sortOrder: Number.isFinite(locale.sortOrder) ? locale.sortOrder : 100,
})),
translations: value.translations.map((translation) => ({
locale: normalizeLocaleCode(translation.locale),
namespace: normalizeIdentifier(translation.namespace, "Namespace geçersiz.", 64),
key: normalizeIdentifier(translation.key, "Çeviri anahtarı geçersiz.", 160),
value: normalizeRequiredText(translation.value, "Çeviri metni boş olamaz."),
})),
};
}
+42
View File
@@ -0,0 +1,42 @@
import {
createTranslatorFromMessages,
type I18nNamespace,
type TranslationValues,
} from "../../lib/i18n";
import { getCatalogVersion, getResolvedCatalog } from "./catalog";
export type ServerTranslator = {
locale: string;
fallbackChain: string[];
messages: Record<string, string>;
t: (key: string, values?: TranslationValues) => string;
};
export function createTranslator(
locale: string,
namespaces: readonly I18nNamespace[],
): ServerTranslator {
const catalog = getResolvedCatalog(locale, namespaces, getCatalogVersion());
const translator = createTranslatorFromMessages(catalog.locale, catalog.messages);
return {
locale: catalog.locale,
fallbackChain: catalog.fallbackChain,
messages: catalog.messages,
t(key, values) {
const value = translator.t(key, values);
if (value === key && process.env.NODE_ENV !== "production") {
console.warn(`[i18n] Missing translation key "${key}" for locale "${catalog.locale}".`);
}
return value;
},
};
}
export function getClientI18nPayload(locale: string, namespaces: readonly I18nNamespace[]) {
const catalog = getResolvedCatalog(locale, namespaces, getCatalogVersion());
return {
locale: catalog.locale,
messages: catalog.messages,
};
}