feat: add core i18n runtime and catalogs
This commit is contained in:
@@ -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());
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./catalog";
|
||||
export * from "./format";
|
||||
export * from "./translator";
|
||||
export * from "./types";
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user