import { createContext, useContext, type ReactNode } from 'react' import { createElement } from 'react' import { en } from './locales/en' import { ko } from './locales/ko' import { ja } from './locales/ja' import { zh } from './locales/zh' import { es } from './locales/es' import { fr } from './locales/fr' import { de } from './locales/de' import { pt } from './locales/pt' import { ru } from './locales/ru' import { vi } from './locales/vi' // ── Types ────────────────────────────────────────────── export type Locale = 'en' | 'ko' | 'ja' | 'zh' | 'es' | 'fr' | 'de' | 'pt' | 'ru' | 'vi' export interface TitledText { title: string body: string } export interface DemoSample { raw: string clean: string } export interface FaqItem { q: string a: string } export interface PlanCopy { name: string desc: string cta: string features: string[] } /** * 사이트의 모든 문구. 컴포넌트에 문자열을 직접 쓰지 않는다. * `{name}` 자리표시자는 `fmt()`로 채운다. */ export interface Translations { meta: { title: string description: string } a11y: { skipToContent: string primaryNav: string openMenu: string closeMenu: string language: string opensInNewTab: string } nav: { features: string how: string privacy: string pricing: string faq: string download: string } hero: { title1: string title2: string subtitle: string hotkeyKey: string hotkeyAction: string trust: string /** 방문자가 Windows가 아닐 때 다운로드 버튼 아래에 보인다. */ windowsOnly: string } demo: { /** figure 의 접근 가능한 이름 */ label: string /** 화면 낭독용 설명. 애니메이션 자체는 aria-hidden 이다. */ description: string note: string windowTitle: string docLine: string /** 캡슐의 처리 중 문구 */ processing: string stateIdle: string stateListening: string statePolishing: string stateDone: string pause: string play: string /** raw 는 띄어쓰기 단위로 한 낱말씩 나타난다. */ samples: [DemoSample, DemoSample] } features: { title: string subtitle: string items: [TitledText, TitledText, TitledText, TitledText, TitledText, TitledText] } how: { title: string subtitle: string steps: [TitledText, TitledText, TitledText, TitledText] } privacy: { title: string subtitle: string points: [TitledText, TitledText, TitledText, TitledText] policyLink: string } pricing: { title: string subtitle: string perMonth: string unlimited: string note: string free: PlanCopy pro: PlanCopy proPlus: PlanCopy } download: { title: string subtitle: string windowsName: string windowsMeta: string cta: string /** {date}: 게시일 (release.ts의 DESKTOP_RELEASE_DATE, 버전 동기화 대상) */ details: string releaseNotes: string checksum: string soonTitle: string soon: [TitledText, TitledText] } /** 사실만 모은 요약. 검색·AI 답변이 그대로 인용할 수 있게 짧은 정의형 문장으로 쓴다. */ facts: { title: string /** {version}·{date}·{pro}·{proPlus} 는 release.ts·plan-catalog 정본에서 채운다. */ rows: [TitledText, TitledText, TitledText, TitledText, TitledText, TitledText, TitledText, TitledText] } faq: { title: string items: [FaqItem, FaqItem, FaqItem, FaqItem, FaqItem, FaqItem, FaqItem, FaqItem] } footer: { description: string privacy: string terms: string deleteAccount: string releaseNotes: string copyright: string } } /** `{key}` 자리표시자를 값으로 바꾼다. */ export function fmt(template: string, values: Record): string { return template.replace(/\{(\w+)\}/g, (match, key: string) => key in values ? String(values[key]) : match, ) } // ── Locale metadata ──────────────────────────────────── export interface LocaleMeta { code: Locale label: string nativeName: string /** Intl 포맷용 BCP 47 태그 */ bcp47: string } export const LOCALES: LocaleMeta[] = [ { code: 'ko', label: 'KO', nativeName: '한국어', bcp47: 'ko-KR' }, { code: 'en', label: 'EN', nativeName: 'English', bcp47: 'en-US' }, { code: 'ja', label: 'JA', nativeName: '日本語', bcp47: 'ja-JP' }, { code: 'zh', label: 'ZH', nativeName: '简体中文', bcp47: 'zh-CN' }, { code: 'es', label: 'ES', nativeName: 'Español', bcp47: 'es-ES' }, { code: 'fr', label: 'FR', nativeName: 'Français', bcp47: 'fr-FR' }, { code: 'de', label: 'DE', nativeName: 'Deutsch', bcp47: 'de-DE' }, { code: 'pt', label: 'PT', nativeName: 'Português', bcp47: 'pt-BR' }, { code: 'ru', label: 'RU', nativeName: 'Русский', bcp47: 'ru-RU' }, { code: 'vi', label: 'VI', nativeName: 'Tiếng Việt', bcp47: 'vi-VN' }, ] // ── Translations map ─────────────────────────────────── export const translations: Record = { en, ko, ja, zh, es, fr, de, pt, ru, vi, } /** 기본 언어. 루트(`/`)가 이 언어이고, 나머지는 `/{code}/` 에 따로 미리 그려진다. */ export const DEFAULT_LOCALE: Locale = 'ko' export function isLocale(value: string): value is Locale { return Object.prototype.hasOwnProperty.call(translations, value) } /** 언어별 페이지 경로. 검색 엔진이 언어마다 다른 주소를 색인하도록 언어를 주소로 나눈다. */ export function localePath(locale: Locale): string { return locale === DEFAULT_LOCALE ? '/' : `/${locale}/` } export function localeMeta(locale: Locale): LocaleMeta { return LOCALES.find((l) => l.code === locale) ?? LOCALES[0] } // ── Context ──────────────────────────────────────────── interface I18nContextValue { locale: Locale bcp47: string t: Translations } const I18nContext = createContext(null) export function useI18n(): I18nContextValue { const ctx = useContext(I18nContext) if (!ctx) throw new Error('useI18n must be used within I18nProvider') return ctx } // ── Provider ─────────────────────────────────────────── interface I18nProviderProps { /** 페이지 언어. 빌드 때 언어별 HTML을 미리 그리고, 브라우저는 같은 언어로 이어받는다. */ locale: Locale children: ReactNode } export function I18nProvider({ locale, children }: I18nProviderProps) { const value = { locale, bcp47: localeMeta(locale).bcp47, t: translations[locale] } return createElement(I18nContext.Provider, { value }, children) }