/** * TranslationEngine.ts * * G7 템플릿 엔진의 다국어 번역 처리 엔진 * * 주요 기능: * - $t:key 문법 파싱 및 번역 텍스트 치환 * - 파라미터 치환 (파이프 방식, 객체 방식) * - 다국어 파일 로드 및 캐싱 * - 폴백 규칙 (ko → en → 키 자체) * * @module TranslationEngine */ import { createLogger } from '../utils/Logger'; import { suffixed, extStaticUrl } from '../support/assetUrl'; import { fetchStaticFirst } from '../support/fetchStaticFirst'; // 순환 import (DataBindingEngine → TranslationEngine → DataBindingEngine) 이지만 // 양쪽 모두 모듈 평가 시점이 아니라 메서드 실행 시점에만 서로를 참조하므로 // live binding 이 채워진 뒤에 사용된다. import { dataBindingEngine } from './DataBindingEngine'; import { hasPipes } from './PipeRegistry'; const logger = createLogger('TranslationEngine'); // ============================================================================ // 타입 정의 // ============================================================================ /** * 다국어 번역 딕셔너리 */ export interface TranslationDictionary { [key: string]: string | TranslationDictionary; } /** * 번역 파라미터 */ export interface TranslationParams { [key: string]: string | number; } /** * 번역 옵션 */ export interface TranslationOptions { /** 기본 로케일 (기본값: 'ko') */ defaultLocale?: string; /** 폴백 로케일 (기본값: 'en') */ fallbackLocale?: string; /** 캐시 만료 시간 (밀리초, 기본값: 300000 = 5분) */ cacheTTL?: number; } /** * 캐시 엔트리 */ interface CacheEntry { data: TranslationDictionary; timestamp: number; } /** * 번역 컨텍스트 */ export interface TranslationContext { /** 템플릿 ID */ templateId: string; /** 현재 로케일 */ locale: string; /** API 베이스 URL */ apiBaseUrl?: string; } /** * 번역 에러 클래스 */ export class TranslationError extends Error { constructor( message: string, public key?: string, public locale?: string ) { super(message); this.name = 'TranslationError'; } } // ============================================================================ // TranslationEngine 클래스 // ============================================================================ /** * 다국어 번역 처리 엔진 (싱글톤) * * 로케일 변경 시에도 이미 로드된 번역을 재사용합니다. * * @example * const engine = TranslationEngine.getInstance(); * await engine.loadTranslations('template-1', 'ko'); * const text = engine.translate('$t:dashboard.title'); // "대시보드" */ export class TranslationEngine { /** 싱글톤 인스턴스 */ private static instance: TranslationEngine | null = null; /** * 번역 문법 패턴: $t:key, $t:defer:key, 또는 $t:key|param=value (공백 포함 값 지원). * * `defer:` prefix 는 인라인 형태(`{{id}} — $t:defer:key|...`) 에서도 흡수되어야 하므로 * 키 그룹 앞에 옵셔널 매칭을 둔다 — 키 문자 클래스 [a-zA-Z0-9._-] 가 콜론을 미포함하여 * 과거 `$t:defer` 만 매칭되고 나머지가 raw 로 남던 회귀 차단 (이슈 #302). */ private static readonly TRANSLATION_PATTERN = /\$t:(?:defer:)?([a-zA-Z0-9._-]+)(\|(?:(?!\$t:).)+)?/g; /** * 파라미터 값 내 중첩 $t: 토큰 패턴 * * `|status=$t:enums.issuing` 형태에서 `=$t:key` 부분을 매칭합니다. * TRANSLATION_PATTERN이 `$t:` 앞에서 매칭을 중단하므로, * 메인 해석 전에 파라미터 값 위치의 $t: 토큰을 먼저 번역합니다. * * @since engine-v1.25.0 */ private static readonly NESTED_TRANSLATION_PARAM_PATTERN = /=(\$t:([a-zA-Z0-9._-]+))(?=[|&\s]|$)/g; /** 파라미터 파싱 패턴 (|나 &로 구분된 key=value 형식) */ private static readonly PARAM_PATTERN = /([^=|&]+)=([^|&]+)/g; /** 다국어 번역 캐시 */ private cache: Map = new Map(); /** * 싱글톤 인스턴스를 반환합니다. * * @param options 번역 옵션 (최초 생성 시에만 적용) */ static getInstance(options: TranslationOptions = {}): TranslationEngine { if (!TranslationEngine.instance) { TranslationEngine.instance = new TranslationEngine(options); } return TranslationEngine.instance; } /** * 싱글톤 인스턴스를 리셋합니다. (테스트용) */ static resetInstance(): void { TranslationEngine.instance = null; } /** 현재 로드된 번역 딕셔너리 */ private translations: Map = new Map(); /** 기본 옵션 */ private options: Required; /** 확장 기능 캐시 버전 (모듈/플러그인 활성화 시 갱신됨) */ private cacheVersion: number = 0; /** * TranslationEngine 생성자 */ constructor(options: TranslationOptions = {}) { this.options = { defaultLocale: options.defaultLocale || 'ko', fallbackLocale: options.fallbackLocale || 'en', cacheTTL: options.cacheTTL || 300000, // 5분 }; } /** * 캐시 버전 설정 * * 모듈/플러그인 활성화 시 캐시 버전이 변경되어 * 새로운 다국어 데이터를 서버에서 가져오도록 합니다. * * @param version 캐시 버전 (타임스탬프) * * @since engine-v1.38.1 활성 `translations` 맵은 비우지 않음 — 이전에는 * `clearCache()` 로 `translations` 까지 비웠기 때문에, `reloadExtensions` * 와 같은 병렬 재동기화 중 새 `loadTranslations()` 가 끝나기 전에 실행되는 * `translate()` 호출이 빈 사전을 만나 raw $t:key 를 반환하는 경합이 있었음. * 이제 TTL 캐시(`this.cache`)만 비우고, 활성 사전(`this.translations`)은 * `loadTranslations` 가 새 데이터를 `set()` 으로 원자 교체할 때까지 유지된다. */ setCacheVersion(version: number): void { if (this.cacheVersion !== version) { logger.log('Cache version updated:', this.cacheVersion, '->', version); this.cacheVersion = version; // TTL 캐시만 클리어 → getFromCache 미스 → 다음 loadTranslations 가 새 버전 URL 로 fetch. // 활성 translations 맵은 fetch 완료 시 원자적으로 교체되므로 건드리지 않음. this.cache.clear(); } } /** * 현재 캐시 버전 반환 */ getCacheVersion(): number { return this.cacheVersion; } /** * 템플릿의 다국어 파일을 로드합니다. * * @param templateId 템플릿 ID * @param locale 로케일 (예: 'ko', 'en') * @param apiBaseUrl API 베이스 URL (기본값: '/api') */ async loadTranslations( templateId: string, locale: string, apiBaseUrl: string = '/api', bustCache: boolean = false ): Promise { const cacheKey = `${templateId}:${locale}`; // 캐시 확인 (bustCache가 true면 캐시 무시) if (!bustCache) { const cached = this.getFromCache(cacheKey); if (cached) { this.translations.set(cacheKey, cached); return cached; } } try { // API 호출 (캐시 버전 쿼리 파라미터 추가, bustCache가 true면 타임스탬프도 추가) // 자산 URL 모드에 따라 `.json` 접미사가 붙거나 빠진다. // 이 경로는 편집기 전용이 아니라 **모든 페이지가 타는 런타임 공통 경로**라, // 여기만 확장자를 직접 조립하면 extensionless 환경에서 다국어가 통째로 404 가 되어 // 이슈 #486 의 원래 증상(화면이 온전히 뜨지 않음)이 다국어 계층에서 재현된다. const extraParams: string[] = []; if (bustCache) { extraParams.push(`_=${Date.now()}`); } const url = suffixed( `${apiBaseUrl}/templates/${templateId}/lang/${locale}`, 'json', this.cacheVersion > 0 ? this.cacheVersion : null, extraParams.length > 0 ? extraParams.join('&') : undefined, ); // 정적 게시본(bake) 우선 (#122) — bustCache 재로드는 목적상 정적 캐시를 // 우회해야 하므로 legacy 직행. miss 는 fetchStaticFirst 가 legacy 로 폴백. const staticUrl = ! bustCache && this.cacheVersion > 0 ? extStaticUrl(`templates/${templateId}/lang/${locale}.json`, this.cacheVersion) : null; const response = staticUrl !== null ? await fetchStaticFirst(staticUrl, url, { label: `lang/${locale}.json` }) : await fetch(url); if (!response.ok) { throw new TranslationError( `Failed to load translations: ${response.statusText}`, undefined, locale ); } const result = await response.json(); // API가 번역 데이터를 직접 반환 (result.data가 아님) const dictionary: TranslationDictionary = result; // 캐시 저장 this.saveToCache(cacheKey, dictionary); this.translations.set(cacheKey, dictionary); return dictionary; } catch (error) { throw new TranslationError( `Failed to fetch translations for ${locale}: ${ error instanceof Error ? error.message : String(error) }`, undefined, locale ); } } /** * 문자열 내 모든 번역 표현식을 치환합니다. * * @param text 번역할 텍스트 * @param context 번역 컨텍스트 * @param dataContext 데이터 컨텍스트 (파라미터 바인딩용) */ resolveTranslations( text: string, context: TranslationContext, dataContext?: any ): string { // 1단계: 파라미터 값 내 중첩 $t: 토큰을 먼저 해석 (engine-v1.25.0) // 예: |status=$t:enums.coupon_issue_status.issuing → |status=발급중 // TRANSLATION_PATTERN이 $t: 앞에서 매칭을 중단하므로, 사전 해석 필요 // 루프: 해석 결과에 다시 =$t:가 포함될 수 있으므로 변화 없을 때까지 반복 (깊이 제한) let processed = text; const MAX_NESTED_DEPTH = 5; let depth = 0; while (depth < MAX_NESTED_DEPTH && processed.includes('=$t:')) { const prev = processed; processed = processed.replace( TranslationEngine.NESTED_TRANSLATION_PARAM_PATTERN, (_match, _fullToken, key) => { return '=' + this.translate(key, context, undefined, dataContext); } ); if (processed === prev) break; // 변화 없으면 종료 (무한 루프 방지) depth++; } // 2단계: 메인 $t: 해석 return processed.replace( TranslationEngine.TRANSLATION_PATTERN, (_match, key, paramsStr) => { // 파라미터에서 trailing 비파라미터 텍스트 분리 const { cleanedParams, trailing } = this.separateTrailingText(paramsStr); // 번역 수행 후 trailing 텍스트 붙이기 return this.translate(key, context, cleanedParams, dataContext) + trailing; } ); } /** * 번역 키를 실제 번역 텍스트로 변환합니다. * * @param key 번역 키 (예: 'dashboard.title') * @param context 번역 컨텍스트 * @param paramsStr 파라미터 문자열 (예: '|count=5') * @param dataContext 데이터 컨텍스트 */ translate( key: string, context: TranslationContext, paramsStr?: string, dataContext?: any ): string { // 번역 텍스트 조회 (폴백 포함) const text = this.getTranslation(key, context); // 파라미터 문자열의 trailing 비파라미터 텍스트 정리 const cleanedParamsStr = paramsStr ? this.cleanParamsStr(paramsStr) : paramsStr; // 파라미터 치환 if (cleanedParamsStr) { const params = this.parseParams(cleanedParamsStr, dataContext); return this.replaceParams(text, params); } return text; } /** * 번역 텍스트 조회 (폴백 규칙 적용) * * @param key 번역 키 * @param context 번역 컨텍스트 */ private getTranslation( key: string, context: TranslationContext ): string { const { templateId, locale } = context; // 1. 현재 로케일에서 조회 const currentKey = `${templateId}:${locale}`; const current = this.translations.get(currentKey); if (current) { const value = this.getNestedValue(current, key); // 빈 문자열도 유효한 번역 값이므로 !== null 체크 if (value !== null) return value; } // 2. 폴백 로케일에서 조회 if (locale !== this.options.fallbackLocale) { const fallbackKey = `${templateId}:${this.options.fallbackLocale}`; const fallback = this.translations.get(fallbackKey); if (fallback) { const value = this.getNestedValue(fallback, key); if (value !== null) return value; } } // 3. 키 자체 반환 (번역 없음) return key; } /** * 중첩된 객체에서 값을 조회합니다. * * @param obj 번역 딕셔너리 * @param path 경로 (예: 'dashboard.title') */ private getNestedValue( obj: TranslationDictionary, path: string ): string | null { const keys = path.split('.'); let current: any = obj; for (const key of keys) { if (current == null || typeof current !== 'object') { return null; } current = current[key]; } // 빈 문자열('')도 유효한 번역 값으로 처리 return typeof current === 'string' ? current : null; } /** * 단일 번역 키 값을 활성 사전에 낙관적으로 주입합니다. * * 레이아웃 편집기 인라인 편집으로 커스텀 키를 생성/수정한 직후, 서버 lang 을 재fetch 하는 * 비동기 동안 캔버스가 그 키를 raw(또는 옛 값)로 렌더하지 않도록, 입력한 값을 즉시 사전에 * 반영한다. 점선 경로(`custom.layout.2`)를 중첩 객체로 풀어 set 하며, 사전이 아직 없으면 * 생성한다. 이후 `loadTranslations(...,true)` 가 서버 권위 값으로 사전을 원자 교체한다. * * 사용자 페이지(비편집)에는 호출되지 않는다 — 편집기 전용 낙관적 경로. * * @param templateId 템플릿 식별자 * @param locale 대상 로케일 * @param key 번역 키 (점선 경로, `$t:` 접두 없이) * @param value 주입할 값 * @return 없음 */ setTranslationValue( templateId: string, locale: string, key: string, value: string ): void { const cacheKey = `${templateId}:${locale}`; const dict = this.translations.get(cacheKey) ?? {}; const parts = key.split('.'); let cursor: Record = dict as Record; for (let i = 0; i < parts.length - 1; i += 1) { const seg = parts[i]; const existing = cursor[seg]; if (existing == null || typeof existing !== 'object' || Array.isArray(existing)) { cursor[seg] = {}; } cursor = cursor[seg] as Record; } cursor[parts[parts.length - 1]] = value; this.translations.set(cacheKey, dict as TranslationDictionary); } /** * 파라미터 문자열을 파싱합니다. * * `{{...}}` 내부의 `|`와 `&`는 구분자로 취급하지 않습니다. * 이를 통해 `||`, `&&` 등의 JavaScript 연산자를 표현식 내에서 사용할 수 있습니다. * * @param paramsStr 파라미터 문자열 (예: '|count=5&name=홍길동') * @param dataContext 데이터 컨텍스트 */ private parseParams( paramsStr: string, dataContext?: any ): TranslationParams { const params: TranslationParams = {}; // 파이프 제거 const cleanStr = paramsStr.startsWith('|') ? paramsStr.slice(1) : paramsStr; // {{...}} 표현식을 임시로 치환하여 내부의 |와 &를 보호 // 패턴: {{ 로 시작하고 }} 로 끝나며, 내부에 단독 }는 허용 const placeholders: string[] = []; const protectedStr = cleanStr.replace(/\{\{(?:[^}]|\}(?!\}))*\}\}/g, (match) => { placeholders.push(match); return `__PLACEHOLDER_${placeholders.length - 1}__`; }); // 파라미터 추출 const matches = protectedStr.matchAll(TranslationEngine.PARAM_PATTERN); for (const match of matches) { const [, key, value] = match; // placeholder를 원래 표현식으로 복원 const restoredValue = value.replace(/__PLACEHOLDER_(\d+)__/g, (_, idx) => { return placeholders[parseInt(idx, 10)]; }); params[key.trim()] = this.resolveParamValue(restoredValue.trim(), dataContext); } return params; } /** * 파라미터 값을 해석합니다. * * @param value 파라미터 값 (예: '{{user.name}}' 또는 '홍길동') * @param dataContext 데이터 컨텍스트 */ private resolveParamValue(value: string, dataContext?: any): string { // {{variable}} 패턴 처리 if (value.startsWith('{{') && value.endsWith('}}')) { const expression = value.slice(2, -2).trim(); // 파이프 필터(`{{x | datetime}}`)는 표현식 평가기가 모른다 — `|` 를 비트 연산자로 // 읽어 평가에 실패하고, 아래 catch 가 빈 문자열을 돌려주므로 문장에서 값만 조용히 // 사라진다("유효시간 까지"). 파이프 전용 평가기로 먼저 처리한다. // @since engine-v1.65.0 if (hasPipes(expression) && dataContext) { try { const piped = dataBindingEngine.evaluatePipeExpression(expression, dataContext); return String(piped ?? ''); } catch (error) { logger.error('Pipe expression evaluation failed:', expression, error); return ''; } } // 복잡한 표현식인지 확인 (연산자, 괄호, 메서드 호출 등 포함) // 산술 연산자(+, -, *, /, %), 비교 연산자(<, >, =), 논리 연산자, 공백(피연산자 분리)도 포함 const isComplexExpression = /[|&()[\]!?:+\-*/%<>=\s]/.test(expression); if (isComplexExpression && dataContext) { // 평가는 엔진에 위임한다. 종전에는 `new Function(...Object.keys(dataContext))` 로 // 자체 평가해 `$localized(...)`/`$t(...)`/`$uuid()` 헬퍼와 optional chaining 전처리, // 표현식 함수 캐시를 쓰지 못했다. 실패 시 조용히 빈 문자열이 되어 번역 문구에서 // 값만 사라졌다. // (컨텍스트 키가 식별자가 아닐 때의 실패는 엔진 쪽 문제였고 engine-v1.56.2 에서 // DataBindingEngine 이 그런 키를 제외하도록 고쳤다.) // @since engine-v1.56.1 try { const resolved = dataBindingEngine.evaluateExpression(expression, dataContext); return String(resolved ?? ''); } catch (error) { logger.error('Expression evaluation failed:', expression, error); return ''; } } else { // 단순 경로로 처리 const resolved = this.getNestedDataValue(dataContext, expression); return String(resolved ?? ''); } } return value; } /** * 파라미터 문자열에서 trailing 비파라미터 텍스트를 분리합니다. * * 파라미터 형식(key=value)이 아닌 마지막 부분을 trailing으로 분리합니다. * 단, 파라미터 값 내의 공백은 값의 일부로 취급합니다. * * 예: "|value1=A / " -> { cleanedParams: "|value1=A", trailing: " / " } * 예: "|name=Admin Basic" -> { cleanedParams: "|name=Admin Basic", trailing: "" } * 예: "|name=Admin Basic / " -> { cleanedParams: "|name=Admin Basic", trailing: " / " } * * `{{...}}` 내부의 텍스트는 trailing으로 처리하지 않습니다. * * @param paramsStr 파라미터 문자열 * @returns 정리된 파라미터와 trailing 텍스트 */ private separateTrailingText(paramsStr?: string): { cleanedParams: string | undefined; trailing: string; } { if (!paramsStr) { return { cleanedParams: undefined, trailing: '' }; } // {{...}}로 끝나는 경우 trailing 처리하지 않음 if (paramsStr.trimEnd().endsWith('}}')) { return { cleanedParams: paramsStr, trailing: '' }; } // 파이프 제거 후 분석 const cleanStr = paramsStr.startsWith('|') ? paramsStr.slice(1) : paramsStr; // key=value 패턴이 없으면 전체가 trailing if (!cleanStr.includes('=')) { return { cleanedParams: undefined, trailing: paramsStr }; } // 마지막 = 이후의 값에서 | 또는 & 뒤에 key= 형식이 아닌 텍스트가 있는지 확인 // 예: "name=Admin Basic / " 에서 " / "를 분리 // 단, "name=Admin Basic" 에서 " Basic"은 값의 일부이므로 분리하지 않음 // trailing은 마지막 파라미터 값 뒤에 separator(|, &) 없이 // 다른 key=가 오지 않는 텍스트만 해당 // 예: "|a=1 / " -> trailing은 " / " (= 없고, | & 뒤가 아님) // 간단한 휴리스틱: 마지막에 공백 + 특수문자(/, \, 등)로 끝나면 trailing // \p{L}을 사용하여 유니코드 문자(한글 등)도 단어 문자로 인식 const trailingMatch = paramsStr.match(/(\s+[^\p{L}\w=|&\s][^\p{L}\w=]*\s*)$/u); if (trailingMatch) { return { cleanedParams: paramsStr.slice(0, -trailingMatch[1].length), trailing: trailingMatch[1], }; } return { cleanedParams: paramsStr, trailing: '' }; } /** * 파라미터 문자열에서 trailing 비파라미터 텍스트를 제거합니다. * * @param paramsStr 파라미터 문자열 * @returns 정리된 파라미터 문자열 */ private cleanParamsStr(paramsStr: string): string { return this.separateTrailingText(paramsStr).cleanedParams || ''; } /** * 데이터 컨텍스트에서 중첩된 값을 조회합니다. * * @param context 데이터 컨텍스트 * @param path 경로 */ private getNestedDataValue(context: any, path: string): any { if (!context) return undefined; const keys = path.split('.'); let current = context; for (const key of keys) { if (current == null) return undefined; current = current[key]; } return current; } /** * 번역 텍스트 내 파라미터를 치환합니다. * * @param text 번역 텍스트 (예: '총 {count}개의 상품' 또는 '총 {{count}}개의 상품') * @param params 파라미터 맵 */ private replaceParams( text: string, params: TranslationParams ): string { let result = text; for (const [key, value] of Object.entries(params)) { // {{key}} 형식 (이중 중괄호) 먼저 치환 const doublePattern = new RegExp(`\\{\\{${key}\\}\\}`, 'g'); result = result.replace(doublePattern, String(value)); // {key} 형식 (단일 중괄호) 치환 const singlePattern = new RegExp(`\\{${key}\\}`, 'g'); result = result.replace(singlePattern, String(value)); } return result; } /** * 캐시에서 번역 딕셔너리를 조회합니다. * * @param key 캐시 키 */ private getFromCache(key: string): TranslationDictionary | null { const entry = this.cache.get(key); if (!entry) return null; // 캐시 만료 확인 const now = Date.now(); if (now - entry.timestamp > this.options.cacheTTL) { this.cache.delete(key); return null; } return entry.data; } /** * 번역 딕셔너리를 캐시에 저장합니다. * * @param key 캐시 키 * @param data 번역 딕셔너리 */ private saveToCache(key: string, data: TranslationDictionary): void { this.cache.set(key, { data, timestamp: Date.now(), }); } /** * 캐시를 초기화합니다. */ clearCache(): void { this.cache.clear(); this.translations.clear(); } /** * 만료된 캐시 엔트리를 제거합니다. */ pruneCache(): void { const now = Date.now(); const expiredKeys: string[] = []; for (const [key, entry] of this.cache.entries()) { if (now - entry.timestamp > this.options.cacheTTL) { expiredKeys.push(key); } } for (const key of expiredKeys) { this.cache.delete(key); } } /** * 캐시 통계를 조회합니다. */ getCacheStats(): { size: number; expired: number } { const now = Date.now(); let expired = 0; for (const entry of this.cache.values()) { if (now - entry.timestamp > this.options.cacheTTL) { expired++; } } return { size: this.cache.size, expired, }; } } /** * 싱글톤 인스턴스 생성 헬퍼 */ let instance: TranslationEngine | null = null; export function getTranslationEngine( options?: TranslationOptions ): TranslationEngine { if (!instance) { instance = new TranslationEngine(options); } return instance; }