diff --git a/common/.gitkeep b/common/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/common/.gitkeep @@ -0,0 +1 @@ + diff --git a/common/core/auth/.gitkeep b/common/core/auth/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/common/core/auth/.gitkeep @@ -0,0 +1 @@ + diff --git a/common/core/i18n/index.ts b/common/core/i18n/index.ts new file mode 100644 index 00000000..77517663 --- /dev/null +++ b/common/core/i18n/index.ts @@ -0,0 +1,11 @@ +export { createTomlTranslator } from "./loader"; +export { + DEFAULT_LOCALE, + LOCALE_STORAGE_KEY, + SUPPORTED_LOCALES, + type Locale, + type TomlObject, + type TomlValue, + type TranslatorInput, + type TranslatorOptions, +} from "./types"; diff --git a/common/core/i18n/loader.ts b/common/core/i18n/loader.ts new file mode 100644 index 00000000..71d1decd --- /dev/null +++ b/common/core/i18n/loader.ts @@ -0,0 +1,176 @@ +import { + DEFAULT_LOCALE, + LOCALE_STORAGE_KEY, + SUPPORTED_LOCALES, + type Locale, + type TomlObject, + type TomlValue, + type TranslatorInput, + type TranslatorOptions, +} from "./types"; + +function mergeTomlObjects(base: TomlObject, override: TomlObject): TomlObject { + const result: TomlObject = { ...base }; + + for (const [key, value] of Object.entries(override)) { + const currentValue = result[key]; + if ( + typeof currentValue === "object" && + currentValue !== null && + typeof value === "object" && + value !== null + ) { + result[key] = mergeTomlObjects(currentValue as TomlObject, value); + continue; + } + result[key] = value; + } + + return result; +} + +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; +} + +function formatTemplate( + template: string, + vars?: Record, + options?: TranslatorOptions, +): string { + const normalizedTemplate = options?.normalizeEscapedNewlines + ? template.replace(/\\n/g, "\n") + : template; + + if (!vars) { + return normalizedTemplate; + } + + return normalizedTemplate.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => { + const value = vars[key]; + if (value === undefined || value === null) { + return match; + } + return String(value); + }); +} + +export function createTomlTranslator( + input: TranslatorInput, + options?: TranslatorOptions, +) { + const translations: Record = { + ko: input.ko + .map((raw) => parseToml(raw)) + .reduce((merged, current) => mergeTomlObjects(merged, current), {}), + en: input.en + .map((raw) => parseToml(raw)) + .reduce((merged, current) => mergeTomlObjects(merged, current), {}), + }; + + return 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, options); + } + return formatTemplate(fallback ?? key, vars, options); + }; +} diff --git a/common/core/i18n/types.ts b/common/core/i18n/types.ts new file mode 100644 index 00000000..b8db5ec3 --- /dev/null +++ b/common/core/i18n/types.ts @@ -0,0 +1,20 @@ +export const LOCALE_STORAGE_KEY = "locale"; +export const DEFAULT_LOCALE = "ko"; +export const SUPPORTED_LOCALES = ["ko", "en"] as const; + +export type Locale = (typeof SUPPORTED_LOCALES)[number]; + +export type TomlValue = string | TomlObject; + +export interface TomlObject { + [key: string]: TomlValue; +} + +export interface TranslatorOptions { + normalizeEscapedNewlines?: boolean; +} + +export interface TranslatorInput { + en: string[]; + ko: string[]; +} diff --git a/common/core/query/.gitkeep b/common/core/query/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/common/core/query/.gitkeep @@ -0,0 +1 @@ + diff --git a/common/core/session/.gitkeep b/common/core/session/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/common/core/session/.gitkeep @@ -0,0 +1 @@ + diff --git a/common/core/utils/.gitkeep b/common/core/utils/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/common/core/utils/.gitkeep @@ -0,0 +1 @@ + diff --git a/common/locales/en.toml b/common/locales/en.toml new file mode 100644 index 00000000..73d530b6 --- /dev/null +++ b/common/locales/en.toml @@ -0,0 +1,77 @@ +[msg.common] +error = "Error" +loading = "Loading..." +no_description = "No Description." +parsing = "Parsing data..." +requesting = "Requesting..." +saving = "Saving..." +unknown_error = "unknown error" + +[ui.common] +add = "Add" +all = "All" +admin_only = "Admin Only" +assign = "Assign" +back = "Back" +back_to_login = "Back to login" +cancel = "Cancel" +change_file = "Change File" +clear_search = "Clear Search" +close = "Close" +collapse = "Collapse" +confirm = "Confirm" +copy = "Copy" +create = "Create" +delete = "Delete" +details = "Details" +disabled = "Disabled" +edit = "Edit" +enabled = "Enabled" +export = "Export" +fail = "Fail" +go_home = "Go Home" +view = "View" +hyphen = "-" +manage = "Manage" +na = "N/A" +never = "Never" +next = "Next" +none = "None" +page_of = "Page {{page}} of {{total}}" +prev = "Prev" +previous = "Previous" +qr = "QR" +reset = "Reset" +read_only = "Read Only" +refresh = "Refresh" +remove = "Remove" +resend = "Resend" +retry = "Retry" +save = "Save" +search = "Search" +select = "Select" +select_file = "Select File" +select_placeholder = "Select Placeholder" +show_more = "Show More" +language = "Language" +language_ko = "Korean" +language_en = "English" +success = "Success" +theme_dark = "Dark" +theme_light = "Light" +theme_toggle = "Theme Toggle" +unknown = "Unknown" + +[ui.common.badge] +admin_only = "Admin only" +command_only = "Command only" +system = "System" + +[ui.common.status] +active = "Active" +blocked = "Blocked" +failure = "Failure" +inactive = "Inactive" +ok = "Ok" +pending = "Pending" +success = "Success" diff --git a/common/locales/ko.toml b/common/locales/ko.toml new file mode 100644 index 00000000..f5cf7c74 --- /dev/null +++ b/common/locales/ko.toml @@ -0,0 +1,77 @@ +[msg.common] +error = "오류가 발생했습니다." +loading = "로딩 중..." +no_description = "설명이 없습니다." +parsing = "데이터 파싱 중..." +requesting = "요청 중..." +saving = "저장 중..." +unknown_error = "알 수 없는 오류" + +[ui.common] +add = "추가" +all = "전체" +admin_only = "관리자 전용" +assign = "할당" +back = "돌아가기" +back_to_login = "로그인으로 돌아가기" +cancel = "취소" +change_file = "파일 변경" +clear_search = "검색 초기화" +close = "닫기" +collapse = "접기" +confirm = "확인" +copy = "복사" +create = "생성" +delete = "삭제" +details = "상세정보" +disabled = "사용 안 함" +edit = "편집" +enabled = "사용" +export = "내보내기" +fail = "실패" +go_home = "홈으로" +view = "보기" +hyphen = "-" +manage = "관리" +na = "N/A" +never = "Never" +next = "다음" +none = "없음" +page_of = "Page {{page}} of {{total}}" +prev = "이전" +previous = "이전" +qr = "QR" +reset = "초기화" +read_only = "읽기 전용" +refresh = "새로고침" +remove = "제외" +resend = "재발송" +retry = "다시 시도" +save = "저장" +search = "검색" +select = "선택" +select_file = "파일 선택" +select_placeholder = "선택하세요" +show_more = "+ 더보기" +language = "언어" +language_ko = "한국어" +language_en = "English" +success = "성공" +theme_dark = "Dark" +theme_light = "Light" +theme_toggle = "테마 전환" +unknown = "Unknown" + +[ui.common.badge] +admin_only = "Admin only" +command_only = "Command only" +system = "System" + +[ui.common.status] +active = "활성" +blocked = "차단됨" +failure = "실패" +inactive = "비활성" +ok = "정상" +pending = "준비 중" +success = "성공" diff --git a/common/locales/template.toml b/common/locales/template.toml new file mode 100644 index 00000000..1cb9e149 --- /dev/null +++ b/common/locales/template.toml @@ -0,0 +1,77 @@ +[msg.common] +error = "" +loading = "" +no_description = "" +parsing = "" +requesting = "" +saving = "" +unknown_error = "" + +[ui.common] +add = "" +all = "" +admin_only = "" +assign = "" +back = "" +back_to_login = "" +cancel = "" +change_file = "" +clear_search = "" +close = "" +collapse = "" +confirm = "" +copy = "" +create = "" +delete = "" +details = "" +disabled = "" +edit = "" +enabled = "" +export = "" +fail = "" +go_home = "" +view = "" +hyphen = "" +manage = "" +na = "" +never = "" +next = "" +none = "" +page_of = "" +prev = "" +previous = "" +qr = "" +reset = "" +read_only = "" +refresh = "" +remove = "" +resend = "" +retry = "" +save = "" +search = "" +select = "" +select_file = "" +select_placeholder = "" +show_more = "" +language = "" +language_ko = "" +language_en = "" +success = "" +theme_dark = "" +theme_light = "" +theme_toggle = "" +unknown = "" + +[ui.common.badge] +admin_only = "" +command_only = "" +system = "" + +[ui.common.status] +active = "" +blocked = "" +failure = "" +inactive = "" +ok = "" +pending = "" +success = "" diff --git a/common/shell/.gitkeep b/common/shell/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/common/shell/.gitkeep @@ -0,0 +1 @@ + diff --git a/common/theme/.gitkeep b/common/theme/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/common/theme/.gitkeep @@ -0,0 +1 @@ + diff --git a/common/ui/.gitkeep b/common/ui/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/common/ui/.gitkeep @@ -0,0 +1 @@ +