feat: wire i18n services and shared UI
This commit is contained in:
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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