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