const LOCALE_STORAGE_KEY = "locale"; const DEFAULT_LOCALE = "ko"; const SUPPORTED_LOCALES = ["ko", "en"] as const; type Locale = (typeof SUPPORTED_LOCALES)[number]; type TomlValue = string | TomlObject; interface TomlObject { [key: string]: TomlValue; } function isSupportedLocale(value: string): value is Locale { return (SUPPORTED_LOCALES as readonly string[]).includes(value); } function parseToml(raw: string): TomlObject { const lines = raw.split(/\r?\n/); const root: TomlObject = {}; let currentPath: string[] = []; for (const rawLine of lines) { const line = rawLine.trim(); if (!line || line.startsWith("#")) { continue; } if (line.startsWith("[") && line.endsWith("]")) { const sectionName = line.slice(1, -1).trim(); currentPath = sectionName ? sectionName .split(".") .map((part) => part.trim()) .filter(Boolean) : []; continue; } const eqIndex = line.indexOf("="); if (eqIndex === -1) { continue; } const key = line.slice(0, eqIndex).trim(); const valueRaw = line.slice(eqIndex + 1).trim(); if (!key) { continue; } let value = valueRaw; if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } let cursor: TomlObject = root; for (const section of currentPath) { if (!cursor[section] || typeof cursor[section] === "string") { cursor[section] = {}; } cursor = cursor[section] as TomlObject; } cursor[key] = value; } return root; } function getValue(target: TomlObject, key: string): string | undefined { const parts = key.split("."); let cursor: TomlValue = target; for (const part of parts) { if (typeof cursor !== "object" || cursor === null) { return undefined; } cursor = (cursor as TomlObject)[part]; if (cursor === undefined) { return undefined; } } return typeof cursor === "string" ? cursor : undefined; } function detectLocale(): Locale { if (typeof window === "undefined") { return DEFAULT_LOCALE; } const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY); if (stored && isSupportedLocale(stored)) { return stored; } const pathLocale = window.location.pathname.split("/")[1]; if (pathLocale && isSupportedLocale(pathLocale)) { return pathLocale; } const browserLang = window.navigator.language.toLowerCase(); if (browserLang.startsWith("ko")) { return "ko"; } return DEFAULT_LOCALE; } // eslint-disable-next-line import/no-unresolved import enRaw from "../locales/en.toml?raw"; // Vite ?raw import는 런타임 상수로 번들됩니다. // eslint-disable-next-line import/no-unresolved import koRaw from "../locales/ko.toml?raw"; const translations: Record = { ko: parseToml(koRaw), en: parseToml(enRaw), }; function formatTemplate( template: string, vars?: Record, ): string { if (!vars) { return template; } return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => { const value = vars[key]; if (value === undefined || value === null) { return match; } return String(value); }); } export function t( key: string, fallback?: string, vars?: Record, ): string { const locale = detectLocale(); const value = getValue(translations[locale], key); if (value && value.length > 0) { return formatTemplate(value, vars); } return formatTemplate(fallback ?? key, vars); }