feat: wire i18n services and shared UI
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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."),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user