공개 제보 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
740 lines
24 KiB
TypeScript
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;
|
|
}
|