Files
Gnuboard7/resources/js/core/template-engine/TranslationEngine.ts
T
HeuJung 5ba7a83597 feat(core,engine): 부트스트랩 리소스 정적 게시(bake) 및 폴백 체계 도입
공개 제보 https://github.com/gnuboard/g7/issues/122 대응 — 초기 부트스트랩
리소스(다국어 병합·컴포넌트 정의·라우트·확장 번들·템플릿 dist)를 캐시 버전
디렉토리(public/build/ext/{v}/)에 실파일로 게시해 웹서버가 rewrite 전에 직접
서빙한다. 부트 임계 경로의 PHP 왕복을 제거하고(실측 TTFB 131~144ms → 1~7ms),
미게시·부분게시·GC 직후에는 fetch·태그·번들 3계층이 종전 API 로 즉시 폴백한다.

- 게시: 원자적 tmp→rename→manifest(존재=완료), 캐시 락 단일 실행, 인라인 GC
 (현재+직전 1개), incrementExtensionCacheVersion 단일 지점 terminating 트리거
 + blade 자가 치유 + 설치기 태스크(best_effort) + 일일 cleanup 스케줄,
 sudo 업데이트 대비 소유권 정상화(normalizeOwnership)·prune/백업 제외
- 프론트(engine-v1.61.0): blade 주입 cache_version 1급 시드(이중 부트 로드 제거),
 fetchStaticFirst 즉시 폴백, ComponentRegistry 버전 키드 매니페스트,
 ModuleAssetLoader 번들 정적→레거시 폴백, asset-url-recovery staticToLegacy 역변환
- 폴백 API 품질: lang/components/routes ETag+304 + 환경 분기 Cache-Control,
 열화 라우트 스냅샷 공개 캐시 금지(서버측 캐시 회피와 대칭), 게시 .htaccess
 mod_deflate + nginx gzip 스니펫(압축 전송량 회귀 방지)
- SEO 정합: 봇 HTML 은 GC 대상 정적 URL 미사용(allowStatic:false), props $switch
 봇측 해석 구현(engine-v1.56.0 패리티), 패리티 룰 expression-dialect 그룹 신설,
 상주 allow 헤더 제거로 잠금 복원, _comment* 접두 주석 키 분류
- 검증: 전 수정 red→green 4단계, Playwright 라이브 21건, Chrome MCP 24축+M1~M3,
 봇 curl 3축, 캐시 저장소(file/redis/database) 축 판정, 히스토리·공개이슈·커밋
 이력 전수 재조사 반영
- 코어 7.0.10, engine-v1.61.0. kill-switch: G7_STATIC_CACHE=false
2026-08-25 17:02:53 +09:00

740 lines
24 KiB
TypeScript

/**
* 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';
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<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 캐시 버전 (타임스탬프)
*
* @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<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면 타임스탬프도 추가)
// 자산 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<string, unknown> = dict as Record<string, unknown>;
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<string, unknown>;
}
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();
// 복잡한 표현식인지 확인 (연산자, 괄호, 메서드 호출 등 포함)
// 산술 연산자(+, -, *, /, %), 비교 연산자(<, >, =), 논리 연산자, 공백(피연산자 분리)도 포함
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;
}