/** * 그누보드7 템플릿 엔진 - 데이터 바인딩 엔진 * * {{variable}} 문법을 파싱하고 실제 데이터로 치환하는 엔진 * * @package G7 * @subpackage Template Engine * @since 1.0.0 */ import { createLogger } from '../utils/Logger'; import { TranslationEngine } from './TranslationEngine'; import { hasPipes, splitPipes, executePipeChain } from './PipeRegistry'; import { classifyExpression, extractSingleBinding, isComplexExpression, LITERALS, scanBindings } from './BindingShape'; import { RAW_PREFIX, wrapRaw, wrapRawDeep } from './rawMarkers'; import { evaluateSafeExpression } from './SafeExpressionEvaluator'; import type { G7DevToolsInterface } from './G7CoreGlobals'; const logger = createLogger('DataBindingEngine'); /** * 함수 파라미터 이름으로 쓸 수 있는 식별자 패턴. * * 표현식 평가는 화이트리스트 AST 평가기(SafeExpressionEvaluator)가 담당한다 — * 컨텍스트를 직접 스코프로 삼아 인터프리터로 해석하므로 `new Function` 이 필요 없다. */ /** * G7Core.devTools 인터페이스 가져오기 * * G7DevToolsCore.getInstance() 직접 호출 대신 G7Core.devTools를 사용합니다. * DevTools가 비활성화되거나 초기화되지 않은 경우 안전하게 undefined 반환 */ function getDevTools(): G7DevToolsInterface | undefined { try { const G7Core = (window as any).G7Core; return G7Core?.devTools; } catch { return undefined; } } /** * 바인딩 컨텍스트 인터페이스 * * 데이터 바인딩 시 사용되는 컨텍스트 객체 */ export interface BindingContext { [key: string]: any; } /** * 해석된 값 타입 * * 바인딩 결과로 반환되는 값 */ export type ResolvedValue = string | number | boolean | null | undefined | object | Array; /** * 바인딩 옵션 인터페이스 */ export interface BindingOptions { /** * 기본값 (변수를 찾을 수 없을 때 반환) */ defaultValue?: any; /** * null-safe 모드 활성화 */ nullSafe?: boolean; /** * 순환 참조 감지 활성화 */ detectCircular?: boolean; /** * 최대 중첩 깊이 (순환 참조 방지) */ maxDepth?: number; /** * 캐시 사용 여부 (기본값: true) * false로 설정하면 캐싱을 건너뜀 * 반복 렌더링(DataGrid의 cellChildren 등)에서 같은 경로가 다른 값을 가져야 할 때 사용 */ skipCache?: boolean; } /** * DevTools 표현식 추적 정보 * * 평가 지점(컴포넌트/prop)을 DevTools 로그에 남기기 위한 부가 정보. * 값 해석 결과에는 영향을 주지 않는다. */ export interface ExpressionTrackingInfo { /** * 평가를 요청한 컴포넌트 ID */ componentId?: string; /** * 평가를 요청한 컴포넌트 이름 */ componentName?: string; /** * 평가 대상 prop 이름 */ propName?: string; /** * DevTools 로그에 기록할 평가 진입점 이름 (기본: 호출된 메서드명) */ method?: string; /** * DevTools 로그에 표시할 원본 식 (기본: `{{expr}}`) */ displayExpression?: string; } /** * 캐시 엔트리 인터페이스 */ interface CacheEntry { /** * 캐시된 값 */ value: ResolvedValue; /** * 캐시 생성 시간 */ timestamp: number; } /** * 데이터 바인딩 엔진 에러 클래스 */ export class DataBindingError extends Error { constructor( message: string, public readonly path: string, public readonly context?: any, ) { super(message); this.name = 'DataBindingError'; } } /** * 데이터 바인딩 엔진 클래스 * * {{variable}} 문법을 파싱하고 실제 데이터로 치환 */ export class DataBindingEngine { /** * 경로 파싱 정규식 * * 중첩 객체 접근 (.) 및 배열 인덱스 ([n]) 파싱 */ private static readonly PATH_SEGMENT_PATTERN = /([^.[\]]+)|\[(\d+)\]/g; /** * 캐시 만료 시간 (밀리초) * * 성능 최적화를 위해 30초로 설정 * - 바인딩 경로 재계산 빈도 감소 * - _global 경로는 캐싱되지 않으므로 동적 데이터 처리에 영향 없음 */ private static readonly CACHE_EXPIRY = 30000; // 30초 /** * 값 캐시 맵 * * key: 바인딩 경로, value: 캐시 엔트리 */ private cache: Map = new Map(); /** * 렌더 사이클 캐시 * * 같은 렌더 사이클 내에서 동일 표현식 재평가 방지 * - 렌더 시작 시 startRenderCycle()로 클리어 * - _local, _global 등 동적 경로도 같은 렌더 내에서는 캐시 사용 * - 65,496회 → ~5,000회 바인딩 평가 감소 목표 */ private renderCycleCache: Map = new Map(); /** * 현재 렌더 사이클 ID * * 매 렌더 시작 시 증가하여 캐시 유효성 판단에 사용 */ private currentRenderCycleId: number = 0; /** * 기본 옵션 */ private defaultOptions: BindingOptions = { defaultValue: undefined, nullSafe: true, detectCircular: true, maxDepth: 10, }; /** * 순환 참조 추적용 Set (각 resolve 호출마다 로컬로 생성) */ // 인스턴스 레벨에서 제거 - 각 resolvePath 호출에서 로컬로 관리 /** * 생성자 * * @param options 기본 옵션 */ constructor(options?: Partial) { if (options) { this.defaultOptions = { ...this.defaultOptions, ...options }; } } /** * 새 렌더 사이클 시작 - 렌더 사이클 캐시 클리어 * * DynamicRenderer 루트 컴포넌트에서 렌더링 시작 시 호출됩니다. * 이를 통해 같은 렌더 사이클 내에서 동일 표현식 재평가를 방지하면서도, * 새 렌더 시 최신 상태값으로 평가됩니다. * * @example * ```ts * // DynamicRenderer.tsx 루트 컴포넌트 * useLayoutEffect(() => { * if (!parentComponentContext) { * bindingEngine.startRenderCycle(); * } * }); * ``` */ public startRenderCycle(): void { this.currentRenderCycleId++; this.renderCycleCache.clear(); } /** * 렌더 사이클 캐시 활성화 여부 확인 * * startRenderCycle()이 호출된 적이 있어야 렌더 사이클 캐시 활성화 * 테스트 환경 등에서 startRenderCycle()을 호출하지 않으면 기존 동작 유지 */ private isRenderCycleCacheActive(): boolean { return this.currentRenderCycleId > 0; } /** * 렌더 사이클 캐시에서 값 조회 * * @param key 캐시 키 (표현식 문자열) * @returns 캐시된 값 또는 undefined (렌더 사이클이 활성화되지 않으면 항상 undefined) */ private getFromRenderCycleCache(key: string): any | undefined { if (!this.isRenderCycleCacheActive()) { return undefined; } return this.renderCycleCache.get(key); } /** * 렌더 사이클 캐시에 값 저장 * * @param key 캐시 키 (표현식 문자열) * @param value 저장할 값 * @note 렌더 사이클이 활성화되지 않으면 저장하지 않음 */ private saveToRenderCycleCache(key: string, value: any): void { if (!this.isRenderCycleCacheActive()) { return; } this.renderCycleCache.set(key, value); } /** * 문자열 내의 모든 바인딩 표현식을 데이터로 치환 * * @param template 바인딩 표현식이 포함된 문자열 * @param context 데이터 컨텍스트 * @param options 바인딩 옵션 * @param trackingInfo DevTools 표현식 추적 정보 (선택적) * @returns 치환된 문자열 * * @example * const engine = new DataBindingEngine(); * const result = engine.resolveBindings("Hello {{user.name}}", { user: { name: "홍길동" } }); * // 결과: "Hello 홍길동" */ public resolveBindings( template: string, context: BindingContext, options?: BindingOptions, trackingInfo?: { componentId?: string; componentName?: string; propName?: string }, ): string { const opts = { ...this.defaultOptions, ...options }; const devTools = getDevTools(); // $computed alias 추가 // layout-json-features.md 문서에 따라 $computed.xxx 형식으로 computed 값에 접근 // 내부적으로 _computed에 저장되므로 $computed를 _computed의 alias로 설정 const extendedContext: BindingContext = context._computed && !context.$computed ? { ...context, $computed: context._computed } : context; const replaceBinding = (_match: string, path: string): string => { // 공백 제거 const trimmedPath = path.trim(); // raw: 접두사 감지 — 번역 면제 바인딩 // @since engine-v1.27.0 let isRawBinding = false; let effectivePath = trimmedPath; if (trimmedPath.startsWith(RAW_PREFIX)) { isRawBinding = true; effectivePath = trimmedPath.slice(RAW_PREFIX.length); } // 파이프 함수 처리 (예: {{post.created_at | date}}) // 평가 자체는 evaluatePipeExpression 이 담당하고, 여기서는 보간의 목적에 맞게 // 결과를 문자열로 서식한다. @since engine-v1.54.9 if (hasPipes(effectivePath)) { try { const result = this.evaluatePipeExpression(effectivePath, extendedContext, opts, { ...trackingInfo, method: 'resolveBindings', displayExpression: `{{${trimmedPath}}}`, }); const formatted = this.formatValue(result as ResolvedValue); return isRawBinding ? wrapRaw(formatted) : formatted; } catch (error) { logger.error('Pipe expression evaluation failed:', effectivePath, error); return _match; // 원본 반환 } } // 삼항 연산자나 복잡한 표현식 감지 (?, :, ||, &&, !, 산술 연산자, 함수 호출 등) // 대괄호 안에 따옴표가 있는 문자열 키 접근도 표현식으로 처리 (예: query['filters[0][field]']) // 함수 호출 패턴도 표현식으로 처리 (예: $localized(...)) // 주의: 단일 | (파이프)는 위에서 먼저 처리되므로 여기서는 || 만 표현식으로 인식 // 판정은 BindingShape 정본을 쓴다 — 종전에는 이 자리의 문자 집합이 다른 렌더 // 경로들과 달라, 같은 식이 여기서는 경로 탐색으로 가고 저기서는 표현식으로 가는 // 비대칭이 있었다. 파이프는 위에서 이미 갈라졌다. @since engine-v1.55.0 const isExpression = isComplexExpression(effectivePath); if (isExpression) { // JavaScript 표현식으로 평가 const exprStartTime = devTools?.isEnabled() ? performance.now() : 0; try { let value: any; let fromCache = false; // skipCache가 아니면 렌더 사이클 캐시 사용 if (!opts.skipCache) { const exprCacheKey = `expr:${effectivePath}`; const exprCached = this.getFromRenderCycleCache(exprCacheKey); if (exprCached !== undefined) { value = exprCached; fromCache = true; } else { value = this.evaluateExpression(effectivePath, extendedContext); this.saveToRenderCycleCache(exprCacheKey, value); } } else { value = this.evaluateExpression(effectivePath, extendedContext); } // DevTools: 복잡한 표현식 평가 추적 if (devTools?.isEnabled()) { const duration = performance.now() - exprStartTime; devTools.trackExpressionEval({ expression: `{{${trimmedPath}}}`, result: this.sanitizeResultForTracking(value), resultType: this.getResultType(value), componentId: trackingInfo?.componentId, componentName: trackingInfo?.componentName, propName: trackingInfo?.propName, fromCache, duration, method: 'resolveBindings', skipCache: opts.skipCache, }); } const formatted = this.formatValue(value); return isRawBinding ? wrapRaw(formatted) : formatted; } catch (error) { logger.error('Expression evaluation failed:', effectivePath, error); return _match; // 원본 반환 } } // skipCache 옵션이면 모든 캐시 사용 안 함 if (opts.skipCache) { const startTime = devTools?.isEnabled() ? performance.now() : 0; const value = this.resolvePath(effectivePath, extendedContext, opts); // DevTools: 표현식 평가 추적 if (devTools?.isEnabled()) { const duration = performance.now() - startTime; devTools.trackExpressionEval({ expression: `{{${trimmedPath}}}`, result: this.sanitizeResultForTracking(value), resultType: this.getResultType(value), componentId: trackingInfo?.componentId, componentName: trackingInfo?.componentName, propName: trackingInfo?.propName, fromCache: false, duration, method: 'resolveBindings', skipCache: true, }); } const formatted = this.formatValue(value); return isRawBinding ? wrapRaw(formatted) : formatted; } // 동적 경로 여부 판단 const isDynamicPath = effectivePath.startsWith('_global') || effectivePath.startsWith('_local') || effectivePath.startsWith('_isolated') || effectivePath.startsWith('$parent'); const startTime = devTools?.isEnabled() ? performance.now() : 0; let value: any; let fromCache = false; if (isDynamicPath) { // 동적 경로: 렌더 사이클 캐시 사용 const renderCycleCached = this.getFromRenderCycleCache(effectivePath); if (renderCycleCached !== undefined) { value = renderCycleCached; fromCache = true; } else { value = this.resolvePath(effectivePath, extendedContext, opts); this.saveToRenderCycleCache(effectivePath, value); } } else { // 정적 경로: 영구 캐시 사용 const cached = this.getFromCache(effectivePath); if (cached !== undefined) { value = cached; fromCache = true; } else { value = this.resolvePath(effectivePath, extendedContext, opts); this.saveToCache(effectivePath, value); } } // DevTools: 표현식 평가 추적 if (devTools?.isEnabled()) { const duration = performance.now() - startTime; devTools.trackExpressionEval({ expression: `{{${trimmedPath}}}`, result: this.sanitizeResultForTracking(value), resultType: this.getResultType(value), componentId: trackingInfo?.componentId, componentName: trackingInfo?.componentName, propName: trackingInfo?.propName, fromCache, duration, method: 'resolveBindings', skipCache: opts.skipCache, }); } const formatted = this.formatValue(value); return isRawBinding ? wrapRaw(formatted) : formatted; }; // 바인딩 위치는 수동 스캐너로 찾는다 — 정규식(`\{\{([^}]+)\}\}`)은 식 안에 `}` 가 // 들어가면 매칭에 실패하고, `String.replace` 는 그때 입력을 그대로 돌려주어 // 원본 `{{...}}` 문자열이 조용히 화면에 노출됐다. @since engine-v1.55.1 const bindings = scanBindings(template); if (bindings.length === 0) return template; let output = ''; let cursor = 0; for (const binding of bindings) { output += template.slice(cursor, binding.start); output += replaceBinding(template.slice(binding.start, binding.end), binding.expr); cursor = binding.end; } return output + template.slice(cursor); } /** * 파이프(`|`)가 포함된 단일 바인딩 식을 평가해 **원본 타입 그대로** 반환 * * `resolveBindings` 는 보간(문자열 산출)이 목적이라 결과에 `formatValue` 를 적용한다. * 반면 prop 값·`if` 조건·반복 렌더처럼 값 자체가 필요한 자리에서는 배열이 * `"[\"a\",\"b\"]"` 로, `false` 가 `"false"`(truthy) 로 바뀌면 안 된다. * 그런 지점은 `resolveBindings(\`{{...}}\`)` 로 우회하지 말고 이 메서드를 직접 호출한다. * * 문자열 조립을 거치지 않으므로 `{{(row.meta ?? {}) | json}}` 처럼 식 안에 중괄호가 * 있는 경우에도 정상 평가된다 — `BINDING_PATTERN` 은 `}` 를 포함한 식을 매칭하지 못해 * 위임 방식에서는 입력 문자열이 그대로 화면에 노출됐다. * * @param expr `{{ }}` 와 `raw:` 접두사를 제거한 파이프 식 (예: `row.tags | slice(0,3)`) * @param context 데이터 컨텍스트 * @param options 바인딩 옵션 (skipCache 등) * @param trackingInfo DevTools 표현식 추적 정보 (선택적) * @returns 파이프 체인 실행 결과 (원본 타입) * @throws 기본 식 평가 또는 파이프 실행 실패 시 그대로 전파 — 호출 지점이 자신의 * 실패 정책(경고 후 undefined 등)을 적용한다 * * @since engine-v1.54.9 */ public evaluatePipeExpression( expr: string, context: BindingContext, options?: BindingOptions, trackingInfo?: ExpressionTrackingInfo, ): unknown { const opts = { ...this.defaultOptions, ...options }; const devTools = getDevTools(); const pipeStartTime = devTools?.isEnabled() ? performance.now() : 0; // $computed alias 추가 (resolveBindings 와 동일 규칙) const extendedContext: BindingContext = context._computed && !context.$computed ? { ...context, $computed: context._computed } : context; const [baseExpr, pipes] = splitPipes(expr); // 기본 표현식 평가 (파이프 적용 전) let value: any; let fromCache = false; const isBaseExpression = isComplexExpression(baseExpr); // 동적 경로 여부 판단 const isDynamicPipeBase = baseExpr.startsWith('_global') || baseExpr.startsWith('_local') || baseExpr.startsWith('_isolated') || baseExpr.startsWith('$parent'); if (opts.skipCache) { // skipCache: 모든 캐시 무시 if (isBaseExpression) { value = this.evaluateExpression(baseExpr, extendedContext); } else { value = this.resolvePath(baseExpr, extendedContext, opts); } } else if (isBaseExpression) { // 복잡한 표현식: 렌더 사이클 캐시 사용 const exprCached = this.getFromRenderCycleCache(`expr:${baseExpr}`); if (exprCached !== undefined) { value = exprCached; fromCache = true; } else { value = this.evaluateExpression(baseExpr, extendedContext); this.saveToRenderCycleCache(`expr:${baseExpr}`, value); } } else if (isDynamicPipeBase) { // 동적 경로: 렌더 사이클 캐시 사용 const renderCycleCached = this.getFromRenderCycleCache(baseExpr); if (renderCycleCached !== undefined) { value = renderCycleCached; fromCache = true; } else { value = this.resolvePath(baseExpr, extendedContext, opts); this.saveToRenderCycleCache(baseExpr, value); } } else { // 정적 경로: 영구 캐시 사용 const cached = this.getFromCache(baseExpr); if (cached !== undefined) { value = cached; fromCache = true; } else { value = this.resolvePath(baseExpr, extendedContext, opts); this.saveToCache(baseExpr, value); } } // 파이프 체인 실행 const result = executePipeChain(value, pipes); // DevTools: 파이프 표현식 평가 추적 if (devTools?.isEnabled()) { const duration = performance.now() - pipeStartTime; devTools.trackExpressionEval({ expression: trackingInfo?.displayExpression ?? `{{${expr}}}`, result: this.sanitizeResultForTracking(result), resultType: this.getResultType(result), componentId: trackingInfo?.componentId, componentName: trackingInfo?.componentName, propName: trackingInfo?.propName, fromCache, duration, method: trackingInfo?.method ?? 'evaluatePipeExpression', skipCache: opts.skipCache, }); } return result; } /** * 단일 바인딩 경로의 값을 해석 * * @param path 바인딩 경로 (예: "user.profile.name") * @param context 데이터 컨텍스트 * @param options 바인딩 옵션 * @param trackingInfo DevTools 표현식 추적 정보 (선택적) * @returns 해석된 값 * * @example * const engine = new DataBindingEngine(); * const value = engine.resolve("user.name", { user: { name: "홍길동" } }); * // 결과: "홍길동" */ public resolve( path: string, context: BindingContext, options?: BindingOptions, trackingInfo?: { componentId?: string; componentName?: string; propName?: string }, ): T { const opts = { ...this.defaultOptions, ...options }; const devTools = getDevTools(); const startTime = devTools?.isEnabled() ? performance.now() : 0; // skipCache 옵션이 true면 모든 캐시 사용 안 함 (반복 렌더링에서 같은 경로가 다른 값을 가져야 할 때) if (opts.skipCache) { const value = this.resolvePath(path, context, opts); // DevTools: 표현식 평가 추적 if (devTools?.isEnabled()) { devTools.trackExpressionEval({ expression: path, result: this.sanitizeResultForTracking(value), resultType: this.getResultType(value), componentId: trackingInfo?.componentId, componentName: trackingInfo?.componentName, propName: trackingInfo?.propName, fromCache: false, duration: performance.now() - startTime, method: 'resolve', skipCache: true, }); } return value as T; } // 동적 경로 여부 판단 // - _global, _local, _isolated, $parent: 동적으로 자주 변경되므로 영구 캐시 사용 안 함 // - 단, 같은 렌더 사이클 내에서는 렌더 사이클 캐시 사용 (성능 최적화) const isDynamicPath = path.startsWith('_global') || path.startsWith('_local') || path.startsWith('_isolated') || path.startsWith('$parent'); let fromCache = false; // 1. 렌더 사이클 캐시 확인 (동적 경로용) // 같은 렌더 사이클 내에서 동일 표현식 재평가 방지 if (isDynamicPath) { const renderCycleCached = this.getFromRenderCycleCache(path); if (renderCycleCached !== undefined) { fromCache = true; // DevTools: 렌더 사이클 캐시 히트 추적 if (devTools?.isEnabled()) { devTools.trackExpressionEval({ expression: path, result: this.sanitizeResultForTracking(renderCycleCached), resultType: this.getResultType(renderCycleCached), componentId: trackingInfo?.componentId, componentName: trackingInfo?.componentName, propName: trackingInfo?.propName, fromCache: true, duration: performance.now() - startTime, method: 'resolve', skipCache: opts.skipCache, }); } return renderCycleCached as T; } } else { // 2. 영구 캐시 확인 (정적 경로용) const cached = this.getFromCache(path); if (cached !== undefined) { fromCache = true; // DevTools: 영구 캐시 히트 추적 if (devTools?.isEnabled()) { devTools.trackExpressionEval({ expression: path, result: this.sanitizeResultForTracking(cached), resultType: this.getResultType(cached), componentId: trackingInfo?.componentId, componentName: trackingInfo?.componentName, propName: trackingInfo?.propName, fromCache: true, duration: performance.now() - startTime, method: 'resolve', skipCache: opts.skipCache, }); } return cached as T; } } // 값 해석 const value = this.resolvePath(path, context, opts); // 캐시 저장 if (isDynamicPath) { // 동적 경로: 렌더 사이클 캐시에 저장 this.saveToRenderCycleCache(path, value); } else { // 정적 경로: 영구 캐시에 저장 this.saveToCache(path, value); } // DevTools: 표현식 평가 추적 if (devTools?.isEnabled()) { devTools.trackExpressionEval({ expression: path, result: this.sanitizeResultForTracking(value), resultType: this.getResultType(value), componentId: trackingInfo?.componentId, componentName: trackingInfo?.componentName, propName: trackingInfo?.propName, fromCache, duration: performance.now() - startTime, method: 'resolve', skipCache: opts.skipCache, }); } return value as T; } /** * 경로를 파싱하여 중첩된 값을 해석 * * @param path 바인딩 경로 * @param context 데이터 컨텍스트 * @param options 바인딩 옵션 * @param depth 현재 중첩 깊이 * @param visitedObjects 순환 참조 추적용 Set (내부 재귀 호출용) * @returns 해석된 값 * @throws {DataBindingError} 순환 참조 감지 시 또는 최대 깊이 초과 시 */ private resolvePath( path: string, context: BindingContext, options: BindingOptions, depth: number = 0, visitedObjects: WeakSet = new WeakSet(), ): ResolvedValue { // 빈 경로 안전망 — 세그먼트가 하나도 없으면 아래 루프를 돌지 않아 // 컨텍스트 객체 **전체**가 값으로 반환된다. 그 값이 문자열로 서식되면 // 전역 상태가 통째로 화면·요청에 실릴 수 있다. @since engine-v1.55.0 if (typeof path !== 'string' || path.trim() === '') { logger.warn('resolvePath: 빈 경로가 전달되었습니다 — undefined 로 해석합니다.'); return undefined; } // 최대 깊이 체크 (순환 참조 방지) if (options.maxDepth && depth > options.maxDepth) { if (options.detectCircular) { throw new DataBindingError( `Maximum depth exceeded (${options.maxDepth}). Possible circular reference.`, path, context, ); } return options.defaultValue; } // 경로 세그먼트 파싱 const segments = this.parsePath(path); // 값 추출 let current: any = context; for (let i = 0; i < segments.length; i++) { const segment = segments[i]; // null/undefined 체크 (null-safe) if (current == null) { if (options.nullSafe) { return options.defaultValue; } throw new DataBindingError( `Cannot read property '${segment}' of ${current}`, path, context, ); } // 순환 참조 감지 (객체인 경우만) if (options.detectCircular && typeof current === 'object') { if (visitedObjects.has(current)) { throw new DataBindingError( `Circular reference detected at path: ${segments.slice(0, i + 1).join('.')}`, path, context, ); } visitedObjects.add(current); } // 다음 값으로 이동 current = current[segment]; // 디버깅 로그 if (path === '_global.toasts') { logger.log(`[DataBindingEngine] segment: ${segment}, current:`, current); } } // 값이 없으면 기본값 반환 return current !== undefined ? current : options.defaultValue; } /** * 경로 문자열을 세그먼트 배열로 파싱 * * @param path 바인딩 경로 * @returns 세그먼트 배열 * * @example * parsePath("user.profile.name") // ["user", "profile", "name"] * parsePath("items[0].name") // ["items", "0", "name"] * parsePath("data[1][2]") // ["data", "1", "2"] */ private parsePath(path: string): string[] { const segments: string[] = []; let match: RegExpExecArray | null; // 정규식 초기화 DataBindingEngine.PATH_SEGMENT_PATTERN.lastIndex = 0; while ((match = DataBindingEngine.PATH_SEGMENT_PATTERN.exec(path)) !== null) { // match[1]: 일반 프로퍼티 (예: "user", "name") // match[2]: 배열 인덱스 (예: "0", "1") const segment = match[1] || match[2]; if (segment) { segments.push(segment); } } return segments; } /** * 값을 문자열로 포맷 * * @param value 해석된 값 * @returns 문자열 표현 */ private formatValue(value: ResolvedValue): string { // null 또는 undefined if (value == null) { return ''; } // 객체 또는 배열 if (typeof value === 'object') { try { return JSON.stringify(value); } catch (error) { return '[Object]'; } } // 기본 타입 (string, number, boolean) return String(value); } /** * 캐시에서 값 조회 * * @param path 바인딩 경로 * @returns 캐시된 값 (없으면 undefined) */ private getFromCache(path: string): ResolvedValue | undefined { const entry = this.cache.get(path); const devTools = getDevTools(); if (!entry) { // DevTools: 캐시 미스 기록 if (devTools?.isEnabled()) { devTools.recordCacheMiss(); } return undefined; } // 캐시 만료 체크 const now = Date.now(); if (now - entry.timestamp > DataBindingEngine.CACHE_EXPIRY) { this.cache.delete(path); // DevTools: 만료된 캐시는 미스로 기록 if (devTools?.isEnabled()) { devTools.recordCacheMiss(); } return undefined; } // DevTools: 캐시 히트 기록 if (devTools?.isEnabled()) { devTools.recordCacheHit(); } return entry.value; } /** * 캐시에 값 저장 * * @param path 바인딩 경로 * @param value 저장할 값 */ private saveToCache(path: string, value: ResolvedValue): void { this.cache.set(path, { value, timestamp: Date.now(), }); } /** * 객체가 액션 정의(ActionDefinition)인지 판별합니다. * * 액션 정의는 렌더 시점에 resolveObject로 선평가하면 안 됩니다. * 내부 템플릿 문자열(예: "{{_local.checkout}}")이 실행 시점에 * ActionDispatcher.resolveParams()에서 최신 상태로 해소되어야 합니다. * * 콜백 prop(onAddressSelect 등)에 포함된 액션 정의가 resolveObject를 거치면 * {{}} 표현식이 렌더 시점 JS 객체로 고정되어 stale 값이 전송됩니다. * 컴포넌트 레벨 actions 배열은 bindComponentActions에서 별도 처리되므로 비해당. * * @param obj 검사할 객체 * @returns 액션 정의이면 true */ public isActionDefinition(obj: Record): boolean { if (typeof obj.handler !== 'string') return false; return ( obj.params !== undefined || Array.isArray(obj.actions) || typeof obj.target === 'string' || obj.onSuccess !== undefined || obj.onError !== undefined ); } /** * 객체 내의 모든 바인딩 표현식을 데이터로 치환 * * @param obj 바인딩 표현식이 포함된 객체 * @param context 데이터 컨텍스트 * @param options 바인딩 옵션 * @returns 치환된 객체 * * @example * const engine = new DataBindingEngine(); * const result = engine.resolveObject( * { style: { color: "{{theme.color}}" } }, * { theme: { color: "red" } } * ); * // 결과: { style: { color: "red" } } */ public resolveObject( obj: Record, context: BindingContext, options?: BindingOptions & { skipBindingKeys?: string[] }, ): Record { // $switch 특수 객체 처리 // { $switch: "{{key}}", $cases: { ... }, $default: value } if (this.isSwitchExpression(obj)) { const switchDef = obj as { $switch: string; $cases: Record; $default?: any }; return this.resolveSwitch(switchDef, context, options) as Record; } // 액션 정의 객체 감지 → 선평가 건너뛰기 // 콜백 prop(onAddressSelect 등)에 포함된 액션 정의는 렌더 시점에 해소하면 // 내부 {{_local.xxx}} 표현식이 stale 객체로 고정됨. // ActionDispatcher.resolveParams()에서 실행 시점에 최신 상태로 해소해야 함. if (this.isActionDefinition(obj)) { return { ...obj }; } // iteration이 있는 ComponentDefinition 감지 → 선평가 건너뛰기 (engine-v1.19.1) // footerCells/footerCardChildren 등 DataGrid prop 내부의 컴포넌트 정의에서 // iteration이 있으면 source, text, children, props 모두 iteration 변수를 참조할 수 있음. // DynamicRenderer 시점에는 iteration 변수가 존재하지 않으므로 선평가하면 에러 발생. // renderItemChildren에서 적절한 iteration 컨텍스트와 함께 런타임에 해석되어야 함. if ('iteration' in obj) { return { ...obj }; } const result: Record = {}; // 기본 skipBindingKeys (하위 호환성) // 컴포넌트별 설정은 DynamicRenderer에서 처리 // resolveObject는 범용 메서드이므로 기본값만 유지 const DEFAULT_SKIP_BINDING_KEYS = ['cellChildren', 'expandChildren', 'expandContext', 'render']; // 옵션으로 전달된 skipBindingKeys와 기본값 병합 const skipBindingKeys = [ ...DEFAULT_SKIP_BINDING_KEYS, ...(options?.skipBindingKeys || []), ]; for (const [key, value] of Object.entries(obj)) { // cellChildren 등 특정 키는 원본 그대로 전달 if (skipBindingKeys.includes(key)) { result[key] = value; continue; } // 해석 실패는 key 단위로 격리한다 — 이 메서드에는 상위 catch 가 없어서 // 예외가 밖으로 나가면 그 컴포넌트의 props 해석 **전체**가 중단되고, // 표현식 하나의 실수가 화면 전체를 날린다. @since engine-v1.55.1 try { this.resolveObjectEntry(result, key, value, context, options); } catch (error) { logger.warn(`resolveObject: 값 해석 실패 (key: ${key}):`, error); result[key] = undefined; } } return result; } /** * `resolveObject` 의 key 하나를 해석해 결과 객체에 기록 * * 실패 격리 단위를 key 로 두기 위해 분리했다. 이 메서드는 예외를 잡지 않는다 — * 호출자(`resolveObject`)가 key 단위로 격리한다. * * @param result 결과 객체 (직접 기록) * @param key 대상 key * @param value 원본 값 * @param context 데이터 컨텍스트 * @param options 바인딩 옵션 * * @since engine-v1.55.1 */ private resolveObjectEntry( result: Record, key: string, value: any, context: BindingContext, options?: BindingOptions & { skipBindingKeys?: string[] }, ): void { { if (typeof value === 'string') { // 전체가 {{variable}} 패턴인 경우 원본 값(배열/객체) 반환. // `[^}]+` 는 `{{error.errors ?? {}}}` 처럼 식 안에 `}` 가 든 경우(빈 객체 fallback 등) // 첫 `}` 에서 끊겨 매칭에 실패 → 원본 문자열이 그대로 새어 나오던 결함이 있었다. // extractSingleBinding 은 외곽 `{{ }}` 안의 중괄호 균형을 추적해 단일 바인딩 식을 정확히 추출한다. const singleBindingPath = extractSingleBinding(value); if (singleBindingPath !== null) { const path = singleBindingPath.trim(); // raw: 접두사 감지 @since engine-v1.27.0 let isRawBinding = false; let effectivePath = path; if (path.startsWith(RAW_PREFIX)) { isRawBinding = true; effectivePath = path.slice(RAW_PREFIX.length); } // 라우팅 판정은 BindingShape 정본을 쓴다 (파이프 → 표현식 → 경로). // 파이프를 evaluateExpression 으로 보내면 `value | pipe` 가 JS 비트 OR 로 평가되어 // 인자 있는 파이프는 예외를 던지고(props 해석 전체가 중단), 인자 없는 // 파이프는 포맷이 적용되지 않은 오답이 된다. @since engine-v1.54.3 // 값이 필요한 자리이므로 서식(formatValue)을 적용하지 않는다. @since engine-v1.54.9 // 실행(캐시·예외 격리)은 이 지점의 정책을 유지한다. @since engine-v1.55.0 let resolved: any; switch (classifyExpression(effectivePath)) { case 'empty': logger.warn(`resolveObject: 빈 바인딩 \`{{}}\` (key: ${key}) — undefined 로 해석합니다.`); resolved = undefined; break; case 'literal': resolved = LITERALS.get(effectivePath); break; case 'pipe': resolved = this.evaluatePipeExpression(effectivePath, context, options); break; case 'expression': resolved = this.evaluateExpression(effectivePath, context, options); break; default: resolved = this.resolve(effectivePath, context, options); } result[key] = isRawBinding && resolved != null ? wrapRawDeep(resolved) : resolved; } else { // 문자열 보간 result[key] = this.resolveBindings(value, context, options); } } else if (Array.isArray(value)) { result[key] = value.map((item) => typeof item === 'string' ? this.resolveBindings(item, context, options) : typeof item === 'object' && item !== null ? this.resolveObject(item, context, options) : item, ); } else if (value && typeof value === 'object') { result[key] = this.resolveObject(value, context, options); } else { result[key] = value; } } } /** * 캐시 초기화 * * 바인딩 값 캐시를 초기화합니다. */ public clearCache(): void { this.cache.clear(); } /** * 특정 키로 시작하는 캐시 항목 삭제 * * 데이터가 업데이트될 때 해당 키와 관련된 캐시만 선택적으로 무효화합니다. * 예: invalidateCacheByKeys(['users']) 호출 시 * 'users', 'users.data', 'users.data.data', 'users[0]' 등 삭제 * * @param keys 삭제할 최상위 키 배열 */ public invalidateCacheByKeys(keys: string[]): void { for (const path of this.cache.keys()) { for (const key of keys) { if (path === key || path.startsWith(`${key}.`) || path.startsWith(`${key}[`)) { this.cache.delete(path); break; } } } } /** * 만료된 캐시 항목 제거 */ public pruneCache(): void { const now = Date.now(); for (const [path, entry] of this.cache.entries()) { if (now - entry.timestamp > DataBindingEngine.CACHE_EXPIRY) { this.cache.delete(path); } } } /** * 캐시 통계 조회 * * @returns 캐시 크기 및 만료된 항목 수 */ public getCacheStats(): { size: number; expired: number } { const now = Date.now(); let expired = 0; for (const entry of this.cache.values()) { if (now - entry.timestamp > DataBindingEngine.CACHE_EXPIRY) { expired++; } } return { size: this.cache.size, expired, }; } /** * JavaScript 표현식 평가 (공개 메서드) * * 삼항 연산자, 논리 연산자 등 복잡한 표현식을 평가합니다. * * @param expr 평가할 표현식 (예: "!_global.sidebarOpen", "_global.sidebarOpen ? 'a' : 'b'") * @param context 데이터 컨텍스트 * @param trackingInfo DevTools 표현식 추적 정보 (선택적) * @returns 평가 결과 * @throws {Error} 표현식 평가 실패 시 */ public evaluateExpression( expr: string, context: BindingContext, trackingInfo?: { componentId?: string; componentName?: string; propName?: string; skipCache?: boolean }, ): any { // DevTools: 바인딩 평가 추적 const devTools = getDevTools(); const startTime = devTools?.isEnabled() ? performance.now() : 0; if (devTools?.isEnabled()) { devTools.trackBindingEval(); } try { // 1. 표현식 전체에서 변수 접근을 optional chaining으로 변환 // 예: perm.identifier -> perm?.identifier // 이를 통해 iteration 변수가 undefined일 때 에러 방지 let processedExpr = this.preprocessOptionalChaining(expr); // 2. $t: 패턴을 문자열로 자동 변환 (예: $t:common.error -> "$t:common.error") // 이를 통해 사용자가 따옴표 없이 $t: 문법을 사용할 수 있음 processedExpr = this.preprocessTranslationTokens(processedExpr); // 표현식에서 사용되는 최상위 변수 추출 const usedVariables = this.extractVariablesFromExpression(processedExpr); // 컨텍스트에 없는 변수를 undefined로 추가 const extendedContext = { ...context }; for (const varName of usedVariables) { if (!(varName in extendedContext)) { extendedContext[varName] = undefined; } } // $computed alias 추가 // layout-json-features.md 문서에 따라 $computed.xxx 형식으로 computed 값에 접근 // 내부적으로 _computed에 저장되므로 $computed를 _computed의 alias로 설정 if (context._computed && !extendedContext.$computed) { extendedContext.$computed = context._computed; } // 활성 로케일 / 템플릿 ID 해석 — $localized 와 $t 가 공유한다. // // @since engine-v1.38.2 ActionDispatcher 경로 fallback — bindActionsToProps // 이후 createHandler 에서 빌드된 action data context 는 `$templateId`/`$locale` // 을 명시적으로 포함하지 않을 수 있다. 이 경우 `window.__templateApp.getConfig()` // 로부터 회수하여 `{{$event.target.checked ? '$t:A' : '$t:B'}}` 같은 조건부 // $t: 평가가 raw key 를 반환하던 버그를 해결한다. // // @since engine-v1.52.1 회수 결과를 `$localized` 도 공유한다. 이전에는 `$localized` // 가 `$t` 보다 먼저 `context.$locale || 'ko'` 로 로케일을 확정해, ActionDispatcher // 경로(init_actions 파생 등)에서 항상 ko 값을 반환했다. `$locale` 이 컨텍스트에 // 없는 것은 로케일이 ko 라는 뜻이 아니라 "이 경로가 로케일을 안 넘긴다"는 뜻이다. let templateId = context.$templateId; let resolvedLocale = context.$locale; if (typeof window !== 'undefined' && (!templateId || !resolvedLocale)) { const appConfig = (window as any).__templateApp?.getConfig?.(); templateId ??= appConfig?.templateId; resolvedLocale ??= appConfig?.locale; } const locale = resolvedLocale || 'ko'; // $localized 헬퍼 함수 추가 // 다국어 객체에서 현재 로케일에 해당하는 값을 반환 // 사용법: // $localized(value) - 객체면 로케일 우선순위로 반환, 문자열이면 그대로 // $localized(value, fallbackKey) - 활성 로케일 키 부재 시 $t(fallbackKey) 호출 // (settings JSON 등 시스템 카탈로그의 lang pack 보강용) extendedContext.$localized = (value: any, fallbackKey?: string): string => { if (value == null && !fallbackKey) { return ''; } if (typeof value === 'string') { return value; } // 1. 객체의 활성 로케일 키 if (value && typeof value === 'object' && value[locale]) { return value[locale]; } // 2. fallbackKey 가 있으면 $t() 로 lang pack 보강 시도 if (fallbackKey && typeof fallbackKey === 'string') { const translated = extendedContext.$t ? extendedContext.$t(fallbackKey) : fallbackKey; if (translated && translated !== fallbackKey) { return translated; } } // 3. 객체의 ko / en / 첫 번째 값 if (value && typeof value === 'object') { return value.ko || value.en || Object.values(value)[0] || ''; } if (value == null) { return ''; } return String(value); }; // $uuid 헬퍼 함수 추가 // UUID v4 형식의 고유 식별자를 생성하여 반환 // 사용법: $uuid() - "550e8400-e29b-41d4-a716-446655440000" 형식의 문자열 반환 const G7Core = typeof window !== 'undefined' ? (window as any).G7Core : undefined; extendedContext.$uuid = (): string => { if (G7Core?.uuid) { return G7Core.uuid(); } if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID(); } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); }; // $t 헬퍼 함수 추가 // 번역 키를 사용하여 다국어 텍스트를 반환 // 사용법: $t('admin.settings.info.write_only') - 번역된 텍스트 반환 // // templateId / locale 은 위에서 해석된 값을 그대로 사용한다($localized 와 공유). const translationContext = { templateId: templateId || '', locale, }; extendedContext.$t = (key: string): string => { if (!key || typeof key !== 'string') { return ''; } try { const translationEngine = TranslationEngine.getInstance(); return translationEngine.translate(key, translationContext); } catch (error) { logger.warn('$t() translation failed for key:', key, error); return key; // 번역 실패 시 키 자체 반환 } }; // $get 헬퍼 함수 추가 // 깊은 객체 경로 접근과 폴백 값 지원 // 사용법: $get(product.prices, [currency, 'formatted'], defaultPrice) extendedContext.$get = (obj: any, path: string | string[], fallback: any = undefined): any => { // null/undefined 객체는 즉시 폴백 반환 if (obj === null || obj === undefined) { return fallback; } // 경로를 배열로 정규화 const keys = Array.isArray(path) ? path : [path]; // 빈 경로는 객체 자체 반환 if (keys.length === 0) { return obj; } let current = obj; for (const key of keys) { // 현재 위치가 null/undefined이면 폴백 반환 if (current === null || current === undefined) { return fallback; } // 키가 null/undefined이면 폴백 반환 if (key === null || key === undefined) { return fallback; } // 다음 레벨로 이동 current = current[key]; } // 최종 값이 null/undefined이면 폴백 반환 return current ?? fallback; }; // 표현식은 화이트리스트 AST 평가기로 안전하게 실행한다(KVE-2026-1915). // `new Function(...)` / `with(ctx)` 기반 평가는 `''.constructor.constructor(...)` // 형태의 샌드박스 탈출을 허용했으므로 폐기하고, evaluateSafeExpression 이 컨텍스트를 // 직접 스코프로 삼아 식을 인터프리터로 해석한다. 컨텍스트 키를 함수 파라미터로 // 만들지 않으므로 `sales_status[]` 같은 비-식별자 키가 섞여도 평가가 죽지 않는다 // (그런 키는 `query['sales_status[]']` 로 상위 객체를 거쳐 그대로 접근된다). // @since engine-v1.59.0 const fromCache = false; const result = evaluateSafeExpression(processedExpr, extendedContext); // DevTools: 표현식 평가 추적 if (devTools?.isEnabled()) { const duration = performance.now() - startTime; devTools.trackExpressionEval({ expression: expr, result: this.sanitizeResultForTracking(result), resultType: this.getResultType(result), componentId: trackingInfo?.componentId, componentName: trackingInfo?.componentName, propName: trackingInfo?.propName, fromCache, duration, method: 'evaluateExpression', skipCache: trackingInfo?.skipCache, }); } return result; } catch (error) { throw new Error( `Failed to evaluate expression "${expr}": ${error instanceof Error ? error.message : String(error)}`, ); } } /** * 결과 값의 타입을 문자열로 반환 */ private getResultType(value: any): string { if (value === null) return 'null'; if (value === undefined) return 'undefined'; if (Array.isArray(value)) return 'array'; return typeof value; } /** * 추적을 위해 결과 값을 안전하게 정리 * (순환 참조 제거, 크기 제한 등) */ private sanitizeResultForTracking(value: any): any { if (value === null || value === undefined) return value; if (typeof value !== 'object') return value; try { // 객체/배열의 경우 간략화된 정보만 저장 if (Array.isArray(value)) { if (value.length > 10) { return `[Array(${value.length})]`; } return value.slice(0, 10); } // 객체의 경우 키 개수 제한 const keys = Object.keys(value); if (keys.length > 20) { return `{Object(${keys.length} keys)}`; } // 순환 참조 방지를 위해 JSON 변환 시도 JSON.stringify(value); return value; } catch { return '[Complex Object]'; } } /** * 표현식 내 $t: 토큰을 처리 * * 1. 따옴표 안의 $t:key → $t('key') 함수 호출로 변환 (computed expressions용) * 2. 따옴표 없는 $t:key → "$t:key" 문자열로 변환 (static JSON용) * * @param expr 원본 표현식 * @returns 변환된 표현식 * * @example * preprocessTranslationTokens("'$t:common.all'") * // 결과: "$t('common.all')" * * @example * preprocessTranslationTokens("_global.msg || $t:common.error") * // 결과: '_global.msg || "$t:common.error"' */ private preprocessTranslationTokens(expr: string): string { // 1. 먼저 따옴표 안의 $t: 패턴을 $t() 함수 호출로 변환 // '$t:common.all' → $t('common.all') // "$t:common.all" → $t('common.all') // '$t:sirsoft-page.admin.key' → $t('sirsoft-page.admin.key') (하이픈 포함 모듈 키 지원) // @since engine-v1.28.1 하이픈(-) 지원 추가 expr = expr.replace(/(['"])(\$t:([a-zA-Z_][a-zA-Z0-9_.\-]*))\1/g, (_match, _quote, _fullKey, keyOnly) => { return `$t('${keyOnly}')`; }); // 2. 기존 로직: 따옴표 없는 $t: 패턴을 문자열로 변환 // $t:key.path → "$t:key.path" // @since engine-v1.28.1 하이픈(-) 지원 추가 // @since engine-v1.60.6 lookbehind 제거 — 선행 따옴표를 소비해 명시 분기한다. // 정규식 lookbehind 는 Safari 16.4 미만이 파싱 자체를 거부해 번들 전체가 실행되지 않는다. return expr.replace( /(['"]\s*)?(\$t:[a-zA-Z_][a-zA-Z0-9_.\-]*)(?!\s*['"])/g, (match, quoted, token) => (quoted ? match : `"${token}"`) ); } /** * 표현식 전체에서 변수 접근을 optional chaining으로 변환 * * iteration 변수 등이 undefined일 때 에러를 방지합니다. * 문자열 리터럴 내부는 변환하지 않습니다. * * @param expr 원본 표현식 * @returns 변환된 표현식 * * @example * preprocessOptionalChaining("perm.identifier") * // 결과: "perm?.identifier" * * preprocessOptionalChaining("$localized(perm.name) + ' (' + perm.identifier + ')'") * // 결과: "$localized(perm?.name) + ' (' + perm?.identifier + ')'" * * preprocessOptionalChaining("_global.user.name") * // 결과: "_global?.user?.name" */ private preprocessOptionalChaining(expr: string): string { // 문자열 리터럴을 임시 토큰으로 대체 const stringLiterals: string[] = []; let tokenizedExpr = expr.replace(/(['"])(?:(?!\1|\\).|\\.)*\1/g, (match) => { stringLiterals.push(match); return `__STRING_LITERAL_${stringLiterals.length - 1}__`; }); // $t: 패턴을 임시 토큰으로 대체 (번역 키 경로는 변환하지 않음) // @since engine-v1.28.1 하이픈(-) 지원 추가 const translationTokens: string[] = []; tokenizedExpr = tokenizedExpr.replace(/\$t:[a-zA-Z_][a-zA-Z0-9_.\-]*(?:\|[^'"\s,)]+)?/g, (match) => { translationTokens.push(match); return `__TRANSLATION_TOKEN_${translationTokens.length - 1}__`; }); // 변수.속성 패턴을 변수?.속성으로 변환 (이미 ?. 인 경우 제외) // 단, 숫자 뒤의 .은 변환하지 않음 (예: 1.5) tokenizedExpr = tokenizedExpr.replace(/([a-zA-Z_$][a-zA-Z0-9_$]*)\.(?!\?)/g, '$1?.'); // $t: 패턴 복원 tokenizedExpr = tokenizedExpr.replace(/__TRANSLATION_TOKEN_(\d+)__/g, (_match, index) => { return translationTokens[parseInt(index, 10)]; }); // 문자열 리터럴 복원 tokenizedExpr = tokenizedExpr.replace(/__STRING_LITERAL_(\d+)__/g, (_match, index) => { return stringLiterals[parseInt(index, 10)]; }); return tokenizedExpr; } /** * 표현식에서 사용되는 최상위 변수명 추출 * * @param expr 표현식 * @returns 변수명 배열 */ private extractVariablesFromExpression(expr: string): string[] { // JavaScript 키워드 및 예약어 const reserved = new Set([ 'true', 'false', 'null', 'undefined', 'NaN', 'Infinity', 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'continue', 'return', 'function', 'class', 'const', 'let', 'var', 'new', 'delete', 'typeof', 'instanceof', 'this', 'super', 'import', 'export', 'default', 'try', 'catch', 'finally', 'throw', // 내장 객체 'Math', 'Date', 'JSON', 'Array', 'Object', 'String', 'Number', 'Boolean', 'RegExp', // 컬렉션/표준 내장 객체 (@since engine-v1.50.0) // 이들이 reserved 에 없으면 컨텍스트 변수로 오인되어 undefined 로 가려지고 // new Function 본문에서 `new Set(...)` 가 `new undefined()` 가 되어 실패한다. // eval/Function/globalThis/window 등 위험 전역은 의도적으로 제외한다. 'Set', 'Map', 'WeakSet', 'WeakMap', 'Symbol', 'Promise', 'BigInt', 'Error', // 전역 함수 'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'encodeURI', 'decodeURI', 'encodeURIComponent', 'decodeURIComponent', ]); // 식별자 패턴 (변수명 시작 위치) // 식별자는 문자, $, _로 시작하고 문자, 숫자, $, _로 계속됨 // @since engine-v1.60.6 lookbehind 제거 — 문두(^) 또는 선행 구분자 1글자를 소비한다. // 식별자 사이에는 항상 구분자가 1개 이상 있고 각 매치는 정확히 1개만 소비하므로 // 인접 매치가 손실되지 않는다. 식별자명은 match[2] 에 있다(구분자가 match[1]). const identifierPattern = /(^|[^.\w$])([a-zA-Z_$][a-zA-Z0-9_$]*)/g; const variables = new Set(); let match; while ((match = identifierPattern.exec(expr)) !== null) { const varName = match[2]; // 예약어가 아니고, 이미 추출되지 않았다면 추가 if (!reserved.has(varName)) { variables.add(varName); } } return Array.from(variables); } /** * $switch 표현식인지 확인 * * 객체가 $switch 키를 가지고 있으면 $switch 표현식으로 간주합니다. * * @param obj 확인할 객체 * @returns $switch 표현식 여부 * * @example * isSwitchExpression({ $switch: "{{tab}}", $cases: { ... } }) // true * isSwitchExpression({ type: "basic", name: "Div" }) // false */ public isSwitchExpression(obj: any): boolean { return ( obj !== null && typeof obj === 'object' && !Array.isArray(obj) && '$switch' in obj && '$cases' in obj ); } /** * $switch 표현식 해석 * * 동적 값에 따라 다른 값을 반환합니다. * 중첩 삼항 연산자를 대체하는 선언적 분기 표현식입니다. * * 트러블슈팅 주의사항: * - Stale Closure 방지: $switch 키 평가 시 항상 skipCache: true 적용 * - 상태 변경 후 즉시 반영을 위해 최신 컨텍스트에서 평가 * * @param switchDef $switch 정의 객체 * @param context 데이터 컨텍스트 * @param options 바인딩 옵션 * @returns 매칭되는 case의 값 또는 default 값 * * @example * ```typescript * const switchDef = { * $switch: "{{tab}}", * $cases: { * products: "shopping-bag", * contents: "book-open", * policies: "file-check" * }, * $default: "file-text" * }; * * resolveSwitch(switchDef, { tab: "products" }); * // 결과: "shopping-bag" * ``` */ public resolveSwitch( switchDef: { $switch: string; $cases: Record; $default?: any }, context: BindingContext, options?: BindingOptions, ): any { // 항상 skipCache: true로 평가 (Stale Closure 방지) const evalOptions = { ...options, skipCache: true }; // 1. $switch 키 표현식 평가 let keyValue: string; try { const resolved = this.resolveBindings(switchDef.$switch, context, evalOptions); // 빈 문자열이거나 undefined/null인 경우 처리 keyValue = resolved?.toString()?.trim() ?? ''; } catch (error) { logger.warn('resolveSwitch: $switch 키 평가 실패:', switchDef.$switch, error); keyValue = ''; } // 2. $cases에서 매칭되는 값 찾기 let result: any; if (keyValue && switchDef.$cases && keyValue in switchDef.$cases) { result = switchDef.$cases[keyValue]; } else if ('$default' in switchDef) { // 매칭되는 case가 없으면 $default 사용 result = switchDef.$default; } else { // $default도 없으면 undefined 반환 return undefined; } // 3. 결과 값도 바인딩 해석 (중첩 표현식 지원) if (typeof result === 'string') { // 바인딩 표현식이 있으면 해석 if (result.includes('{{')) { return this.resolveBindings(result, context, evalOptions); } return result; } else if (result !== null && typeof result === 'object') { // 중첩 객체인 경우 재귀적으로 해석 return this.resolveObject(result, context, evalOptions); } return result; } } /** * 전역 데이터 바인딩 엔진 인스턴스 * * @example * import { dataBindingEngine } from './DataBindingEngine'; * const result = dataBindingEngine.resolve("user.name", context); */ export const dataBindingEngine = new DataBindingEngine();