Files
Gnuboard7/resources/js/core/template-engine/TranslationEngine.ts
T
2026-04-01 10:30:52 +09:00

667 lines
19 KiB
TypeScript

/**
* TranslationEngine.ts
*
* G7 템플릿 엔진의 다국어 번역 처리 엔진
*
* 주요 기능:
* - $t:key 문법 파싱 및 번역 텍스트 치환
* - 파라미터 치환 (파이프 방식, 객체 방식)
* - 다국어 파일 로드 및 캐싱
* - 폴백 규칙 (ko → en → 키 자체)
*
* @module TranslationEngine
*/
import { createLogger } from '../utils/Logger';
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:key|param=value (공백 포함 값 지원) */
private static readonly TRANSLATION_PATTERN = /\$t:([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<string, CacheEntry> = 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<string, TranslationDictionary> = new Map();
/** 기본 옵션 */
private options: Required<TranslationOptions>;
/** 확장 기능 캐시 버전 (모듈/플러그인 활성화 시 갱신됨) */
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 캐시 버전 (타임스탬프)
*/
setCacheVersion(version: number): void {
if (this.cacheVersion !== version) {
logger.log('Cache version updated:', this.cacheVersion, '->', version);
this.cacheVersion = version;
// 캐시 버전 변경 시 기존 캐시 클리어 (새 버전으로 로드 필요)
this.clearCache();
}
}
/**
* 현재 캐시 버전 반환
*/
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<TranslationDictionary> {
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면 타임스탬프도 추가)
let url = `${apiBaseUrl}/templates/${templateId}/lang/${locale}.json`;
// 쿼리 파라미터 구성
const queryParams: string[] = [];
if (this.cacheVersion > 0) {
queryParams.push(`v=${this.cacheVersion}`);
}
if (bustCache) {
queryParams.push(`_=${Date.now()}`);
}
if (queryParams.length > 0) {
url += `?${queryParams.join('&')}`;
}
const response = 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;
}
/**
* 파라미터 문자열을 파싱합니다.
*
* `{{...}}` 내부의 `|`와 `&`는 구분자로 취급하지 않습니다.
* 이를 통해 `||`, `&&` 등의 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();
// 복잡한 표현식인지 확인 (연산자, 괄호, 메서드 호출 등 포함)
// 산술 연산자(+, -, *, /, %), 비교 연산자(<, >, =), 논리 연산자, 공백(피연산자 분리)도 포함
const isComplexExpression = /[|&()[\]!?:+\-*/%<>=\s]/.test(expression);
if (isComplexExpression && dataContext) {
// JavaScript 표현식으로 평가
try {
const func = new Function(...Object.keys(dataContext), `return ${expression}`);
const resolved = func(...Object.values(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;
}