feat: add core i18n runtime and catalogs

This commit is contained in:
poyrazavsever
2026-07-19 03:06:38 +03:00
parent 0548820584
commit f4a2342ac4
12 changed files with 776 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
import { toIntlLocale } from "./format";
export function getDocumentLocale(): string {
if (typeof document === "undefined") return "tr";
return document.documentElement.lang || "tr";
}
export function getDocumentIntlLocale(): string {
return toIntlLocale(getDocumentLocale());
}
+53
View File
@@ -0,0 +1,53 @@
import { enCatalog } from "../../locales/en";
import { trCatalog } from "../../locales/tr";
import type { I18nNamespace, TranslationCatalog } from "./types";
export const DEFAULT_LOCALE = "tr";
export const BUILT_IN_LOCALES = ["tr", "en"] as const;
export const BUILT_IN_CATALOGS: Record<string, TranslationCatalog> = {
tr: trCatalog,
en: enCatalog,
};
export function getBuiltInCatalog(locale: string): TranslationCatalog | null {
return BUILT_IN_CATALOGS[locale] ?? null;
}
export function pickNamespaces(
catalog: TranslationCatalog,
namespaces: readonly I18nNamespace[],
): Partial<TranslationCatalog> {
return Object.fromEntries(
namespaces.map((namespace) => [namespace, catalog[namespace] ?? {}]),
) as Partial<TranslationCatalog>;
}
export function flattenCatalog(
catalog: Partial<TranslationCatalog>,
namespaces: readonly I18nNamespace[],
): Record<string, string> {
const entries: Record<string, string> = {};
for (const namespace of namespaces) {
const namespaceCatalog = catalog[namespace] ?? {};
for (const [key, value] of Object.entries(namespaceCatalog)) {
entries[`${namespace}.${key}`] = value;
}
}
return entries;
}
export function compareCatalogKeys(
left: TranslationCatalog,
right: TranslationCatalog,
namespaces: readonly I18nNamespace[],
): { missingInLeft: string[]; missingInRight: string[] } {
const leftKeys = new Set(Object.keys(flattenCatalog(left, namespaces)));
const rightKeys = new Set(Object.keys(flattenCatalog(right, namespaces)));
return {
missingInLeft: [...rightKeys].filter((key) => !leftKeys.has(key)).sort(),
missingInRight: [...leftKeys].filter((key) => !rightKeys.has(key)).sort(),
};
}
+50
View File
@@ -0,0 +1,50 @@
export type ContentTranslationFieldKind = "text" | "textarea";
export type ContentTranslationEntityType =
| "branding"
| "calendar_event"
| "client"
| "planning_section"
| "project"
| "task";
export type ContentTranslationField = {
name: string;
label: string;
kind?: ContentTranslationFieldKind;
required?: boolean;
maxLength?: number;
placeholder?: string;
};
export type ContentTranslationInput = Record<string, Record<string, string | null>>;
export const contentTranslationRegistry = {
project: [
{ name: "name", label: "Proje adı", required: true, maxLength: 200, placeholder: "Örn. Marka web sitesi" },
{ name: "description", label: "Açıklama", kind: "textarea", maxLength: 20_000, placeholder: "Kapsam, hedef veya teslimat notları..." },
{ name: "coverImageAlt", label: "Görsel alt metni", maxLength: 500, placeholder: "Görseli kısaca açıkla" },
],
planning_section: [
{ name: "title", label: "Başlık", required: true, maxLength: 300, placeholder: "Örn. Başarı kriterleri" },
{ name: "content", label: "İçerik", kind: "textarea", maxLength: 50_000, placeholder: "Kısa notlar, kriterler, renkler, tipografi kararları..." },
],
task: [
{ name: "title", label: "Başlık", required: true, maxLength: 300, placeholder: "Örn. Ana sayfa wireframe revizyonu" },
{ name: "description", label: "Açıklama", kind: "textarea", maxLength: 20_000, placeholder: "Kapsam, not veya teslim kriterleri..." },
],
branding: [
{ name: "portalWelcome", label: "Portal karşılama metni", kind: "textarea", maxLength: 10_000 },
{ name: "portalFooter", label: "Portal footer metni", kind: "textarea", maxLength: 5_000 },
],
calendar_event: [
{ name: "title", label: "Başlık", required: true, maxLength: 300 },
{ name: "description", label: "Açıklama", kind: "textarea", maxLength: 20_000 },
],
client: [
{ name: "notes", label: "Notlar", kind: "textarea", maxLength: 10_000 },
],
} satisfies Record<ContentTranslationEntityType, ContentTranslationField[]>;
export function contentInputName(locale: string, field: string) {
return `i18n.${locale}.${field}`;
}
+13
View File
@@ -0,0 +1,13 @@
import { enUS, fr, tr } from "date-fns/locale";
export function getDateFnsLocale(locale: string) {
const normalized = locale.split("-")[0];
if (normalized === "tr") return tr;
if (normalized === "fr") return fr;
return enUS;
}
export function getDocumentDateFnsLocale() {
if (typeof document === "undefined") return tr;
return getDateFnsLocale(document.documentElement.lang || "tr");
}
+72
View File
@@ -0,0 +1,72 @@
import type { TranslationValues } from "./types";
const PLURAL_PATTERN = /\{(\w+),\s*plural,\s*one\s*\{([^{}]*)\}\s*other\s*\{([^{}]*)\}\s*\}/g;
const VALUE_PATTERN = /\{(\w+)\}/g;
export function interpolateMessage(
message: string,
values: TranslationValues = {},
locale = "tr",
): string {
const pluralized = message.replace(
PLURAL_PATTERN,
(_match, key: string, one: string, other: string) => {
const count = Number(values[key] ?? 0);
const category = new Intl.PluralRules(toIntlLocale(locale)).select(count);
const template = category === "one" ? one : other;
return template.replaceAll("#", String(count));
},
);
return pluralized.replace(VALUE_PATTERN, (_match, key: string) => {
const value = values[key];
if (value === null || value === undefined) return "";
if (value instanceof Date) return formatDate(value, locale);
return String(value);
});
}
export function formatDate(
value: Date | string | number,
locale: string,
options: Intl.DateTimeFormatOptions = { day: "2-digit", month: "long", year: "numeric" },
): string {
return new Intl.DateTimeFormat(toIntlLocale(locale), options).format(new Date(value));
}
export function formatDateTime(
value: Date | string | number,
locale: string,
options: Intl.DateTimeFormatOptions = {
day: "2-digit",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
},
): string {
return new Intl.DateTimeFormat(toIntlLocale(locale), options).format(new Date(value));
}
export function formatNumber(
value: number,
locale: string,
options?: Intl.NumberFormatOptions,
): string {
return new Intl.NumberFormat(toIntlLocale(locale), options).format(value);
}
export function formatMoney(
amountMinor: number,
currency: string,
locale: string,
): string {
return new Intl.NumberFormat(toIntlLocale(locale), {
style: "currency",
currency,
}).format(amountMinor / 100);
}
export function toIntlLocale(locale: string): string {
return locale === "tr" ? "tr-TR" : locale === "en" ? "en-US" : locale;
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./catalog";
export * from "./format";
export * from "./translator";
export * from "./types";
+22
View File
@@ -0,0 +1,22 @@
import { interpolateMessage } from "./format";
import type { TranslationValues } from "./types";
export type Translator = {
locale: string;
messages: Record<string, string>;
t: (key: string, values?: TranslationValues) => string;
};
export function createTranslatorFromMessages(
locale: string,
messages: Record<string, string>,
): Translator {
return {
locale,
messages,
t(key, values) {
const message = messages[key] ?? key;
return interpolateMessage(message, values, locale);
},
};
}
+34
View File
@@ -0,0 +1,34 @@
export const I18N_NAMESPACES = [
"common",
"auth",
"navigation",
"dashboard",
"clients",
"projects",
"tasks",
"calendar",
"finance",
"journal",
"chat",
"settings",
"portal",
"status",
"validation",
"api",
] as const;
export type I18nNamespace = (typeof I18N_NAMESPACES)[number];
export type LocaleCode = string;
export type TextDirection = "ltr" | "rtl";
export type TranslationValues = Record<string, string | number | boolean | Date | null | undefined>;
export type TranslationCatalog = Record<I18nNamespace, Record<string, string>>;
export type LocaleDescriptor = {
code: LocaleCode;
name: string;
nativeName: string;
status: "draft" | "active" | "archived" | "test";
fallbackLocale: LocaleCode | null;
textDirection: TextDirection;
builtIn: boolean;
};