표현식 해석 로직이 엔진 전역에 복제되면서 복사본마다 기능 집합과 판정 기준이 갈라졌고, 같은 작성이 어느 렌더 경로를 타느냐에 따라 다른 결과를 냈다. 증상 대부분이 예외가 아니라 조용한 오답 또는 값 소실이라 재현 위치를 특정하기 어려웠다. 판정만 BindingShape 단일 출처로 모으고 실행은 지점별로 유지했다. 전면 통합은 회귀 위험이 크고, 갈라진 것은 실행이 아니라 판정이었기 때문이다. - 파이프 평가를 문자열 보간에 위임하면서 생긴 비대칭 3건 해소 (중괄호 노출 / _computed stale 전파 / 원본 타입 소실) - 파이프 분기 누락 8지점 보강 — 평가 지점 모집단을 스캔해 가드를 강제하는 회귀선 신설 - 단일 바인딩·복잡식 판정 14곳을 정본으로 치환, 빈 바인딩 봉인 - BINDING_PATTERN 정규식을 수동 스캐너로 교체, resolveObject 를 key 단위 예외 격리로 전환 - iteration + 항목 참조 if 가 목록 전체를 지우던 결함 해소, 반복 경로 기능 격차 정렬 - 템플릿 컴포넌트의 new Function 자체 평가기 제거 → G7Core.evaluateCondition 위임 - 컨텍스트에 식별자가 아닌 키가 하나라도 있으면 그 컨텍스트의 모든 식이 평가되지 못하던 결함 수정 - SEO 봇 렌더러도 같은 규칙으로 정렬 — React 만 고치면 봇 화면에서만 목록이 빈다 - audit walker 가 레이아웃의 74%(partial 루트 444/598)로 내려가지 못하던 사각 해소, 신규 룰 5종 등재
294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
/**
|
|
* 바인딩 식의 **형태 판정** 단일 출처
|
|
*
|
|
* "이 문자열이 단일 바인딩인가", "이 식이 경로인가 표현식인가 파이프인가" 를 판정하는
|
|
* 로직은 엔진 곳곳에 복제되어 있었고, 복사본마다 인식 문자 집합이 달라 **같은 식이
|
|
* 어느 렌더 경로를 타느냐에 따라 다른 결과**를 냈다. 판정은 이 모듈 하나로 모은다.
|
|
*
|
|
* 통일하는 것은 **판정뿐**이다. 캐시 정책·예외 처리·DevTools 추적·후처리(번역, raw 마커)는
|
|
* 지점마다 정당한 이유로 다르므로 각 호출 지점이 그대로 유지한다.
|
|
*
|
|
* 순수 함수만 두고 런타임 의존은 `PipeRegistry`(파이프 스캐너) 하나로 제한한다 —
|
|
* `DataBindingEngine` 은 `import type` 으로만 참조해 순환 import 를 만들지 않는다.
|
|
*
|
|
* @since engine-v1.55.0
|
|
*/
|
|
|
|
import { hasPipes } from './PipeRegistry';
|
|
import type { BindingContext, BindingOptions, DataBindingEngine } from './DataBindingEngine';
|
|
|
|
/**
|
|
* JavaScript 리터럴 값 매핑
|
|
*
|
|
* 경로 탐색(`resolve`)이 `true`/`null` 같은 리터럴을 컨텍스트 키로 오인하는 것을 막는다.
|
|
* `undefined` 는 `Map.get()` 의 기본 반환값과 구분되지 않으므로 `has()` 로 판정한다.
|
|
*/
|
|
export const LITERALS = new Map<string, unknown>([
|
|
['true', true],
|
|
['false', false],
|
|
['null', null],
|
|
['undefined', undefined],
|
|
]);
|
|
|
|
/**
|
|
* 바인딩 식의 형태
|
|
*
|
|
* - `empty`: `{{}}` — 식이 없음 (평가 대상 아님)
|
|
* - `literal`: `true` / `false` / `null` / `undefined`
|
|
* - `pipe`: 파이프(`|`)가 포함된 식
|
|
* - `expression`: 연산자·괄호·리터럴 등이 포함된 JS 표현식
|
|
* - `path`: 점/대괄호 경로 (`user.profile.name`, `items[0]`)
|
|
*/
|
|
export type ExpressionShape = 'empty' | 'literal' | 'pipe' | 'expression' | 'path';
|
|
|
|
/**
|
|
* 문자열 전체가 단일 `{{ }}` 바인딩인지 판정하고 내부 식을 반환
|
|
*
|
|
* 판정 기준(정본):
|
|
* - 따옴표(`'`, `"`, `` ` ``) 안의 중괄호는 세지 않는다 — `{{a['}']}}` 같은 식이 잘리지 않는다.
|
|
* - 따옴표 안의 백슬래시 이스케이프(`'\''`)는 다음 한 글자를 소비한다.
|
|
* - 중괄호 균형을 추적해, 외곽 바인딩이 중간에 닫히면(`"{{a}} {{b}}"`) 단일 바인딩이 아니다.
|
|
* - 균형이 맞지 않으면(`{{a} }}`) 단일 바인딩으로 보지 않는다.
|
|
* - 결과는 trim 한다.
|
|
*
|
|
* `{{}}` 는 빈 문자열을 반환한다 — "바인딩이긴 하나 식이 없음"이며, 호출 지점은
|
|
* `classifyExpression` 이 돌려주는 `'empty'` 로 이 상태를 구분해 처리한다.
|
|
*
|
|
* @param value 검사할 문자열
|
|
* @returns 단일 바인딩이면 내부 식(trim), 아니면 null
|
|
*/
|
|
export function extractSingleBinding(value: string): string | null {
|
|
if (typeof value !== 'string') return null;
|
|
if (!value.startsWith('{{') || !value.endsWith('}}') || value.length < 4) return null;
|
|
|
|
const inner = value.slice(2, -2);
|
|
let quote: string | null = null;
|
|
let depth = 0;
|
|
|
|
for (let i = 0; i < inner.length; i++) {
|
|
const char = inner[i];
|
|
|
|
if (quote !== null) {
|
|
if (char === '\\') {
|
|
i++; // 이스케이프된 문자 1개 소비
|
|
continue;
|
|
}
|
|
if (char === quote) quote = null;
|
|
continue;
|
|
}
|
|
|
|
if (char === '"' || char === "'" || char === '`') {
|
|
quote = char;
|
|
continue;
|
|
}
|
|
if (char === '{') {
|
|
depth++;
|
|
continue;
|
|
}
|
|
if (char === '}') {
|
|
if (depth > 0) {
|
|
depth--;
|
|
continue;
|
|
}
|
|
// 깊이 0 에서의 `}` → 외곽 바인딩이 중간에 닫혔거나 균형이 맞지 않는다.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return depth === 0 ? inner.trim() : null;
|
|
}
|
|
|
|
/**
|
|
* 문자열에서 찾은 `{{ }}` 바인딩 한 건
|
|
*/
|
|
export interface ScannedBinding {
|
|
/** 원본 문자열에서 `{{` 가 시작하는 인덱스 */
|
|
start: number;
|
|
/** 원본 문자열에서 `}}` 다음 인덱스 (slice 종료 지점) */
|
|
end: number;
|
|
/** `{{ }}` 를 벗긴 내부 식 (trim 하지 않은 원본) */
|
|
expr: string;
|
|
}
|
|
|
|
/**
|
|
* 문자열 안의 모든 `{{ }}` 바인딩 위치를 스캔
|
|
*
|
|
* 정규식 `\{\{([^}]+)\}\}` 은 식 안에 `}` 가 들어가면(`{{(row.meta ?? {}) | json}}`)
|
|
* 매칭에 실패한다. `String.replace` 는 매칭이 없으면 입력을 그대로 돌려주므로,
|
|
* 그 결과는 예외도 경고도 없이 **원본 `{{...}}` 문자열이 화면에 노출**되는 것이었다.
|
|
*
|
|
* 이 스캐너는 따옴표와 중괄호 깊이를 추적해 바인딩의 끝을 정확히 찾는다.
|
|
* 닫히지 않은 `{{` 는 바인딩으로 보지 않고 리터럴로 남긴다(종전 정규식과 같은 결과).
|
|
*
|
|
* @param template 검사할 문자열
|
|
* @returns 발견된 바인딩 목록 (등장 순서)
|
|
*
|
|
* @since engine-v1.55.1
|
|
*/
|
|
export function scanBindings(template: string): ScannedBinding[] {
|
|
const found: ScannedBinding[] = [];
|
|
if (typeof template !== 'string') return found;
|
|
|
|
for (let i = 0; i < template.length - 1; i++) {
|
|
if (template[i] !== '{' || template[i + 1] !== '{') continue;
|
|
|
|
let quote: string | null = null;
|
|
let depth = 0;
|
|
let closed = -1;
|
|
|
|
for (let j = i + 2; j < template.length; j++) {
|
|
const char = template[j];
|
|
|
|
if (quote !== null) {
|
|
if (char === '\\') {
|
|
j++;
|
|
continue;
|
|
}
|
|
if (char === quote) quote = null;
|
|
continue;
|
|
}
|
|
|
|
if (char === '"' || char === "'" || char === '`') {
|
|
quote = char;
|
|
continue;
|
|
}
|
|
if (char === '{') {
|
|
depth++;
|
|
continue;
|
|
}
|
|
if (char === '}') {
|
|
if (depth > 0) {
|
|
depth--;
|
|
continue;
|
|
}
|
|
if (template[j + 1] === '}') {
|
|
closed = j;
|
|
break;
|
|
}
|
|
// 깊이 0 에서 짝이 맞지 않는 `}` — 바인딩이 아니다.
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (closed === -1) continue; // 닫히지 않음 → 리터럴로 취급
|
|
|
|
found.push({ start: i, end: closed + 2, expr: template.slice(i + 2, closed) });
|
|
i = closed + 1; // 루프의 i++ 와 합쳐 바인딩 다음으로 이동
|
|
}
|
|
|
|
return found;
|
|
}
|
|
|
|
/**
|
|
* 복잡한 표현식 여부 판정
|
|
*
|
|
* true 면 `evaluateExpression`(JS 평가)으로, false 면 `resolve`(경로 탐색)로 보낸다.
|
|
*
|
|
* 인식 문자 집합은 종전 6개 방언 중 **최대집합**을 정본으로 삼는다. 좁은 방언은
|
|
* 배열/객체 리터럴(`['a','b']`)이나 따옴표 키 인덱싱(`query['sales_status[]']`)을
|
|
* "단순 경로" 로 오판해 경로 탐색으로 보냈고, 그 결과는 조용한 `undefined` 였다.
|
|
*
|
|
* `|`(파이프)도 집합에 포함되지만, 분류 순서상 파이프가 먼저 걸러지므로
|
|
* 파이프 식이 이 판정에 도달하지 않는다(`classifyExpression` 참조).
|
|
*
|
|
* @param expr 판정할 식 (`{{ }}` 제거된 상태)
|
|
* @returns 복잡한 표현식 여부
|
|
*/
|
|
export function isComplexExpression(expr: string): boolean {
|
|
return /[?:|&!+\-*/<>=()[\]{}]/.test(expr);
|
|
}
|
|
|
|
/**
|
|
* 바인딩 식의 형태 분류
|
|
*
|
|
* 분류 순서가 곧 라우팅 우선순위다: 빈 식 → 리터럴 → 파이프 → 표현식 → 경로.
|
|
* 리터럴을 파이프보다 먼저 보는 이유는 `true`/`null` 이 경로로 해석되면 안 되기 때문이고,
|
|
* 리터럴 문자열 자체에는 파이프가 들어갈 수 없어 순서 충돌이 없다.
|
|
*
|
|
* @param expr 분류할 식 (`{{ }}` 제거된 상태)
|
|
* @returns 식의 형태
|
|
*/
|
|
export function classifyExpression(expr: string): ExpressionShape {
|
|
const trimmed = typeof expr === 'string' ? expr.trim() : '';
|
|
if (trimmed === '') return 'empty';
|
|
if (LITERALS.has(trimmed)) return 'literal';
|
|
if (hasPipes(trimmed)) return 'pipe';
|
|
if (isComplexExpression(trimmed)) return 'expression';
|
|
return 'path';
|
|
}
|
|
|
|
/**
|
|
* 단일 바인딩 값 해석 옵션
|
|
*/
|
|
export interface ResolveSingleBindingOptions {
|
|
/** 캐시 사용 안 함 (반복 렌더 등) */
|
|
skipCache?: boolean;
|
|
|
|
/** DevTools 표현식 추적 정보 */
|
|
trackingInfo?: { componentId?: string; componentName?: string; propName?: string };
|
|
|
|
/**
|
|
* 평가 실패 시 호출된다. 반환값이 해석 결과가 된다.
|
|
* 미지정이면 예외를 그대로 전파한다 — 실패 정책은 호출 지점의 몫이다.
|
|
*/
|
|
onError?: (error: unknown, expr: string) => unknown;
|
|
|
|
/**
|
|
* 빈 바인딩(`{{}}`)을 만났을 때 호출된다. 미지정이면 조용히 `undefined` 를 반환한다.
|
|
*/
|
|
onEmpty?: (expr: string) => void;
|
|
|
|
// `format` 옵션은 두지 않는다.
|
|
//
|
|
// 이 함수는 **판정과 라우팅**만 담당하고 결과를 원본 타입으로 돌려준다. 서식은
|
|
// 호출 지점마다 규칙이 다르다 — `text` 노드는 문자열이 필요하고, prop 은 배열/객체/
|
|
// boolean 을 그대로 넘겨야 한다(원본 타입 소실이 engine-v1.54.9 에서 고친 결함이다).
|
|
// 여기에 서식을 얹으면 그 결함을 옵션 형태로 되살리는 셈이라, 서식은 호출 지점에 남긴다.
|
|
}
|
|
|
|
/**
|
|
* 단일 바인딩 식을 형태에 따라 알맞은 평가기로 보내 값을 해석
|
|
*
|
|
* 형태 판정(`classifyExpression`)과 라우팅만 담당하고, 실행은 전달받은 엔진에 위임한다.
|
|
* 결과는 **원본 타입**이다 — 문자열 서식이 필요한 호출 지점이 스스로 적용한다.
|
|
*
|
|
* @param expr 단일 바인딩 내부 식 (`{{ }}` 와 `raw:` 접두사가 제거된 상태)
|
|
* @param context 데이터 컨텍스트
|
|
* @param engine DataBindingEngine 인스턴스
|
|
* @param options 해석 옵션
|
|
* @returns 해석된 값 (빈 바인딩이면 undefined)
|
|
*/
|
|
export function resolveSingleBindingValue(
|
|
expr: string,
|
|
context: BindingContext,
|
|
engine: DataBindingEngine,
|
|
options?: ResolveSingleBindingOptions,
|
|
): unknown {
|
|
const trimmed = typeof expr === 'string' ? expr.trim() : '';
|
|
const shape = classifyExpression(trimmed);
|
|
const bindingOptions: BindingOptions | undefined = options?.skipCache
|
|
? { skipCache: true }
|
|
: undefined;
|
|
|
|
if (shape === 'empty') {
|
|
options?.onEmpty?.(expr);
|
|
return undefined;
|
|
}
|
|
if (shape === 'literal') {
|
|
return LITERALS.get(trimmed);
|
|
}
|
|
|
|
try {
|
|
if (shape === 'pipe') {
|
|
return engine.evaluatePipeExpression(trimmed, context, bindingOptions, options?.trackingInfo);
|
|
}
|
|
if (shape === 'expression') {
|
|
return engine.evaluateExpression(trimmed, context, options?.trackingInfo);
|
|
}
|
|
return engine.resolve(trimmed, context, bindingOptions, options?.trackingInfo);
|
|
} catch (error) {
|
|
if (options?.onError) return options.onError(error, trimmed);
|
|
throw error;
|
|
}
|
|
}
|