pipeRegistry = new PipeRegistry; } /** * 파이프 레지스트리를 반환합니다. * * @return PipeRegistry 파이프 레지스트리 인스턴스 */ public function getPipeRegistry(): PipeRegistry { return $this->pipeRegistry; } /** * 템플릿 번역 사전을 설정합니다. * * @param array $translations 번역 사전 (dot notation 접근 가능) */ public function setTranslations(array $translations): void { $this->translations = $translations; } /** * SEO 상태 오버라이드를 설정합니다. * * seo-config.json의 seo_overrides 섹션으로, SEO 렌더링 시 * _local/_global 상태에 기본값을 주입합니다. * 와일드카드("*")를 통해 동적 접근 시 기본값을 반환할 수 있습니다. * * @param array $overrides 오버라이드 정의 (예: {"_local": {"collapsedReplies": {"*": false}}}) */ public function setSeoOverrides(array $overrides): void { $this->seoOverrides = $overrides; } /** * 표현식이 포함된 문자열을 평가합니다. * * @param string $expression 표현식 문자열 * @param array $context 데이터 컨텍스트 * @return string 평가된 문자열 */ /** * 표현식을 평가하여 원본 값(배열/객체 포함)을 반환합니다. * * text_format의 dot notation 등에서 객체 prop을 해석할 때 사용합니다. * 단일 {{expr}} 패턴일 때만 원본 값을 반환하고, 복합 표현식은 문자열을 반환합니다. * * @param string $expression 표현식 * @param array $context 데이터 컨텍스트 * @return mixed 원본 값 (배열, 문자열, 숫자 등) */ public function evaluateRaw(string $expression, array $context): mixed { // 단일 {{expr}} 패턴인 경우 원본 값 반환 $trimmed = trim($expression); if ($this->isSingleBinding($trimmed)) { $expr = trim(substr($trimmed, 2, -2)); // raw: 접두사 제거 — 남겨 두면 콜론이 식의 일부로 파싱돼 평가가 실패한다. if (str_starts_with($expr, self::RAW_PREFIX)) { $expr = trim(substr($expr, strlen(self::RAW_PREFIX))); } // 파이프 표현식: 원본 값을 파이프로 변환 후 반환 if (PipeRegistry::hasPipes($expr)) { [$baseExpr, $pipes] = PipeRegistry::splitPipes($expr); $value = $this->resolveToValue($baseExpr, $context); foreach ($pipes as $pipeExpr) { $parsed = PipeRegistry::parsePipeExpression($pipeExpr); if ($this->pipeRegistry->has($parsed['name'])) { $value = $this->pipeRegistry->execute($parsed['name'], $value, $parsed['args']); } } return $value; } // 비교·논리 연산은 참/거짓 값으로 돌려준다. // `resolveToValue()` 는 경로/리터럴 해석기라 `(a ?? 'text') === 'html'` 같은 // 비교식을 null 로 되돌린다. 그 null 이 확장 주입 props 의 기본값으로 흘러들면 // (예: `isHtml ?? true`) 레이아웃이 선언한 판정이 통째로 뒤집힌다. if ($this->isBooleanExpression($expr)) { return $this->coerceBooleanString($this->evaluate($trimmed, $context)); } // 연산자/함수 호출/리터럴이 포함된 표현식은 resolveToValue로 처리 if (str_contains($expr, '??') || str_contains($expr, '?') || preg_match('/[\w$]+\s*\(/', $expr) || str_starts_with($expr, '{') || str_contains($expr, '...')) { return $this->resolveToValue($expr, $context); } return $this->resolvePath($expr, $context); } // 복합 표현식은 문자열로 반환 return $this->evaluate($expression, $context); } /** * 표현식이 비교·논리 연산인지 판정합니다. * * 삼항(`? :`)과 화살표 함수(`=>`)는 결과가 참/거짓이 아니므로 제외합니다 * (`??` 와 `?.` 은 삼항이 아니라 제외 대상에서 먼저 걷어냅니다). * * @param string $expr `{{}}` 를 벗겨낸 표현식 * @return bool 비교·논리 연산이면 true */ private function isBooleanExpression(string $expr): bool { $normalized = str_replace(['??', '?.'], '', $expr); if (str_contains($normalized, '=>') || str_contains($normalized, '?')) { return false; } if (str_starts_with(trim($expr), '!')) { return true; } return preg_match('/(===|!==|==|!=|>=|<=|>|<|&&|\|\|)/', $normalized) === 1; } /** * 참/거짓 문자열을 bool 로 되돌립니다. * * `||` 처럼 피연산자 값을 그대로 돌려주는 연산도 있으므로, 참/거짓 문자열이 * 아니면 원본 문자열을 유지합니다. * * @param string $value 평가 결과 문자열 * @return mixed bool 또는 원본 문자열 */ private function coerceBooleanString(string $value): mixed { return match ($value) { 'true' => true, 'false' => false, default => $value, }; } /** * 평가 결과 문자열의 truthy 여부를 판정합니다. * * 논리 연산자(`||`, `&&`)의 단락 평가 기준입니다. * * @param string $value 평가 결과 문자열 * @return bool truthy 이면 true */ private function isTruthyValue(string $value): bool { return $value !== '' && $value !== 'false' && $value !== '0'; } /** * 표현식이 단일 {{expr}} 바인딩 하나로만 구성되는지 판정합니다. * * `{{a}} - {{b}}` 처럼 바인딩이 둘 이상이면 false 를 반환해야 합니다. * 정규식 `/^\{\{(.+?)\}\}$/` 는 양끝 앵커 때문에 이런 다중 바인딩도 매치되어 * 내부가 `a}} - {{b` 로 잘못 해석되므로, 중괄호 깊이를 직접 추적합니다. * 객체 리터럴(`{{ {outer: {inner: 'v'}} }}`)처럼 내부에 `}}` 가 등장하는 * 경우까지 정확히 구분하기 위함입니다. * * @param string $trimmed 앞뒤 공백이 제거된 표현식 * @return bool 단일 바인딩이면 true */ private function isSingleBinding(string $trimmed): bool { $length = strlen($trimmed); if ($length < 5 || ! str_starts_with($trimmed, '{{') || ! str_ends_with($trimmed, '}}')) { return false; } // 여는 `{{` 를 깊이 2 로 두고 스캔 — 마지막 문자 이전에 깊이가 0 이 되면 바인딩이 둘 이상 $depth = 2; for ($i = 2; $i < $length; $i++) { $char = $trimmed[$i]; if ($char === '{') { $depth++; continue; } if ($char !== '}') { continue; } $depth--; if ($depth === 0) { return $i === $length - 1; } } return false; } /** * ?? 연산자를 포함한 표현식을 원본 값으로 평가합니다. * * evaluateRaw에서 단일 {{expr ?? fallback}} 패턴 처리 시 사용됩니다. * 좌측 경로의 원본 값(배열/객체 등)을 유지하면서 null coalescing을 수행합니다. * * @param string $expr 표현식 ({{ }} 없이) * @param array $context 데이터 컨텍스트 * @return mixed 해석된 원본 값 */ private function evaluateRawWithNullCoalescing(string $expr, array $context): mixed { $parts = $this->splitOutsideBrackets($expr, '??'); if (count($parts) !== 2) { return $this->resolvePath($expr, $context); } $value = $this->resolvePath(trim($parts[0]), $context); if ($value !== null && $value !== '') { return $value; } $fallback = trim($parts[1]); // 빈 배열 리터럴 [] if ($fallback === '[]') { return []; } // 따옴표 감싸인 문자열 리터럴 if (preg_match('/^[\'"](.*)[\'"]\s*$/', $fallback, $m)) { return $m[1]; } // 숫자 리터럴 if (is_numeric($fallback)) { return $fallback + 0; } // 표현식으로 재귀 해석 return $this->resolvePath($fallback, $context); } /** * SEO 메타 표현식을 컨텍스트와 함께 평가하여 최종 문자열을 반환합니다. * * `$t:` 번역 키 prefix, `{{path | pipe(arg)}}` 바인딩, 파이프 체인을 모두 지원합니다. * * @param string $expression 평가 대상 표현식 (예: `'$t:seo.title'`, `'{{post.title | upper}}'`) * @param array $context 바인딩 컨텍스트 (route/post/locale 등) * @return string 평가 결과 문자열 (해석 실패 시 원본 표현식 또는 빈 문자열) */ public function evaluate(string $expression, array $context): string { // $t: 번역 키 처리 (전체가 $t:로 시작하는 경우) if (str_starts_with($expression, '$t:')) { return $this->resolveTranslation($expression, $context); } // raw: 접두사(번역 면제 바인딩)를 벗기고 후처리 대상에서 제외한다. // React 는 `{{raw:expr}}` 의 접두사를 떼고 평가한 뒤 결과의 `$t:` 토큰을 // 번역하지 않는다(rawMarkers). 여기서 접두사를 그대로 두면 콜론이 식의 // 일부로 파싱되어 평가가 실패하고, 봇 화면에서만 값이 사라진다. $rawExempt = false; // {{binding | pipe}} 패턴 치환 (파이프 표현식 지원) $result = (string) preg_replace_callback('/\{\{(.+?)\}\}/', function ($matches) use ($context, &$rawExempt) { $expr = trim($matches[1]); if (str_starts_with($expr, self::RAW_PREFIX)) { $rawExempt = true; $expr = trim(substr($expr, strlen(self::RAW_PREFIX))); } // 파이프 분리: expr | pipe1 | pipe2(arg) if (PipeRegistry::hasPipes($expr)) { [$baseExpr, $pipes] = PipeRegistry::splitPipes($expr); $value = $this->resolveToValue($baseExpr, $context); foreach ($pipes as $pipeExpr) { $parsed = PipeRegistry::parsePipeExpression($pipeExpr); if ($this->pipeRegistry->has($parsed['name'])) { $value = $this->pipeRegistry->execute($parsed['name'], $value, $parsed['args']); } } return $this->valueToString($value); } return $this->evaluateExpression($expr, $context); }, $expression); // 인라인 $t:key 토큰 해석 ({{}} 외부 텍스트 내 $t:key|param=value 패턴) // raw: 바인딩 결과는 번역 면제 — React 와 동일하게 토큰을 그대로 둔다. if (! $rawExempt && str_contains($result, '$t:')) { $result = preg_replace_callback('/\$t:(?:defer:)?([\w.\-]+(?:\|[\w.\-]+=[\w.\-{}]+)*)/', function ($matches) use ($context) { return $this->resolveTranslation('$t:'.$matches[1], $context); }, $result); } // 후처리: 빈 {{}} 치환으로 남은 고립된 연산자/부호 정리 (예: "+", "-") $cleaned = trim($result); if ($cleaned !== '' && strlen($cleaned) <= 2 && preg_match('/^[+\-\/*]+$/', $cleaned)) { return ''; } return $result; } /** * $t: 번역 키를 해석합니다. * * `$t:defer:key` 형태(프론트엔드에서 렌더 시점까지 해석을 미루는 표기)도 * 동일한 키로 취급합니다 — SEO는 서버에서 한 번에 해석하므로 지연 개념이 * 없고, `defer:` 를 키에 포함시키면 사전 조회가 실패해 빈 문자열이 됩니다. * * @param string $expression 번역 표현식 ($t:key, $t:defer:key 또는 $t:key|param=value) * @param array $context 데이터 컨텍스트 (파라미터 값의 {{}} 표현식 해석용) * @return string 번역된 문자열 */ private function resolveTranslation(string $expression, array $context = []): string { $key = substr($expression, 3); if (str_starts_with($key, 'defer:')) { $key = substr($key, 6); } // 파라미터 분리: $t:key|param1=value1|param2=value2 $params = []; if (str_contains($key, '|')) { $parts = explode('|', $key); $key = $parts[0]; foreach (array_slice($parts, 1) as $param) { if (str_contains($param, '=')) { [$paramKey, $paramValue] = explode('=', $param, 2); $paramValue = trim($paramValue); // {{}} 표현식이 포함된 파라미터 값을 해석 if (str_contains($paramValue, '{{') && ! empty($context)) { $paramValue = (string) preg_replace_callback('/\{\{(.+?)\}\}/', function ($m) use ($context) { return $this->evaluateExpression(trim($m[1]), $context); }, $paramValue); } $params[trim($paramKey)] = $paramValue; } } } // 1. 템플릿 번역 사전에서 먼저 조회 if (! empty($this->translations)) { $translated = Arr::get($this->translations, $key); if ($translated !== null && is_string($translated)) { // 파라미터 치환: :param 또는 {{param}} → value // 템플릿 번역은 {{param}} 형식, Laravel 번역은 :param 형식 foreach ($params as $paramKey => $paramValue) { $translated = str_replace( ['{{'.$paramKey.'}}', ':'.$paramKey], [$paramValue, $paramValue], $translated ); } return $translated; } } // 2. Laravel 번역 fallback $translated = __($key, $params); // 번역 키가 그대로 반환되면 빈 문자열 (SEO 페이지에서 raw key 노출 방지) if ($translated === $key) { return ''; } return $translated; } /** * 단일 표현식을 평가합니다. * * @param string $expr 표현식 * @param array $context 데이터 컨텍스트 * @return string 평가된 값 */ private function evaluateExpression(string $expr, array $context): string { // 0. 외부 괄호 제거: (expr) → expr (재귀 평가) if (str_starts_with($expr, '(') && str_ends_with($expr, ')')) { $inner = substr($expr, 1, -1); $depth = 0; $valid = true; for ($i = 0; $i < strlen($inner); $i++) { if ($inner[$i] === '(') { $depth++; } elseif ($inner[$i] === ')') { $depth--; if ($depth < 0) { $valid = false; break; } } } if ($valid && $depth === 0) { return $this->evaluateExpression($inner, $context); } } // 0.5. 괄호 하위 표현식 + 후속 프로퍼티 접근: (expr).property // JavaScript의 (array ?? []).length 같은 패턴을 PHP에서 해석 // @since engine-v1.17.6 if (str_starts_with($expr, '(')) { $depth = 0; $closingPos = null; for ($i = 0; $i < strlen($expr); $i++) { if ($expr[$i] === '(') { $depth++; } elseif ($expr[$i] === ')') { $depth--; if ($depth === 0) { $closingPos = $i; break; } } } if ($closingPos !== null && $closingPos < strlen($expr) - 1) { $trailing = substr($expr, $closingPos + 1); // (subexpr).prop 패턴: 후속 부분이 .으로 시작하고 단순 프로퍼티 경로 if (preg_match('/^\.([\w.]+)$/', $trailing, $propMatch)) { $innerExpr = substr($expr, 1, $closingPos - 1); $innerValue = $this->resolveToValue($innerExpr, $context); if ($innerValue !== null) { $current = $innerValue; foreach (explode('.', $propMatch[1]) as $seg) { if ($seg === '') { continue; } $virtual = $this->resolveVirtualProperty($current, $seg); if ($virtual !== null) { $current = $virtual; continue; } if (is_array($current) && array_key_exists($seg, $current)) { $current = $current[$seg]; continue; } return ''; } return $this->valueToString($current); } } } } // 1. 삼항 연산자: a ? b : c (JS 우선순위에서 ||/&& 보다 낮으므로 최우선 분리) $ternaryResult = $this->evaluateTernary($expr, $context); if ($ternaryResult !== null) { return $ternaryResult; } // 1.5. null coalescing: value ?? 'default' (브래킷 내부 ?? 제외) if (str_contains($expr, '??')) { $parts = $this->splitOutsideBrackets($expr, '??'); if (count($parts) === 2) { $value = $this->evaluateExpression(trim($parts[0]), $context); if ($value !== '') { return $value; } $fallback = trim($parts[1]); // 따옴표 감싸인 문자열 리터럴 if (preg_match('/^[\'"](.*)[\'"]\s*$/', $fallback, $m)) { return $m[1]; } // 빈 배열 리터럴 [] if ($fallback === '[]') { return ''; } // 표현식으로 재귀 평가 return $this->evaluateExpression($fallback, $context); } } // 2. 논리 OR: a || b // JS 시맨틱 — boolean이 아니라 "첫 truthy 피연산자"를 반환한다. // ({{query?.q || ''}} 같은 표현식이 값 대신 'true'가 되는 것을 방지) if (str_contains($expr, '||')) { $parts = $this->splitOutsideBrackets($expr, '||'); if (count($parts) === 2) { $leftVal = $this->evaluateExpression(trim($parts[0]), $context); if ($this->isTruthyValue($leftVal)) { return $leftVal; } return $this->evaluateExpression(trim($parts[1]), $context); } } // 3. 논리 AND: a && b // JS 시맨틱 — 좌변이 falsy면 좌변을, 아니면 우변을 반환한다. if (str_contains($expr, '&&')) { $parts = $this->splitOutsideBrackets($expr, '&&'); if (count($parts) === 2) { $leftVal = $this->evaluateExpression(trim($parts[0]), $context); if (! $this->isTruthyValue($leftVal)) { return $leftVal; } return $this->evaluateExpression(trim($parts[1]), $context); } } // 4. 비교 연산자: a > b, a === 'value', 등 if ($this->isSimpleComparison($expr)) { return $this->evaluateComparison($expr, $context); } // 4.5. 산술 연산: expr +-*/% N (우변이 숫자 리터럴인 경우) if (preg_match('/^(.+?)\s*([+\-*\/%])\s*(\d+(?:\.\d+)?)$/', $expr, $arithMatch)) { $leftVal = $this->evaluateExpression(trim($arithMatch[1]), $context); if (is_numeric($leftVal)) { $operator = $arithMatch[2]; $right = (float) $arithMatch[3]; $left = (float) $leftVal; $result = match ($operator) { '+' => $left + $right, '-' => $left - $right, '*' => $left * $right, '/' => $right != 0 ? $left / $right : 0, '%' => $right != 0 ? fmod($left, $right) : 0, }; return (string) ($result == (int) $result ? (int) $result : $result); } } // 5. 부정 연산자: !path if (str_starts_with($expr, '!') && ! str_starts_with($expr, '!=')) { $inner = trim(substr($expr, 1)); $value = $this->evaluateExpression($inner, $context); return (! $value || $value === 'false' || $value === '0') ? 'true' : 'false'; } // 6. 메서드/함수 호출 처리 ($t, $localized 등 $ 접두사 함수 포함) if (preg_match('/[\w$]+\s*\(/', $expr)) { // 7a. 순수 메서드 호출: expr.method(args) if (str_ends_with($expr, ')')) { $callResult = $this->evaluateCallExpression($expr, $context); if ($callResult !== null) { return $this->valueToString($callResult); } } // 7b. 메서드 호출 + 후속 프로퍼티: expr.method(args).prop $lastParen = strrpos($expr, ')'); if ($lastParen !== false && $lastParen < strlen($expr) - 1) { $methodPart = substr($expr, 0, $lastParen + 1); $propPart = substr($expr, $lastParen + 1); if (str_starts_with($propPart, '.')) { $callResult = $this->evaluateCallExpression($methodPart, $context); if ($callResult !== null) { $current = $callResult; foreach (explode('.', str_replace('?.', '.', ltrim($propPart, '.'))) as $seg) { if ($seg === '') { continue; } $virtual = $this->resolveVirtualProperty($current, $seg); if ($virtual !== null) { $current = $virtual; continue; } if (is_array($current) && array_key_exists($seg, $current)) { $current = $current[$seg]; continue; } return ''; } return $this->valueToString($current); } } } return ''; } // 8. 리터럴 값 감지 (숫자, 따옴표 문자열, boolean/null) if (preg_match('/^-?\d+(\.\d+)?$/', $expr)) { return $expr; // 숫자 리터럴 } if (preg_match('/^[\'"](.*)[\'"]\s*$/', $expr, $litMatch)) { return $litMatch[1]; // 문자열 리터럴 } if ($expr === 'true' || $expr === 'false') { return $expr; } if ($expr === 'null' || $expr === 'undefined') { return ''; } // 9. 경로 해석 (브래킷 접근자 포함) $value = $this->resolvePath($expr, $context); return $value !== null ? $this->valueToString($value) : ''; } /** * 단순 비교 표현식인지 확인합니다. * * @param string $expr 표현식 * @return bool 비교 표현식 여부 */ private function isSimpleComparison(string $expr): bool { // 우측: 숫자, 따옴표 문자열, boolean/null 리터럴, 단순 경로 return (bool) preg_match( '/^(.+?)\s*(===|!==|==|!=|>=|<=|>|<)\s*([\d.\-]+|\'[^\']*\'|"[^"]*"|true|false|null|[\w.\?\[\]]+)$/', $expr ); } /** * 비교 표현식을 평가합니다. * * @param string $expr 비교 표현식 * @param array $context 데이터 컨텍스트 * @return string 'true' 또는 'false' */ private function evaluateComparison(string $expr, array $context): string { if (! preg_match('/^(.+?)\s*(===|!==|==|!=|>=|<=|>|<)\s*(.+)$/', $expr, $matches)) { return ''; } $leftExpr = trim($matches[1]); $operator = $matches[2]; $rightExpr = trim($matches[3]); // 좌측 해석 (복합 표현식 또는 단순 경로) // ?.는 optional chaining(경로 구문)이므로 제외, 실제 연산자만 매칭 if (preg_match('/\?\?|\|\||&&|\(|!/', $leftExpr)) { // 원본 타입 보존 평가 시도 (배열 다중 값 매칭 지원) $leftValue = $this->evaluateComparisonOperand($leftExpr, $context); } else { $leftValue = $this->resolvePath($leftExpr, $context); } // 우측 해석 (리터럴 또는 경로) $rightValue = $this->resolveComparisonValue($rightExpr, $context); // 좌측이 null이면 JavaScript null 시맨틱 적용 // SEO 컨텍스트에서 _global/_local 등 미존재 경로 참조 시 null 반환 // null !== value → true, null === value → false (JavaScript 동작과 일치) if ($leftValue === null) { return match ($operator) { '!==', '!=' => ($rightValue === null) ? 'false' : 'true', '===', '==' => ($rightValue === null) ? 'true' : 'false', default => '', }; } $result = match ($operator) { '===', '==' => $this->looseEquals($leftValue, $rightValue), '!==', '!=' => ! $this->looseEquals($leftValue, $rightValue), '>' => is_numeric($leftValue) && is_numeric($rightValue) && (float) $leftValue > (float) $rightValue, '<' => is_numeric($leftValue) && is_numeric($rightValue) && (float) $leftValue < (float) $rightValue, '>=' => is_numeric($leftValue) && is_numeric($rightValue) && (float) $leftValue >= (float) $rightValue, '<=' => is_numeric($leftValue) && is_numeric($rightValue) && (float) $leftValue <= (float) $rightValue, default => false, }; return $result ? 'true' : 'false'; } /** * 비교 좌측 복합 표현식을 원본 타입 보존하며 평가합니다. * * evaluateExpression()은 string만 반환하므로 배열 타입이 유실됩니다. * 이 메서드는 (path ?? fallback) 패턴에서 배열 오버라이드를 보존하여 * looseEquals()의 배열 다중 값 매칭이 동작하도록 합니다. * * @param string $expr 좌측 표현식 (예: "(_local.activeTab ?? 'info')") * @param array $context 데이터 컨텍스트 * @return mixed 해석된 값 (배열 타입 보존) */ private function evaluateComparisonOperand(string $expr, array $context): mixed { // 외부 괄호 제거 $inner = $expr; if (str_starts_with($inner, '(') && str_ends_with($inner, ')')) { $body = substr($inner, 1, -1); $depth = 0; $valid = true; for ($i = 0; $i < strlen($body); $i++) { if ($body[$i] === '(') { $depth++; } elseif ($body[$i] === ')') { $depth--; if ($depth < 0) { $valid = false; break; } } } if ($valid && $depth === 0) { $inner = $body; } } // 단순 null coalescing (path ?? fallback): 원본 타입 보존 if (str_contains($inner, '??')) { $parts = $this->splitOutsideBrackets($inner, '??'); if (count($parts) === 2 && ! preg_match('/\|\||&&/', $inner)) { return $this->evaluateRawWithNullCoalescing($inner, $context); } } // 기타 복합 표현식: evaluateExpression으로 문자열 평가 (기존 동작) $evaluated = $this->evaluateExpression($expr, $context); return $evaluated !== '' ? $evaluated : null; } /** * 비교 우측 값을 해석합니다. * * @param string $expr 우측 표현식 * @param array $context 데이터 컨텍스트 * @return mixed 해석된 값 */ private function resolveComparisonValue(string $expr, array $context): mixed { // 따옴표 문자열 리터럴 if (preg_match('/^[\'"](.*)[\'"]\s*$/', $expr, $matches)) { return $matches[1]; } // 숫자 리터럴 if (is_numeric($expr)) { return $expr + 0; } // Boolean/null 리터럴 if ($expr === 'true') { return true; } if ($expr === 'false') { return false; } if ($expr === 'null') { return null; } // 경로 참조 return $this->resolvePath($expr, $context); } /** * 느슨한 동등 비교를 수행합니다. * * seo_overrides에서 배열 값이 설정된 경우 다중 값 매칭을 지원합니다. * 예: _local.activeTab = ["info", "reviews", "qna"] 설정 시 * _local.activeTab === 'reviews' → true (in_array 매칭) * * @param mixed $a 좌측 값 * @param mixed $b 우측 값 * @return bool 동등 여부 */ private function looseEquals(mixed $a, mixed $b): bool { // seo_overrides 배열 다중 값 매칭: 한쪽이 배열이면 in_array 체크 // SEO 렌더링에서 탭/아코디언 등 다중 조건을 동시에 true로 만들기 위한 기능 if (is_array($a) && ! is_array($b)) { return in_array($b, $a, false); } if (is_array($b) && ! is_array($a)) { return in_array($a, $b, false); } if (is_string($a) && is_string($b)) { return $a === $b; } if (is_numeric($a) && is_numeric($b)) { return (float) $a === (float) $b; } return $a == $b; } /** * 삼항 연산자를 평가합니다. * * `condition ? trueValue : falseValue` 형태의 삼항 표현식을 파싱하여 평가합니다. * `?.` (optional chaining)와 `?` (삼항)을 정확히 구분하며, * 중첩 삼항은 우측 결합(right-associative)으로 처리합니다. * * @param string $expr 표현식 * @param array $context 데이터 컨텍스트 * @return string|null 평가 결과 (삼항이 아니면 null) */ private function evaluateTernary(string $expr, array $context): ?string { $len = strlen($expr); $depth = 0; // () [] 깊이 $inSingle = false; // 작은따옴표 문자열 내부 $inDouble = false; // 큰따옴표 문자열 내부 $questionPos = -1; // 1단계: 최외곽 삼항 `?` 위치 찾기 (?.은 건너뜀) for ($i = 0; $i < $len; $i++) { $char = $expr[$i]; // 따옴표 추적 if ($char === "'" && ! $inDouble) { $inSingle = ! $inSingle; continue; } if ($char === '"' && ! $inSingle) { $inDouble = ! $inDouble; continue; } if ($inSingle || $inDouble) { continue; } // 괄호/브래킷 깊이 추적 if ($char === '(' || $char === '[') { $depth++; continue; } if ($char === ')' || $char === ']') { $depth--; continue; } // depth === 0일 때만 삼항 `?` 검사 if ($depth === 0 && $char === '?') { // `?.` (optional chaining)이면 건너뛰기 if ($i + 1 < $len && $expr[$i + 1] === '.') { $i++; // `.` 건너뛰기 continue; } // `??` (null coalescing)이면 건너뛰기 if ($i + 1 < $len && $expr[$i + 1] === '?') { $i++; // 두 번째 `?` 건너뛰기 continue; } $questionPos = $i; break; } } if ($questionPos < 0) { return null; // 삼항 아님 } // 2단계: `?` 이후에서 매칭되는 `:` 찾기 $depth = 0; $inSingle = false; $inDouble = false; $colonPos = -1; for ($i = $questionPos + 1; $i < $len; $i++) { $char = $expr[$i]; if ($char === "'" && ! $inDouble) { $inSingle = ! $inSingle; continue; } if ($char === '"' && ! $inSingle) { $inDouble = ! $inDouble; continue; } if ($inSingle || $inDouble) { continue; } if ($char === '(' || $char === '[') { $depth++; continue; } if ($char === ')' || $char === ']') { $depth--; continue; } // depth === 0이고 삼항의 `:` (`::`가 아닌) if ($depth === 0 && $char === ':') { $colonPos = $i; break; } } if ($colonPos < 0) { return null; // `:` 없음 → 삼항 아님 } // 3단계: 조건 / true 분기 / false 분기 분리 후 평가 $condition = trim(substr($expr, 0, $questionPos)); $trueExpr = trim(substr($expr, $questionPos + 1, $colonPos - $questionPos - 1)); $falseExpr = trim(substr($expr, $colonPos + 1)); // 우측 결합: 나머지 전체가 false 분기 $condResult = $this->evaluateExpression($condition, $context); $isTruthy = $condResult !== '' && $condResult !== 'false' && $condResult !== '0' && $condResult !== 'null'; return $isTruthy ? $this->evaluateExpression($trueExpr, $context) : $this->evaluateExpression($falseExpr, $context); } /** * 삼항 연산자를 평가하여 원본 값(raw)을 반환합니다. * * resolveToValue()에서 호출되며, 결과를 [value] 배열로 래핑하여 * null 결과와 "삼항이 아님"을 구분합니다. * * @param string $expr 표현식 * @param array $context 데이터 컨텍스트 * @return array|null [value] (삼항이면) 또는 null (삼항이 아니면) */ private function evaluateTernaryRaw(string $expr, array $context): ?array { $len = strlen($expr); $depth = 0; $inSingle = false; $inDouble = false; $questionPos = -1; for ($i = 0; $i < $len; $i++) { $char = $expr[$i]; if ($char === "'" && ! $inDouble) { $inSingle = ! $inSingle; continue; } if ($char === '"' && ! $inSingle) { $inDouble = ! $inDouble; continue; } if ($inSingle || $inDouble) { continue; } if ($char === '(' || $char === '[') { $depth++; continue; } if ($char === ')' || $char === ']') { $depth--; continue; } if ($depth === 0 && $char === '?') { if ($i + 1 < $len && ($expr[$i + 1] === '.' || $expr[$i + 1] === '?')) { $i++; continue; } $questionPos = $i; break; } } if ($questionPos < 0) { return null; } $depth = 0; $inSingle = false; $inDouble = false; $colonPos = -1; for ($i = $questionPos + 1; $i < $len; $i++) { $char = $expr[$i]; if ($char === "'" && ! $inDouble) { $inSingle = ! $inSingle; continue; } if ($char === '"' && ! $inSingle) { $inDouble = ! $inDouble; continue; } if ($inSingle || $inDouble) { continue; } if ($char === '(' || $char === '[') { $depth++; continue; } if ($char === ')' || $char === ']') { $depth--; continue; } if ($depth === 0 && $char === ':') { $colonPos = $i; break; } } if ($colonPos < 0) { return null; } $condition = trim(substr($expr, 0, $questionPos)); $trueExpr = trim(substr($expr, $questionPos + 1, $colonPos - $questionPos - 1)); $falseExpr = trim(substr($expr, $colonPos + 1)); $condResult = $this->evaluateExpression($condition, $context); $isTruthy = $condResult !== '' && $condResult !== 'false' && $condResult !== '0' && $condResult !== 'null'; return [$this->resolveToValue($isTruthy ? $trueExpr : $falseExpr, $context)]; } /** * 브래킷 외부에서 구분자로 문자열을 분할합니다. * * [] 또는 () 내부의 구분자는 무시합니다. * * @param string $expr 표현식 * @param string $delimiter 구분자 (예: '??', '||', '&&') * @return array 분할된 배열 (분할 불가 시 [$expr]) */ private function splitOutsideBrackets(string $expr, string $delimiter): array { $depth = 0; $len = strlen($expr); $delimLen = strlen($delimiter); for ($i = 0; $i <= $len - $delimLen; $i++) { $char = $expr[$i]; if ($char === '[' || $char === '(') { $depth++; } elseif ($char === ']' || $char === ')') { $depth--; } elseif ($depth === 0 && substr($expr, $i, $delimLen) === $delimiter) { return [ substr($expr, 0, $i), substr($expr, $i + $delimLen), ]; } } return [$expr]; } /** * dot notation + optional chaining + 배열 인덱스 + 브래킷 접근자로 값을 해석합니다. * * @param string $path 경로 * @param array $context 데이터 컨텍스트 * @return mixed 해석된 값 또는 null */ private function resolvePath(string $path, array $context): mixed { // optional chaining (?.) → dot notation으로 변환 $normalizedPath = str_replace('?.', '.', $path); // 동적 브래킷 접근자 감지: path.[expr].rest (단순 숫자 인덱스 제외) if (preg_match('/\[(?!\d+\])/', $normalizedPath)) { return $this->resolvePathWithBrackets($normalizedPath, $context); } // 배열 인덱스 처리: images[0] → images.0 $normalizedPath = preg_replace('/\[(\d+)\]/', '.$1', $normalizedPath); // 1차: SEO 오버라이드 매칭 (배열 다중 값 매칭은 context 값보다 우선) // 예: _local.activeTab = ["info", "reviews", "qna"] → 탭 조건 모두 true $override = $this->matchSeoOverride($normalizedPath); if ($override !== null) { // 배열 다중 값 오버라이드: context 값보다 항상 우선 (모든 조건을 동시에 충족시키기 위함) if (is_array($override) && ! array_key_exists('*', $override)) { return $override; } // 와일드카드 오버라이드: context 값이 없을 때만 적용 (기존 동작 유지) $value = Arr::get($context, $normalizedPath); if ($value !== null) { return $value; } return $override; } // 2차: Arr::get()으로 직접 해석 시도 $value = Arr::get($context, $normalizedPath); if ($value !== null) { return $value; } // 3차: 세그먼트별 순회하며 가상 프로퍼티 해석 return $this->resolvePathWithVirtualProps($normalizedPath, $context); } /** * 세그먼트별로 경로를 순회하며 가상 프로퍼티를 해석합니다. * * PHP 배열/문자열에 존재하지 않지만 JavaScript에서 지원하는 * 프로퍼티(length 등)를 부모 값의 타입에 따라 동적으로 해석합니다. * * @param string $normalizedPath 정규화된 경로 (dot notation) * @param array $context 데이터 컨텍스트 * @return mixed 해석된 값 또는 null */ private function resolvePathWithVirtualProps(string $normalizedPath, array $context): mixed { $segments = explode('.', $normalizedPath); $current = $context; for ($i = 0; $i < count($segments); $i++) { $segment = $segments[$i]; // 현재 값이 배열이고 키가 존재하면 진행 if (is_array($current) && array_key_exists($segment, $current)) { $current = $current[$segment]; continue; } // 키가 존재하지 않으면 → 가상 프로퍼티 해석 시도 $virtualValue = $this->resolveVirtualProperty($current, $segment); if ($virtualValue !== null) { // 가상 프로퍼티 뒤에 추가 세그먼트가 있으면 계속 순회 if ($i < count($segments) - 1) { $remainingPath = implode('.', array_slice($segments, $i + 1)); return is_array($virtualValue) ? Arr::get($virtualValue, $remainingPath) : null; } return $virtualValue; } return null; } return $current; } /** * 값의 타입에 따라 JavaScript 가상 프로퍼티를 해석합니다. * * @param mixed $value 부모 값 * @param string $property 프로퍼티 이름 * @return mixed 해석된 값 또는 null (미지원 프로퍼티) */ private function resolveVirtualProperty(mixed $value, string $property): mixed { if (is_array($value)) { return match ($property) { 'length' => count($value), default => null, }; } if (is_string($value)) { return match ($property) { 'length' => mb_strlen($value), default => null, }; } return null; } /** * SEO 오버라이드에서 경로 매칭을 수행합니다. * * seo_overrides._local / seo_overrides._global 내의 와일드카드("*") 키를 * 사용하여, 동적 브래킷 접근이나 미존재 경로에 대해 기본값을 반환합니다. * * 예: normalizedPath = "_local.collapsedReplies.5" * 오버라이드: {"_local": {"collapsedReplies": {"*": false}}} * → "collapsedReplies.*" 와일드카드 매칭 → false 반환 * * @param string $normalizedPath 정규화된 경로 (dot notation) * @return mixed 매칭된 오버라이드 값 또는 null (매칭 없음) */ private function matchSeoOverride(string $normalizedPath): mixed { if (empty($this->seoOverrides)) { return null; } foreach (['_local', '_global'] as $scope) { $scopeOverrides = $this->seoOverrides[$scope] ?? []; if (empty($scopeOverrides) || ! str_starts_with($normalizedPath, $scope.'.')) { continue; } $relativePath = substr($normalizedPath, strlen($scope) + 1); foreach ($scopeOverrides as $overrideKey => $overrideValue) { // 정확한 매칭: _local.key → overrideKey = "key" if ($relativePath === $overrideKey) { return $overrideValue; } // 와일드카드 매칭: _local.collapsedReplies.xxx → overrideKey = "collapsedReplies" // overrideValue가 배열이고 "*" 키가 있으면 와일드카드 if (is_array($overrideValue) && array_key_exists('*', $overrideValue)) { // prefix.* 직접 매칭 (resolvePathWithBrackets에서 호출) if ($relativePath === $overrideKey.'.*') { return $overrideValue['*']; } // prefix.dynamicKey 매칭 (resolvePath에서 호출) if (str_starts_with($relativePath, $overrideKey.'.')) { return $overrideValue['*']; } } } } return null; } /** * 브래킷 접근자가 포함된 경로를 해석합니다. * * 예: product.data.multi_currency.[preferredCurrency ?? 'KRW'].formatted * * @param string $path 경로 (optional chaining 이미 정규화됨) * @param array $context 데이터 컨텍스트 * @return mixed 해석된 값 또는 null */ private function resolvePathWithBrackets(string $path, array $context): mixed { if (preg_match('/^(.+?)\[(.+?)\](.*)$/', $path, $matches)) { $prefix = $matches[1]; $bracketExpr = trim($matches[2]); $suffix = ltrim($matches[3], '.'); // 브래킷 내부 표현식을 키로 해석 $key = $this->resolveBracketExpression($bracketExpr, $context); if ($key === null) { // SEO 오버라이드: 브래킷 키 해석 불가 시 prefix에 대한 와일드카드 매칭 // 예: _local.collapsedReplies?.[$computed.xxx] → $computed 미존재 → prefix 와일드카드 확인 $normalizedPrefix = rtrim(preg_replace('/\[(\d+)\]/', '.$1', str_replace('?.', '.', $prefix)), '.'); $override = $this->matchSeoOverride($normalizedPrefix.'.*'); if ($override !== null) { return $override; } return null; } // 경로 재구성 후 재귀 해석 (prefix 끝 dot 제거 — path.[key] 패턴의 이중 dot 방지) $fullPath = rtrim($prefix, '.').'.'.$key; if ($suffix !== '') { $fullPath .= '.'.$suffix; } return $this->resolvePath($fullPath, $context); } // fallback: 숫자 인덱스만 변환 후 해석 $normalizedPath = preg_replace('/\[(\d+)\]/', '.$1', $path); return Arr::get($context, $normalizedPath); } /** * 브래킷 내부 표현식을 키 문자열로 해석합니다. * * @param string $expr 브래킷 내부 표현식 * @param array $context 데이터 컨텍스트 * @return string|null 해석된 키 또는 null */ private function resolveBracketExpression(string $expr, array $context): ?string { // 문자열 리터럴: 'KRW' 또는 "KRW" if (preg_match('/^[\'"](.+?)[\'"]$/', $expr, $m)) { return $m[1]; } // 숫자 인덱스 if (is_numeric($expr)) { return $expr; } // null coalescing: path ?? 'fallback' if (str_contains($expr, '??')) { $parts = array_map('trim', explode('??', $expr, 2)); $value = $this->resolvePath($parts[0], $context); if ($value !== null && $value !== '') { return (string) $value; } // fallback (따옴표 제거) return trim($parts[1], " '\""); } // 경로 참조 $value = $this->resolvePath($expr, $context); return $value !== null ? (string) $value : null; } // ==================================================================== // 메서드/함수 호출 평가 시스템 // JavaScript 표현식의 메서드 호출을 PHP로 해석합니다. // ==================================================================== /** * 표현식을 raw 값(배열/숫자/문자열 등)으로 해석합니다. * * evaluateExpression()과 달리 string이 아닌 원본 타입을 반환합니다. * 메서드 호출의 인수 해석이나 체이닝에 사용됩니다. * * @param string $expr 표현식 * @param array $context 데이터 컨텍스트 * @return mixed 해석된 값 */ private function resolveToValue(string $expr, array $context): mixed { $expr = trim($expr); if ($expr === '') { return null; } // 문자열 리터럴 if (preg_match('/^[\'"](.*)[\'"]\s*$/', $expr, $m)) { return $m[1]; } // 숫자 리터럴 if (is_numeric($expr)) { return $expr + 0; } // Boolean/null/빈 배열 리터럴 if ($expr === 'true') { return true; } if ($expr === 'false') { return false; } if ($expr === 'null') { return null; } if ($expr === '[]') { return []; } // 배열 리터럴: ['a','b','c'] 또는 [...arr, item] → PHP 배열 if (str_starts_with($expr, '[') && str_ends_with($expr, ']')) { $parsed = $this->parseArrayLiteral($expr, $context); if ($parsed !== null) { return $parsed; } } // 객체 리터럴: {key: value, ...obj} → PHP 연상 배열 if (str_starts_with($expr, '{') && str_ends_with($expr, '}')) { $parsed = $this->parseObjectLiteral($expr, $context); if ($parsed !== null) { return $parsed; } } // 외부 괄호 제거: (expr) → expr if (str_starts_with($expr, '(') && str_ends_with($expr, ')')) { $inner = substr($expr, 1, -1); $depth = 0; $valid = true; for ($i = 0; $i < strlen($inner); $i++) { if ($inner[$i] === '(') { $depth++; } elseif ($inner[$i] === ')') { $depth--; if ($depth < 0) { $valid = false; break; } } } if ($valid && $depth === 0) { return $this->resolveToValue($inner, $context); } } // 메서드/함수 호출 ($t, $localized 등 $ 접두사 함수 포함) if (preg_match('/[\w$]+\s*\(/', $expr)) { if (str_ends_with($expr, ')')) { $result = $this->evaluateCallExpression($expr, $context); if ($result !== null) { return $result; } } // 메서드 호출 + 후속 프로퍼티: expr().prop $lastParen = strrpos($expr, ')'); if ($lastParen !== false && $lastParen < strlen($expr) - 1) { $methodPart = substr($expr, 0, $lastParen + 1); $propPart = ltrim(substr($expr, $lastParen + 1), '.'); $methodResult = $this->evaluateCallExpression($methodPart, $context); if ($methodResult !== null && $propPart !== '') { $current = $methodResult; foreach (explode('.', str_replace('?.', '.', $propPart)) as $seg) { if ($seg === '') { continue; } $virtual = $this->resolveVirtualProperty($current, $seg); if ($virtual !== null) { $current = $virtual; continue; } if (is_array($current) && array_key_exists($seg, $current)) { $current = $current[$seg]; continue; } return null; } return $current; } } } // 삼항 연산자: condition ? trueExpr : falseExpr $ternaryResult = $this->evaluateTernaryRaw($expr, $context); if ($ternaryResult !== null) { return $ternaryResult[0]; // 배열로 래핑하여 null 결과도 구분 } // null coalescing if (str_contains($expr, '??')) { $parts = $this->splitOutsideBrackets($expr, '??'); if (count($parts) === 2) { $value = $this->resolveToValue(trim($parts[0]), $context); if ($value !== null && $value !== '') { return $value; } return $this->resolveToValue(trim($parts[1]), $context); } } // 경로 해석 (raw 값 반환) return $this->resolvePath($expr, $context); } /** * 배열 리터럴 문자열을 PHP 배열로 파싱합니다. * * JavaScript 배열 리터럴 ['a', 'b', 123, true] 등을 지원합니다. * 스프레드 연산자 [...arr, item]도 지원합니다. * context가 제공되면 스프레드 및 표현식 요소를 평가합니다. * * @param string $expr 배열 리터럴 문자열 (예: "['gallery','card']") * @param array|null $context 데이터 컨텍스트 (스프레드/표현식 평가용) * @return array|null 파싱된 배열 또는 null (파싱 불가 시) */ private function parseArrayLiteral(string $expr, ?array $context = null): ?array { $inner = trim(substr($expr, 1, -1)); if ($inner === '') { return []; } $elements = $this->splitArgs($inner); $result = []; foreach ($elements as $element) { $element = trim($element); // 배열 스프레드: ...arr if (str_starts_with($element, '...')) { if ($context === null) { return null; } $spreadExpr = trim(substr($element, 3)); $spreadValue = $this->resolveToValue($spreadExpr, $context); if (is_array($spreadValue)) { foreach ($spreadValue as $v) { $result[] = $v; } } continue; } // 문자열 리터럴 if (preg_match('/^[\'"](.*)[\'"]\s*$/', $element, $m)) { $result[] = $m[1]; continue; } // 숫자 리터럴 if (is_numeric($element)) { $result[] = $element + 0; continue; } // boolean/null if ($element === 'true') { $result[] = true; continue; } if ($element === 'false') { $result[] = false; continue; } if ($element === 'null') { $result[] = null; continue; } // context 있으면 표현식으로 평가 시도 if ($context !== null) { $resolved = $this->resolveToValue($element, $context); if ($resolved !== null) { $result[] = $resolved; continue; } } // 리터럴이 아닌 요소가 있고 context도 없으면 배열 리터럴로 취급하지 않음 return null; } return $result; } /** * 객체 리터럴 문자열을 PHP 연상 배열로 파싱합니다. * * JavaScript 객체 리터럴 { key: value, key2: 'text' } 등을 지원합니다. * 스프레드 연산자 { ...obj, key: value }도 지원합니다. * 동적 키 { [expr]: value }도 지원합니다. * * @param string $expr 객체 리터럴 문자열 (예: "{ status: 'active', ...defaults }") * @param array $context 데이터 컨텍스트 * @return array|null 파싱된 연상 배열 또는 null (파싱 불가 시) */ private function parseObjectLiteral(string $expr, array $context): ?array { $inner = trim(substr($expr, 1, -1)); if ($inner === '') { return []; } $entries = $this->splitArgs($inner); $result = []; foreach ($entries as $entry) { $entry = trim($entry); // 스프레드: ...obj if (str_starts_with($entry, '...')) { $spreadExpr = trim(substr($entry, 3)); $spreadValue = $this->resolveToValue($spreadExpr, $context); if (is_array($spreadValue)) { foreach ($spreadValue as $k => $v) { $result[$k] = $v; } } continue; } // 동적 키: [expr]: value if (str_starts_with($entry, '[')) { $closeBracket = $this->findMatchingBracket($entry, 0); if ($closeBracket !== false) { $keyExpr = trim(substr($entry, 1, $closeBracket - 1)); $rest = trim(substr($entry, $closeBracket + 1)); // ]: value → value 부분 추출 if (str_starts_with($rest, ':')) { $valueExpr = trim(substr($rest, 1)); $key = $this->resolveToValue($keyExpr, $context); if ($key === null || $key === '') { continue; } $value = $this->resolveObjectPropertyValue($valueExpr, $context); $result[(string) $key] = $value; continue; } } return null; } // 일반 key: value $colonPos = $this->findColonInEntry($entry); if ($colonPos === false) { // shorthand property: { key } → key: key 형태 $value = $this->resolveToValue($entry, $context); $result[$entry] = $value; continue; } $key = trim(substr($entry, 0, $colonPos)); $valueExpr = trim(substr($entry, $colonPos + 1)); // 따옴표로 감싸인 키 if (preg_match('/^[\'"](.+)[\'"]$/', $key, $m)) { $key = $m[1]; } $value = $this->resolveObjectPropertyValue($valueExpr, $context); $result[$key] = $value; } return $result; } /** * 객체 리터럴 프로퍼티 값을 해석합니다. * * @param string $valueExpr 값 표현식 * @param array $context 데이터 컨텍스트 * @return mixed 해석된 값 */ private function resolveObjectPropertyValue(string $valueExpr, array $context): mixed { // 문자열 리터럴 if (preg_match('/^[\'"](.*)[\'"]\s*$/', $valueExpr, $m)) { return $m[1]; } // 숫자 리터럴 if (is_numeric($valueExpr)) { return $valueExpr + 0; } // boolean/null if ($valueExpr === 'true') { return true; } if ($valueExpr === 'false') { return false; } if ($valueExpr === 'null') { return null; } // 배열/객체 리터럴 if (str_starts_with($valueExpr, '[') && str_ends_with($valueExpr, ']')) { $parsed = $this->parseArrayLiteral($valueExpr, $context); if ($parsed !== null) { return $parsed; } } if (str_starts_with($valueExpr, '{') && str_ends_with($valueExpr, '}')) { $parsed = $this->parseObjectLiteral($valueExpr, $context); if ($parsed !== null) { return $parsed; } } // 표현식으로 평가 return $this->resolveToValue($valueExpr, $context); } /** * 객체 리터럴 엔트리에서 key: value 구분용 콜론 위치를 찾습니다. * * 중첩 구조 내부의 콜론(삼항 연산자의 : 등)은 무시합니다. * * @param string $entry 단일 엔트리 문자열 * @return int|false 콜론 위치 또는 false */ private function findColonInEntry(string $entry): int|false { $depth = 0; $inQuote = null; for ($i = 0; $i < strlen($entry); $i++) { $char = $entry[$i]; if ($inQuote !== null) { if ($char === $inQuote && ($i === 0 || $entry[$i - 1] !== '\\')) { $inQuote = null; } continue; } if ($char === "'" || $char === '"') { $inQuote = $char; continue; } if (in_array($char, ['(', '[', '{'])) { $depth++; } if (in_array($char, [')', ']', '}'])) { $depth--; } if ($char === ':' && $depth === 0) { return $i; } } return false; } /** * 문자열에서 매칭되는 닫는 브래킷 위치를 찾습니다. * * @param string $str 문자열 * @param int $openPos 여는 브래킷 위치 * @return int|false 닫는 브래킷 위치 또는 false */ private function findMatchingBracket(string $str, int $openPos): int|false { $open = $str[$openPos]; $close = match ($open) { '[' => ']', '(' => ')', '{' => '}', default => null, }; if ($close === null) { return false; } $depth = 0; $inQuote = null; for ($i = $openPos; $i < strlen($str); $i++) { $char = $str[$i]; if ($inQuote !== null) { if ($char === $inQuote && ($i === 0 || $str[$i - 1] !== '\\')) { $inQuote = null; } continue; } if ($char === "'" || $char === '"') { $inQuote = $char; continue; } if ($char === $open) { $depth++; } if ($char === $close) { $depth--; if ($depth === 0) { return $i; } } } return false; } /** * 메서드/함수 호출 표현식을 평가합니다. * * @param string $expr 표현식 (반드시 )로 끝나야 함) * @param array $context 데이터 컨텍스트 * @return mixed 평가 결과 또는 null (해석 불가) */ private function evaluateCallExpression(string $expr, array $context): mixed { $call = $this->parseCallExpression($expr); if ($call === null) { return null; } return match ($call['type']) { 'static' => $this->evaluateStaticCall($call['class'], $call['method'], $call['args'], $context), 'global' => $this->evaluateGlobalFunction($call['function'], $call['args'], $context), 'instance' => $this->evaluateInstanceCall($call['object'], $call['method'], $call['args'], $context), default => null, }; } /** * 메서드 호출 구문을 파싱합니다. * * 지원 형태: * - 정적 메서드: Object.values(expr), Math.min(a, b) * - 전역 함수: Number(expr), String(expr) * - 인스턴스 메서드: expr.method(args) * * @param string $expr 표현식 * @return array|null 파싱 결과 또는 null */ private function parseCallExpression(string $expr): ?array { $len = strlen($expr); if ($len === 0 || $expr[$len - 1] !== ')') { return null; } // 매칭되는 ( 찾기 $depth = 0; $openPos = -1; for ($i = $len - 1; $i >= 0; $i--) { if ($expr[$i] === ')') { $depth++; } elseif ($expr[$i] === '(') { $depth--; if ($depth === 0) { $openPos = $i; break; } } } if ($openPos < 0) { return null; } $beforeParen = substr($expr, 0, $openPos); $argsStr = substr($expr, $openPos + 1, $len - $openPos - 2); // 정적 메서드: Object.values, Math.min, Array.isArray, JSON.stringify 등 if (preg_match('/^(Object|Math|Array|JSON|Date|Number)\.([\w]+)$/', $beforeParen, $m)) { return ['type' => 'static', 'class' => $m[1], 'method' => $m[2], 'args' => $argsStr]; } // 전역 함수: Number(), String(), parseInt(), $t(), $localized() 등 if (preg_match('/^(Number|String|Boolean|parseInt|parseFloat|isNaN|isFinite|encodeURIComponent|decodeURIComponent|\$t|\$localized)$/', $beforeParen)) { return ['type' => 'global', 'function' => $beforeParen, 'args' => $argsStr]; } // 인스턴스 메서드: expr.method() if (preg_match('/^(.+)\.([\w]+)$/', $beforeParen, $m)) { return ['type' => 'instance', 'object' => $m[1], 'method' => $m[2], 'args' => $argsStr]; } return null; } /** * 메서드 인수 문자열을 쉼표로 분할합니다. * * 따옴표, 괄호, 브래킷 내부의 쉼표는 무시합니다. * * @param string $argsStr 인수 문자열 * @return array 분할된 인수 문자열 배열 */ private function splitArgs(string $argsStr): array { $argsStr = trim($argsStr); if ($argsStr === '') { return []; } $args = []; $current = ''; $depth = 0; $inQuote = null; for ($i = 0; $i < strlen($argsStr); $i++) { $char = $argsStr[$i]; if ($inQuote !== null) { $current .= $char; if ($char === $inQuote && ($i === 0 || $argsStr[$i - 1] !== '\\')) { $inQuote = null; } continue; } if ($char === "'" || $char === '"') { $inQuote = $char; $current .= $char; continue; } if (in_array($char, ['(', '[', '{'])) { $depth++; } if (in_array($char, [')', ']', '}'])) { $depth--; } if ($char === ',' && $depth === 0) { $args[] = trim($current); $current = ''; continue; } $current .= $char; } if (trim($current) !== '') { $args[] = trim($current); } return $args; } /** * 정적 메서드를 평가합니다. * * @param string $class 클래스명 (Object, Math, Array, JSON, Date, Number) * @param string $method 메서드명 * @param string $argsStr 인수 문자열 * @param array $context 데이터 컨텍스트 * @return mixed 평가 결과 또는 null */ private function evaluateStaticCall(string $class, string $method, string $argsStr, array $context): mixed { $args = $this->splitArgs($argsStr); $resolvedArgs = array_map(fn ($a) => $this->resolveToValue($a, $context), $args); return match ($class) { 'Object' => match ($method) { 'keys' => is_array($resolvedArgs[0] ?? null) ? array_keys($resolvedArgs[0]) : [], 'values' => is_array($resolvedArgs[0] ?? null) ? array_values($resolvedArgs[0]) : [], 'entries' => is_array($resolvedArgs[0] ?? null) ? array_map(fn ($k, $v) => [$k, $v], array_keys($resolvedArgs[0]), array_values($resolvedArgs[0])) : [], 'assign' => $this->objectAssign($resolvedArgs), default => null, }, 'Math' => match ($method) { 'min' => min(...array_filter($resolvedArgs, 'is_numeric')), 'max' => max(...array_filter($resolvedArgs, 'is_numeric')), 'floor' => is_numeric($resolvedArgs[0] ?? null) ? (int) floor((float) $resolvedArgs[0]) : null, 'ceil' => is_numeric($resolvedArgs[0] ?? null) ? (int) ceil((float) $resolvedArgs[0]) : null, 'round' => is_numeric($resolvedArgs[0] ?? null) ? round((float) $resolvedArgs[0], (int) ($resolvedArgs[1] ?? 0)) : null, 'abs' => is_numeric($resolvedArgs[0] ?? null) ? abs($resolvedArgs[0]) : null, 'random' => mt_rand() / mt_getrandmax(), default => null, }, 'Array' => match ($method) { 'isArray' => is_array($resolvedArgs[0] ?? null), 'from' => is_array($resolvedArgs[0] ?? null) ? array_values($resolvedArgs[0]) : [], default => null, }, 'JSON' => match ($method) { 'stringify' => json_encode($resolvedArgs[0] ?? null, JSON_UNESCAPED_UNICODE), 'parse' => is_string($resolvedArgs[0] ?? null) ? json_decode($resolvedArgs[0], true) : null, default => null, }, 'Number' => match ($method) { 'isNaN' => ! is_numeric($resolvedArgs[0] ?? null), 'isFinite' => is_numeric($resolvedArgs[0] ?? null) && is_finite((float) $resolvedArgs[0]), 'parseInt' => is_numeric($resolvedArgs[0] ?? null) ? (int) $resolvedArgs[0] : null, 'parseFloat' => is_numeric($resolvedArgs[0] ?? null) ? (float) $resolvedArgs[0] : null, default => null, }, default => null, }; } /** * Object.assign() 구현 * * @param array $resolvedArgs 해석된 인수 배열 * @return array 병합된 객체 */ private function objectAssign(array $resolvedArgs): array { $result = []; foreach ($resolvedArgs as $arg) { if (is_array($arg)) { $result = array_merge($result, $arg); } } return $result; } /** * 전역 함수를 평가합니다. * * @param string $function 함수명 * @param string $argsStr 인수 문자열 * @param array $context 데이터 컨텍스트 * @return mixed 평가 결과 */ private function evaluateGlobalFunction(string $function, string $argsStr, array $context): mixed { $args = $this->splitArgs($argsStr); $value = ! empty($args) ? $this->resolveToValue($args[0], $context) : null; return match ($function) { 'Number' => is_numeric($value) ? $value + 0 : (is_bool($value) ? ($value ? 1 : 0) : 0), 'String' => $value !== null ? (string) $value : '', 'Boolean' => ! empty($value) && $value !== 'false' && $value !== '0', 'parseInt' => is_numeric($value) ? (int) $value : null, 'parseFloat' => is_numeric($value) ? (float) $value : null, 'isNaN' => ! is_numeric($value), 'isFinite' => is_numeric($value) && is_finite((float) $value), 'encodeURIComponent' => is_string($value) ? rawurlencode($value) : '', 'decodeURIComponent' => is_string($value) ? rawurldecode($value) : '', '$t' => $this->evaluateTranslationFunction($argsStr, $context), '$localized' => $this->evaluateLocalizedFunction($value, $context), default => null, }; } /** * $t() 함수 호출을 평가합니다. * * 프론트엔드의 `$t('key')` 구문과 호환됩니다. * 내부적으로 기존 resolveTranslation()을 재활용합니다. * * @param string $argsStr 인수 문자열 (예: "'shop.product.sold_out'") * @param array $context 데이터 컨텍스트 * @return string 번역된 문자열 */ private function evaluateTranslationFunction(string $argsStr, array $context): string { $args = $this->splitArgs($argsStr); if (empty($args)) { return ''; } // 첫 번째 인수: 번역 키 (문자열 리터럴 또는 표현식) $keyArg = trim($args[0]); if (preg_match('/^[\'"](.+)[\'"]\s*$/', $keyArg, $m)) { $key = $m[1]; } else { // 표현식으로 평가하여 키 획득 $key = $this->evaluateExpression($keyArg, $context); } if ($key === '') { return ''; } return $this->resolveTranslation('$t:'.$key, $context); } /** * $localized() 함수 호출을 평가합니다. * * 다국어 객체 {ko: "한국어", en: "English"} 에서 현재 로케일에 해당하는 값을 반환합니다. * fallback 순서: 현재 로케일 → 'ko' → 첫 번째 값 * * @param mixed $value 다국어 객체 (배열) * @param array $context 데이터 컨텍스트 * @return mixed 현재 로케일의 값 */ private function evaluateLocalizedFunction(mixed $value, array $context): mixed { if (! is_array($value)) { return $value; } $locale = app()->getLocale(); return $value[$locale] ?? $value[config('app.fallback_locale', 'ko')] ?? ($value ? reset($value) : ''); } /** * 인스턴스 메서드를 평가합니다. * * @param string $objectExpr 객체 표현식 * @param string $method 메서드명 * @param string $argsStr 인수 문자열 * @param array $context 데이터 컨텍스트 * @return mixed 평가 결과 또는 null */ private function evaluateInstanceCall(string $objectExpr, string $method, string $argsStr, array $context): mixed { $value = $this->resolveToValue($objectExpr, $context); if (is_array($value)) { return $this->callArrayMethod($value, $method, $argsStr, $context); } if (is_string($value)) { return $this->callStringMethod($value, $method, $argsStr, $context); } if (is_numeric($value)) { return $this->callNumberMethod($value, $method, $argsStr, $context); } return null; } /** * 배열 메서드를 호출합니다. * * @param array $value 배열 * @param string $method 메서드명 * @param string $argsStr 인수 문자열 * @param array $context 데이터 컨텍스트 * @return mixed 결과 */ private function callArrayMethod(array $value, string $method, string $argsStr, array $context): mixed { $args = $this->splitArgs($argsStr); return match ($method) { 'join' => implode( ! empty($args) ? $this->resolveToValue($args[0], $context) ?? ',' : ',', array_map(fn ($v) => is_array($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string) ($v ?? ''), $value) ), 'slice' => $this->arraySlice( $value, (int) ($this->resolveToValue($args[0] ?? '0', $context) ?? 0), isset($args[1]) ? (int) $this->resolveToValue($args[1], $context) : null ), 'includes' => in_array($this->resolveToValue($args[0] ?? 'null', $context), $value), 'indexOf' => ($idx = array_search($this->resolveToValue($args[0] ?? 'null', $context), $value)) !== false ? $idx : -1, 'flat' => $this->arrayFlat($value, isset($args[0]) ? (int) $this->resolveToValue($args[0], $context) : 1), 'reverse' => array_reverse($value), 'pop' => ! empty($value) ? end($value) : null, 'shift' => ! empty($value) ? reset($value) : null, 'concat' => array_merge($value, ...array_map( fn ($a) => (array) ($this->resolveToValue($a, $context) ?? []), $args )), 'at' => $this->arrayAt($value, (int) ($this->resolveToValue($args[0] ?? '0', $context) ?? 0)), 'fill' => array_fill(0, count($value), $this->resolveToValue($args[0] ?? 'null', $context)), 'toString' => implode(',', $value), // 콜백 메서드 'map' => $this->callArrayCallbackMethod($value, 'map', $argsStr, $context), 'filter' => $this->callArrayCallbackMethod($value, 'filter', $argsStr, $context), 'find' => $this->callArrayCallbackMethod($value, 'find', $argsStr, $context), 'findIndex' => $this->callArrayCallbackMethod($value, 'findIndex', $argsStr, $context), 'some' => $this->callArrayCallbackMethod($value, 'some', $argsStr, $context), 'every' => $this->callArrayCallbackMethod($value, 'every', $argsStr, $context), 'flatMap' => $this->callArrayCallbackMethod($value, 'flatMap', $argsStr, $context), 'reduce' => $this->callArrayCallbackMethod($value, 'reduce', $argsStr, $context), 'sort' => $this->callArrayCallbackMethod($value, 'sort', $argsStr, $context), default => null, }; } /** * 문자열 메서드를 호출합니다. * * @param string $value 문자열 * @param string $method 메서드명 * @param string $argsStr 인수 문자열 * @param array $context 데이터 컨텍스트 * @return mixed 결과 */ private function callStringMethod(string $value, string $method, string $argsStr, array $context): mixed { $args = $this->splitArgs($argsStr); $resolvedArgs = array_map(fn ($a) => $this->resolveToValue($a, $context), $args); return match ($method) { 'split' => explode((string) ($resolvedArgs[0] ?? ''), $value), 'join' => $value, // 문자열에 join은 자기 자신 반환 'trim' => trim($value), 'trimStart', 'trimLeft' => ltrim($value), 'trimEnd', 'trimRight' => rtrim($value), 'toLowerCase', 'toLocaleLowerCase' => mb_strtolower($value), 'toUpperCase', 'toLocaleUpperCase' => mb_strtoupper($value), 'substring' => mb_substr( $value, (int) ($resolvedArgs[0] ?? 0), isset($resolvedArgs[1]) ? (int) $resolvedArgs[1] - (int) ($resolvedArgs[0] ?? 0) : null ), 'substr' => mb_substr($value, (int) ($resolvedArgs[0] ?? 0), isset($resolvedArgs[1]) ? (int) $resolvedArgs[1] : null), 'slice' => $this->stringSlice($value, (int) ($resolvedArgs[0] ?? 0), $resolvedArgs[1] ?? null), 'includes' => str_contains($value, (string) ($resolvedArgs[0] ?? '')), 'indexOf' => ($pos = mb_strpos($value, (string) ($resolvedArgs[0] ?? ''))) !== false ? $pos : -1, 'lastIndexOf' => ($pos = mb_strrpos($value, (string) ($resolvedArgs[0] ?? ''))) !== false ? $pos : -1, 'startsWith' => str_starts_with($value, (string) ($resolvedArgs[0] ?? '')), 'endsWith' => str_ends_with($value, (string) ($resolvedArgs[0] ?? '')), 'replace' => $this->stringReplace($value, $resolvedArgs), 'replaceAll' => str_replace((string) ($resolvedArgs[0] ?? ''), (string) ($resolvedArgs[1] ?? ''), $value), 'repeat' => str_repeat($value, max(0, (int) ($resolvedArgs[0] ?? 0))), 'padStart' => str_pad($value, (int) ($resolvedArgs[0] ?? 0), (string) ($resolvedArgs[1] ?? ' '), STR_PAD_LEFT), 'padEnd' => str_pad($value, (int) ($resolvedArgs[0] ?? 0), (string) ($resolvedArgs[1] ?? ' '), STR_PAD_RIGHT), 'charAt' => mb_substr($value, (int) ($resolvedArgs[0] ?? 0), 1), 'charCodeAt' => mb_ord(mb_substr($value, (int) ($resolvedArgs[0] ?? 0), 1)), 'at' => $this->stringAt($value, (int) ($resolvedArgs[0] ?? 0)), 'concat' => $value.implode('', array_map(fn ($a) => (string) ($a ?? ''), $resolvedArgs)), 'toString' => $value, 'match' => null, // 정규식 — SEO에서 불필요 'search' => null, 'normalize' => $value, // PHP normalizer 불필요 'localeCompare' => null, 'toLocaleString' => $value, default => null, }; } /** * 숫자 메서드를 호출합니다. * * @param int|float $value 숫자 * @param string $method 메서드명 * @param string $argsStr 인수 문자열 * @param array $context 데이터 컨텍스트 * @return mixed 결과 */ private function callNumberMethod(int|float $value, string $method, string $argsStr, array $context): mixed { $args = $this->splitArgs($argsStr); $resolvedArgs = array_map(fn ($a) => $this->resolveToValue($a, $context), $args); return match ($method) { 'toLocaleString' => number_format($value, is_float($value) && $value != (int) $value ? 2 : 0), 'toFixed' => number_format((float) $value, (int) ($resolvedArgs[0] ?? 0), '.', ''), 'toString' => isset($resolvedArgs[0]) && is_numeric($resolvedArgs[0]) ? base_convert((string) (int) $value, 10, (int) $resolvedArgs[0]) : (string) $value, 'valueOf' => $value, default => null, }; } /** * JavaScript String.prototype.slice 구현 * * @param string $value 문자열 * @param int $start 시작 인덱스 (음수 가능) * @param mixed $end 끝 인덱스 (null = 끝까지) * @return string 추출된 문자열 */ private function stringSlice(string $value, int $start, mixed $end): string { $len = mb_strlen($value); if ($start < 0) { $start = max($len + $start, 0); } if ($end === null) { return mb_substr($value, $start); } $end = (int) $end; if ($end < 0) { $end = $len + $end; } $length = $end - $start; return $length > 0 ? mb_substr($value, $start, $length) : ''; } /** * JavaScript String.prototype.replace 구현 (최초 1회만) * * @param string $value 문자열 * @param array $args 인수 [search, replacement] * @return string 치환된 문자열 */ private function stringReplace(string $value, array $args): string { $search = (string) ($args[0] ?? ''); $replace = (string) ($args[1] ?? ''); $pos = strpos($value, $search); if ($pos !== false) { return substr_replace($value, $replace, $pos, strlen($search)); } return $value; } /** * JavaScript String.prototype.at 구현 (음수 인덱스 지원) * * @param string $value 문자열 * @param int $index 인덱스 * @return string 문자 */ private function stringAt(string $value, int $index): string { $len = mb_strlen($value); if ($index < 0) { $index = $len + $index; } if ($index < 0 || $index >= $len) { return ''; } return mb_substr($value, $index, 1); } /** * JavaScript Array.prototype.at 구현 (음수 인덱스 지원) * * @param array $value 배열 * @param int $index 인덱스 * @return mixed 요소 */ private function arrayAt(array $value, int $index): mixed { $values = array_values($value); $len = count($values); if ($index < 0) { $index = $len + $index; } return ($index >= 0 && $index < $len) ? $values[$index] : null; } /** * JavaScript Array.prototype.slice 구현 (끝 인덱스 기반) * * @param array $value 배열 * @param int $start 시작 인덱스 (음수 가능) * @param int|null $end 끝 인덱스 (null = 끝까지, 음수 가능) * @return array 추출된 배열 */ private function arraySlice(array $value, int $start, ?int $end): array { $values = array_values($value); $len = count($values); if ($start < 0) { $start = max($len + $start, 0); } if ($end === null) { return array_slice($values, $start); } if ($end < 0) { $end = $len + $end; } $length = $end - $start; return $length > 0 ? array_slice($values, $start, $length) : []; } /** * JavaScript Array.prototype.flat 구현 * * @param array $value 배열 * @param int $depth 깊이 * @return array 평탄화된 배열 */ private function arrayFlat(array $value, int $depth = 1): array { $result = []; foreach ($value as $item) { if (is_array($item) && $depth > 0) { $result = array_merge($result, $this->arrayFlat($item, $depth - 1)); } else { $result[] = $item; } } return $result; } /** * 콜백이 필요한 배열 메서드를 호출합니다. * * 단순 화살표 함수만 지원: item => item.prop, (item, idx) => condition * * @param array $value 배열 * @param string $method 메서드명 * @param string $argsStr 인수 문자열 (콜백 포함) * @param array $context 데이터 컨텍스트 * @return mixed 결과 */ private function callArrayCallbackMethod(array $value, string $method, string $argsStr, array $context): mixed { $allArgs = $this->splitArgs($argsStr); $callbackStr = $allArgs[0] ?? ''; $callback = $this->parseSimpleCallback($callbackStr); // reduce의 초기값 $initialValue = isset($allArgs[1]) ? $this->resolveToValue($allArgs[1], $context) : null; // 콜백 파싱 실패 시 if ($callback === null) { // sort()는 콜백 없이도 동작 if ($method === 'sort') { sort($value); return $value; } return $method === 'filter' ? $value : null; } $values = array_values($value); return match ($method) { 'map' => array_values(array_map( fn ($item, $idx) => $this->evaluateCallbackBody($callback, $item, $idx, $context), $values, array_keys($values) )), 'filter' => array_values(array_filter( $values, fn ($item, $idx) => $this->isTruthy($this->evaluateCallbackBody($callback, $item, $idx, $context)), ARRAY_FILTER_USE_BOTH )), 'find' => $this->arrayFind($values, $callback, $context), 'findIndex' => $this->arrayFindIndex($values, $callback, $context), 'some' => $this->arraySome($values, $callback, $context), 'every' => $this->arrayEvery($values, $callback, $context), 'flatMap' => $this->arrayFlat(array_values(array_map( fn ($item, $idx) => $this->evaluateCallbackBody($callback, $item, $idx, $context), $values, array_keys($values) ))), 'reduce' => $this->arrayReduce($values, $callback, $initialValue, $context), 'sort' => $this->arraySort($values, $callback, $context), default => null, }; } /** * 단순 화살표 함수를 파싱합니다. * * 지원: item => body, (item) => body, (item, idx) => body, ([a, b]) => body * * @param string $callbackStr 콜백 문자열 * @return array|null ['params' => [...], 'body' => '...'] 또는 null */ private function parseSimpleCallback(string $callbackStr): ?array { $callbackStr = trim($callbackStr); // (params) => body 또는 param => body if (! preg_match('/^(?:\(([^)]*)\)|(\w+))\s*=>\s*(.+)$/s', $callbackStr, $m)) { return null; } $paramsStr = $m[1] !== '' ? $m[1] : $m[2]; $body = trim($m[3]); $params = array_map('trim', explode(',', $paramsStr)); // 구조 분해 제거: [key, value] → key, value $params = array_map(fn ($p) => trim($p, '[] '), $params); return ['params' => $params, 'body' => $body]; } /** * 콜백 본문을 평가합니다. * * @param array $callback 파싱된 콜백 * @param mixed $item 현재 아이템 * @param int $index 현재 인덱스 * @param array $context 데이터 컨텍스트 * @return mixed 평가 결과 */ private function evaluateCallbackBody(array $callback, mixed $item, int $index, array $context): mixed { $localContext = $context; // 파라미터 바인딩 $params = $callback['params']; if (isset($params[0]) && $params[0] !== '_') { $localContext[$params[0]] = $item; } if (isset($params[1]) && $params[1] !== '_') { $localContext[$params[1]] = $index; } return $this->resolveToValue($callback['body'], $localContext); } /** * 값의 truthiness를 판단합니다. * * @param mixed $value 값 * @return bool truthy 여부 */ private function isTruthy(mixed $value): bool { if ($value === null || $value === '' || $value === false || $value === 0 || $value === '0' || $value === 'false') { return false; } return true; } /** * Array.prototype.find 구현 */ private function arrayFind(array $values, array $callback, array $context): mixed { foreach ($values as $idx => $item) { if ($this->isTruthy($this->evaluateCallbackBody($callback, $item, $idx, $context))) { return $item; } } return null; } /** * Array.prototype.findIndex 구현 */ private function arrayFindIndex(array $values, array $callback, array $context): int { foreach ($values as $idx => $item) { if ($this->isTruthy($this->evaluateCallbackBody($callback, $item, $idx, $context))) { return $idx; } } return -1; } /** * Array.prototype.some 구현 */ private function arraySome(array $values, array $callback, array $context): bool { foreach ($values as $idx => $item) { if ($this->isTruthy($this->evaluateCallbackBody($callback, $item, $idx, $context))) { return true; } } return false; } /** * Array.prototype.every 구현 */ private function arrayEvery(array $values, array $callback, array $context): bool { foreach ($values as $idx => $item) { if (! $this->isTruthy($this->evaluateCallbackBody($callback, $item, $idx, $context))) { return false; } } return true; } /** * Array.prototype.reduce 구현 */ private function arrayReduce(array $values, array $callback, mixed $initialValue, array $context): mixed { $accumulator = $initialValue; foreach ($values as $idx => $item) { $localContext = $context; $params = $callback['params']; if (isset($params[0])) { $localContext[$params[0]] = $accumulator; } if (isset($params[1])) { $localContext[$params[1]] = $item; } if (isset($params[2])) { $localContext[$params[2]] = $idx; } $accumulator = $this->resolveToValue($callback['body'], $localContext); } return $accumulator; } /** * Array.prototype.sort (콜백 비교) 구현 */ private function arraySort(array $values, array $callback, array $context): array { usort($values, function ($a, $b) use ($callback, $context) { $localContext = $context; $params = $callback['params']; if (isset($params[0])) { $localContext[$params[0]] = $a; } if (isset($params[1])) { $localContext[$params[1]] = $b; } $result = $this->resolveToValue($callback['body'], $localContext); return is_numeric($result) ? (int) $result : 0; }); return $values; } /** * 값을 문자열로 변환합니다. * * @param mixed $value 변환할 값 * @return string 변환된 문자열 */ private function valueToString(mixed $value): string { if (is_string($value)) { return $value; } if (is_numeric($value)) { return (string) $value; } if (is_bool($value)) { return $value ? 'true' : 'false'; } if (is_array($value)) { // 다국어 객체 자동 해석: {"ko": "값", "en": "value"} → 현재 로케일 값 $locale = app()->getLocale(); if (isset($value[$locale])) { return (string) $value[$locale]; } // fallback 로케일 $fallbackLocale = config('app.fallback_locale', 'en'); if (isset($value[$fallbackLocale])) { return (string) $value[$fallbackLocale]; } return json_encode($value, JSON_UNESCAPED_UNICODE) ?: ''; } return ''; } }