[0, 767], 'tablet' => [768, 1023], 'desktop' => [1024, PHP_INT_MAX], 'portable' => [0, 1023], ]; /** * 템플릿 제공 컴포넌트 매핑 (seo-config.json의 component_map) */ private array $componentMap = []; /** * 템플릿 제공 렌더 모드 정의 (seo-config.json의 render_modes) */ private array $renderModes = []; /** * 셀프 클로징 태그 목록 (seo-config.json의 self_closing) */ private array $selfClosing = []; /** * 텍스트 추출 대상 props 키 목록 (seo-config.json의 text_props로 오버라이드 가능) */ private array $textProps = ['text', 'label', 'value', 'title']; /** * React→HTML 속성명 매핑 (seo-config.json의 attr_map으로 오버라이드 가능) */ private array $attrMap = [ 'className' => 'class', 'htmlFor' => 'for', ]; /** * 허용된 HTML 속성 목록 (seo-config.json의 allowed_attrs로 오버라이드 가능) */ private array $allowedAttrs = [ 'class', 'id', 'href', 'src', 'alt', 'title', 'name', 'type', 'placeholder', 'for', 'target', 'rel', 'width', 'height', 'role', 'aria-label', 'aria-describedby', 'data-testid', 'style', ]; /** * SEO 변수 (meta.seo.vars에서 해석된 값, format 모드에서 사용) */ private array $seoVars = []; /** * _global 표현식 해석 콜백 (SeoRenderer에서 주입) */ private ?\Closure $globalResolver = null; /** * fields 렌더 모드 실행 중 임시 참조 (renderFieldEntry에서 $t: 번역 해석용) */ private ?ExpressionEvaluator $fieldsEvaluator = null; /** * fields 렌더 모드 실행 중 임시 데이터 컨텍스트 */ private array $fieldsContext = []; /** * 사용자 작성 HTML 정화기 (raw 렌더 모드에서 지연 생성) */ private ?HtmlSanitizer $htmlSanitizer = null; /** * `value` prop 을 텍스트로 승격하지 않는 태그 목록 * * 폼 컨트롤의 `value` 는 현재 선택값(속성)이지 화면에 보이는 글자가 아닙니다. * React 는 `created_at_desc`) 두 화면이 어긋나고 * 색인에도 내부 식별자가 섞입니다. */ private const VALUE_NOT_TEXT_TAGS = ['select', 'option', 'input', 'textarea', 'progress', 'meter']; /** * 템플릿 제공 컴포넌트 매핑을 설정합니다. * * @param array $componentMap seo-config.json의 component_map */ public function setComponentMap(array $componentMap): void { $this->componentMap = $componentMap; } /** * 템플릿 제공 렌더 모드를 설정합니다. * * @param array $renderModes seo-config.json의 render_modes */ public function setRenderModes(array $renderModes): void { $this->renderModes = $renderModes; } /** * 셀프 클로징 태그 목록을 설정합니다. * * @param array $selfClosing seo-config.json의 self_closing */ public function setSelfClosing(array $selfClosing): void { $this->selfClosing = $selfClosing; } /** * 텍스트 추출 대상 props 키 목록을 설정합니다. * * @param array $textProps seo-config.json의 text_props */ public function setTextProps(array $textProps): void { $this->textProps = $textProps; } /** * React→HTML 속성명 매핑을 설정합니다. * * @param array $attrMap seo-config.json의 attr_map */ public function setAttrMap(array $attrMap): void { $this->attrMap = $attrMap; } /** * 허용된 HTML 속성 목록을 설정합니다. * * @param array $allowedAttrs seo-config.json의 allowed_attrs */ public function setAllowedAttrs(array $allowedAttrs): void { $this->allowedAttrs = $allowedAttrs; } /** * SEO 변수를 설정합니다 (meta.seo.vars에서 해석된 값). * * format 렌더 모드에서 {key} 플레이스홀더 해석 시 * props → seoVars → defaults 순서로 참조됩니다. * * @param array $seoVars 해석된 SEO 변수 (키 → 값) */ public function setSeoVars(array $seoVars): void { $this->seoVars = $seoVars; } /** * _global 표현식 해석 콜백을 설정합니다. * * @param \Closure $resolver _global 경로를 해석하는 콜백 (string → ?string) */ public function setGlobalResolver(\Closure $resolver): void { $this->globalResolver = $resolver; } /** * 컴포넌트 트리를 HTML 문자열로 변환합니다. * * @param array $components 컴포넌트 배열 * @param array $context 데이터 컨텍스트 * @param ExpressionEvaluator $evaluator 표현식 평가기 * @return string HTML 문자열 */ public function render(array $components, array $context, ExpressionEvaluator $evaluator): string { $html = ''; foreach ($components as $component) { // children 배열 내 문자열 = 텍스트 노드 // React DynamicRenderer 는 이 문자열을 그대로 반환한다(표현식·번역 미해석). // SEO 가 여기서 표현식을 해석하면 반대 방향의 패리티 격차가 생기므로, // 이스케이프만 하고 리터럴로 출력한다. if (is_string($component)) { $html .= e($component); continue; } if (! is_array($component)) { continue; } $html .= $this->renderComponent($component, $context, $evaluator); } return $html; } /** * 단일 컴포넌트를 HTML로 변환합니다. * * @param array $component 컴포넌트 정의 * @param array $context 데이터 컨텍스트 * @param ExpressionEvaluator $evaluator 표현식 평가기 * @return string HTML 문자열 */ private function renderComponent(array $component, array $context, ExpressionEvaluator $evaluator): string { // Extension Point 주입 컴포넌트의 props를 평가 컨텍스트에 주입 // LayoutExtensionService가 호스트 props를 주입 컴포넌트 최상위 extensionPointProps로 // 부착하므로, 여기서 해석해 컨텍스트에 넣어야 {{extensionPointProps.xxx}}가 평가된다. // $context는 값 전달이므로 이 노드의 서브트리에만 상속되고 형제로 새지 않는다 // (React DynamicRenderer의 extendedDataContext 전파와 동형). // if/iteration 평가에도 이미 반영되어야 하므로 가장 먼저 처리한다. $this->injectExtensionPointContext($component, $context, $evaluator); // type: "iterator" → iteration 속성으로 변환 (React DynamicRenderer와 동일 매핑) $component = $this->normalizeIteratorNode($component); // SEO는 데스크톱 뷰로 렌더링 (검색 봇 = 데스크톱) // 데스크톱 폭에 매칭되는 responsive 오버라이드를 기본 속성에 병합 $component = $this->applyResponsiveOverrides($component); // 조건부 렌더링 (if / condition / conditions) // // iteration 이 있으면 여기서 끊지 않는다. 조건이 항목 변수(`{{user.is_active}}` 등)를 // 참조하면 이 시점 컨텍스트에는 그 변수가 없어 항상 거짓이 되고, 목록 전체가 // 사라진다. 항목별 컨텍스트에서 renderIteration 이 같은 조건을 다시 평가한다 // (iteration 만 제거한 정의를 넘기므로 `if` 는 그대로 남는다). // React DynamicRenderer 와 동일한 순서 — 한쪽만 고치면 봇 화면에서만 목록이 빈다. if (! isset($component['iteration']) && ! $this->shouldRender($component, $context, $evaluator)) { return ''; } // 반복 렌더링 (iteration) if (isset($component['iteration'])) { return $this->renderIteration($component, $context, $evaluator); } // classMap: 조건부 CSS 클래스 해석 → className에 병합 if (isset($component['classMap'])) { $component = $this->resolveClassMap($component, $context, $evaluator); } $name = $component['name'] ?? ''; // component_map에서 매핑 조회 $configEntry = $this->componentMap[$name] ?? null; $tag = 'div'; // fallback $html = ''; if ($configEntry) { // skip: true → SEO에서 렌더링 제외 (인터랙티브 전용 컴포넌트) if (! empty($configEntry['skip'])) { return ''; } $tag = $configEntry['tag'] ?? 'div'; // Fragment: 래퍼 태그 없이 children만 렌더링 if ($tag === '') { return $this->renderChildren($component, $context, $evaluator); } // Icon name → Font Awesome class 변환 if (! empty($configEntry['name_to_class'])) { $component = $this->transformIconProps($configEntry, $component, $context, $evaluator); } // 특수 렌더링 모드 $renderMode = $configEntry['render'] ?? null; if ($renderMode) { $html = $this->renderByMode($renderMode, $tag, $configEntry, $component, $context, $evaluator); } else { // 일반 태그 렌더링 $html = $this->renderTag($tag, $component, $context, $evaluator); } } else { // config에 없는 컴포넌트 → div fallback $html = $this->renderTag('div', $component, $context, $evaluator); } // navigate/openWindow 링크 자동 생성 $linkAction = $this->extractLinkAction($component); if ($linkAction !== null) { $href = $this->resolveNavigateHref($linkAction['params'], $context, $evaluator); if ($href !== null) { $html = $this->applyNavigateLink($html, $tag, $href, $linkAction['handler'], $component); } } return $html; } /** * Extension Point 주입 컴포넌트의 props를 평가 컨텍스트에 주입합니다. * * LayoutExtensionService는 extension_point 호스트의 props를 주입 컴포넌트 최상위 * `extensionPointProps` 키로 부착합니다. 주입 컴포넌트는 `{{extensionPointProps.content}}` * 형태로 그 값을 참조하므로, 렌더링 전에 표현식을 해석해 컨텍스트에 넣어야 합니다. * * 해석된 값은 이 노드와 그 자손 전체에 상속됩니다 (React DynamicRenderer가 * extendedDataContext를 자식에게 전달하는 동작과 동일). * * `extensionPointCallbacks`는 의도적으로 주입하지 않습니다 — SEO 파이프라인에는 * 액션 디스패처가 없고, 액션 배열을 컨텍스트에 넣으면 HTML에 JSON 파편이 * 노출될 수 있습니다. * * @param array $component 컴포넌트 정의 (해석 후 extensionPointProps 키 제거) * @param array $context 데이터 컨텍스트 (해석된 값이 주입됨) * @param ExpressionEvaluator $evaluator 표현식 평가기 */ private function injectExtensionPointContext(array &$component, array &$context, ExpressionEvaluator $evaluator): void { if (! isset($component['extensionPointProps']) || ! is_array($component['extensionPointProps'])) { return; } $context['extensionPointProps'] = $this->resolveAllProps( $component['extensionPointProps'], $context, $evaluator ); // iteration 재진입 시 항목별로 재해석되지 않도록 제거 // (React는 바깥 컨텍스트 기준 1회 해석 후 상속) unset($component['extensionPointProps']); } /** * `type: "iterator"` 노드를 `iteration` 속성 형태로 정규화합니다. * * React DynamicRenderer가 `{ type: "iterator", data, itemName, indexName }` 를 * `iteration: { source, item_var, index_var }` 로 변환하는 것과 동일합니다. * * @param array $component 컴포넌트 정의 * @return array 정규화된 컴포넌트 정의 */ private function normalizeIteratorNode(array $component): array { if (($component['type'] ?? null) !== 'iterator' || ! isset($component['data'])) { return $component; } $component['iteration'] = [ 'source' => $component['data'], 'item_var' => $component['itemName'] ?? 'item', 'index_var' => $component['indexName'] ?? null, ]; unset($component['data'], $component['itemName'], $component['indexName']); return $component; } /** * 데스크톱 폭에 해당하는 responsive 오버라이드를 병합합니다. * * SEO는 검색 봇을 데스크톱 뷰포트로 간주하므로 `desktop` 프리셋과 데스크톱 폭 * (1024px 이상)을 포함하는 숫자 범위 키만 적용합니다. mobile/tablet/portable 은 * 데스크톱에서 매칭되지 않으므로 무시합니다. * * 오버라이드 항목은 React와 동일하게 props/if/text/children/iteration 입니다. * * @param array $component 컴포넌트 정의 * @return array 오버라이드가 병합된 컴포넌트 정의 */ private function applyResponsiveOverrides(array $component): array { if (empty($component['responsive']) || ! is_array($component['responsive'])) { return $component; } $breakpoint = $this->matchingBreakpointKey($component['responsive']); if ($breakpoint === null) { return $component; } $override = $component['responsive'][$breakpoint]; if (isset($override['props'])) { $component['props'] = array_merge($component['props'] ?? [], $override['props']); } foreach (['if', 'text', 'children', 'iteration'] as $key) { if (isset($override[$key])) { $component[$key] = $override[$key]; } } return $component; } /** * 데스크톱 폭에 매칭되는 responsive 키를 하나만 고릅니다. * * React `ResponsiveManager::getMatchingKey()` 와 동일한 우선순위를 따릅니다: * ① 커스텀 범위가 프리셋보다 우선 ② 좁은 범위가 넓은 범위보다 우선. * 매칭되는 키를 전부 순차 병합하면 JSON 키 선언 순서에 따라 결과가 달라진다. * * @param array $responsive responsive 오버라이드 맵 * @return string|null 적용할 키 (없으면 null) */ private function matchingBreakpointKey(array $responsive): ?string { $matched = []; foreach ($responsive as $breakpoint => $override) { if (! is_array($override)) { continue; } $range = $this->parseBreakpointRange((string) $breakpoint); if ($range === null) { continue; } [$min, $max] = $range; if ($min > self::DESKTOP_VIEWPORT_WIDTH || $max < self::DESKTOP_VIEWPORT_WIDTH) { continue; } $matched[] = [ 'key' => (string) $breakpoint, 'isPreset' => isset(self::BREAKPOINT_PRESETS[$breakpoint]), 'width' => $max - $min, ]; } if ($matched === []) { return null; } usort($matched, function (array $a, array $b): int { if ($a['isPreset'] !== $b['isPreset']) { return $a['isPreset'] ? 1 : -1; } return $a['width'] <=> $b['width']; }); return $matched[0]['key']; } /** * responsive 브레이크포인트 키를 [min, max] 범위로 파싱합니다. * * 프리셋(`desktop`/`portable`/`mobile`/`tablet`)과 `1024-`, `1280-1535`, * `-599` 같은 숫자 범위를 지원합니다. `min > max` 인 잘못된 범위는 무시합니다 * (React `ResponsiveManager::parseRange()` 와 동일). * * @param string $breakpoint 브레이크포인트 키 * @return array{0: int, 1: int}|null [min, max] (파싱 불가 시 null) */ private function parseBreakpointRange(string $breakpoint): ?array { if (isset(self::BREAKPOINT_PRESETS[$breakpoint])) { return self::BREAKPOINT_PRESETS[$breakpoint]; } if (! preg_match('/^(-?\d*)-(-?\d*)$/', $breakpoint, $matches)) { return null; } $min = $matches[1] === '' ? 0 : (int) $matches[1]; $max = $matches[2] === '' ? PHP_INT_MAX : (int) $matches[2]; return $min <= $max ? [$min, $max] : null; } /** * 컴포넌트를 렌더링해야 하는지 판정합니다 (if / condition / conditions). * * React RenderHelpers.evaluateRenderCondition과 동일한 우선순위를 따릅니다: * `if` → `condition`(별칭) → `conditions`(AND/OR 그룹 또는 if/else 체인). * * @param array $component 컴포넌트 정의 * @param array $context 데이터 컨텍스트 * @param ExpressionEvaluator $evaluator 표현식 평가기 * @return bool 렌더링 대상이면 true */ private function shouldRender(array $component, array $context, ExpressionEvaluator $evaluator): bool { if (isset($component['if'])) { return $this->evaluateBooleanExpression($component['if'], $context, $evaluator); } if (isset($component['condition'])) { return $this->evaluateBooleanExpression($component['condition'], $context, $evaluator); } if (! isset($component['conditions'])) { return true; } return $this->evaluateConditions($component['conditions'], $context, $evaluator); } /** * conditions 속성(AND/OR 그룹 또는 if/else 체인)을 평가합니다. * * - 문자열 / `{and: [...]}` / `{or: [...]}` → 조건식 평가 * - `[{if: ...}, {if: ...}, {}]` → 하나라도 매칭되면 true (if 없는 항목은 else 브랜치) * - 위 어느 형식도 아니면 **렌더링**한다 — React `evaluateRenderCondition()` 이 알 수 없는 * 형식에 대해 경고 후 true 를 반환하므로, 여기서 false 를 주면 봇 화면에서만 노드가 사라진다. * * @param mixed $conditions conditions 속성 값 * @param array $context 데이터 컨텍스트 * @param ExpressionEvaluator $evaluator 표현식 평가기 * @return bool 평가 결과 */ private function evaluateConditions(mixed $conditions, array $context, ExpressionEvaluator $evaluator): bool { if ($this->isConditionExpression($conditions)) { return $this->evaluateConditionExpression($conditions, $context, $evaluator); } if ($this->isConditionBranches($conditions)) { foreach ($conditions as $branch) { if (! is_array($branch)) { continue; } // if 없는 항목 = else 브랜치 (항상 매칭) if (! isset($branch['if'])) { return true; } if ($this->evaluateConditionExpression($branch['if'], $context, $evaluator)) { return true; } } return false; } // 알 수 없는 형식 → 렌더링 (React 동형) return true; } /** * 값이 단일 ConditionExpression(문자열 / `{and:[]}` / `{or:[]}`) 인지 판정합니다. * * React `ConditionEvaluator.isConditionExpression()` 과 동일한 판정입니다. * * @param mixed $value 판정 대상 * @return bool ConditionExpression 이면 true */ private function isConditionExpression(mixed $value): bool { if (is_string($value) || is_bool($value)) { return true; } if (is_array($value) && ! array_is_list($value)) { return array_key_exists('and', $value) || array_key_exists('or', $value); } return false; } /** * 값이 ConditionBranch[] 인지 판정합니다. * * 빈 배열은 브랜치 배열로 보지 않으며, 첫 항목에 `if` 또는 `then` 이 있어야 합니다 * (React `ConditionEvaluator.isConditionBranches()` 와 동일). * * @param mixed $value 판정 대상 * @return bool ConditionBranch[] 이면 true */ private function isConditionBranches(mixed $value): bool { if (! is_array($value) || ! array_is_list($value) || $value === []) { return false; } $first = $value[0]; if (! is_array($first)) { return false; } return array_key_exists('if', $first) || array_key_exists('then', $first); } /** * 조건식(문자열 또는 AND/OR 그룹)을 평가합니다. * * 빈 AND 그룹은 true, 빈 OR 그룹은 false 를 반환합니다 (React와 동일). * * @param mixed $condition 조건식 * @param array $context 데이터 컨텍스트 * @param ExpressionEvaluator $evaluator 표현식 평가기 * @return bool 평가 결과 */ private function evaluateConditionExpression(mixed $condition, array $context, ExpressionEvaluator $evaluator): bool { if (! is_array($condition)) { return $this->evaluateBooleanExpression($condition, $context, $evaluator); } if (array_key_exists('and', $condition)) { if (! is_array($condition['and']) || $condition['and'] === []) { return true; } foreach ($condition['and'] as $sub) { if (! $this->evaluateConditionExpression($sub, $context, $evaluator)) { return false; } } return true; } if (array_key_exists('or', $condition)) { if (! is_array($condition['or']) || $condition['or'] === []) { return false; } foreach ($condition['or'] as $sub) { if ($this->evaluateConditionExpression($sub, $context, $evaluator)) { return true; } } return false; } // 알 수 없는 조건 형식 return false; } /** * 단일 조건 표현식을 boolean으로 평가합니다. * * 문자열 결과의 falsy 판정 목록은 React RenderHelpers.evaluateIfCondition과 * 동일합니다 (`''`, `false`, `0`, `null`, `undefined` — 대소문자·공백 무시). * * @param mixed $condition 조건 표현식 (문자열 또는 boolean) * @param array $context 데이터 컨텍스트 * @param ExpressionEvaluator $evaluator 표현식 평가기 * @return bool 평가 결과 */ private function evaluateBooleanExpression(mixed $condition, array $context, ExpressionEvaluator $evaluator): bool { if (is_bool($condition)) { return $condition; } // 조건이 비어 있으면 조건 없음으로 간주 (React: `if (!ifCondition) return true`) if ($condition === null || $condition === '') { return true; } $resolved = $evaluator->evaluate((string) $condition, $context); return ! in_array(strtolower(trim($resolved)), ['', 'false', '0', 'null', 'undefined'], true); } /** * 선언적 렌더 모드로 컴포넌트를 HTML로 변환합니다. * * 우선순위가 renderTag() 와 반대인 것은 의도된 것이다 — renderTag() 는 text 를 * children 보다 먼저 쓰지만(React DynamicRenderer 동형), 이 경로는 자체 렌더링을 * 가진 집합 컴포넌트(Select·Pagination·HtmlContent 등) 전용이라 모드 출력이 먼저다. * React 에서도 Select 는 options prop 으로 스스로 option 을 그리고 text/children 을 * 무시하므로, 여기서 text 를 우선하면 옵션 목록이 통째로 사라져 오히려 어긋난다. * * @param string $mode 렌더 모드명 (render_modes 키) * @param string $tag 외부 래퍼 HTML 태그 * @param array $configEntry seo-config.json의 component_map 엔트리 * @param array $component 컴포넌트 정의 * @param array $context 데이터 컨텍스트 * @param ExpressionEvaluator $evaluator 표현식 평가기 * @return string HTML 문자열 */ private function renderByMode(string $mode, string $tag, array $configEntry, array $component, array $context, ExpressionEvaluator $evaluator): string { $modeConfig = $this->renderModes[$mode] ?? null; if (! $modeConfig) { return ''; } $props = $component['props'] ?? []; $attrs = $this->buildAttributes($props, $context, $evaluator); $innerHtml = match ($modeConfig['type'] ?? '') { 'iterate' => $this->renderIterateMode($modeConfig, $configEntry, $component, $context, $evaluator), 'format' => $this->renderFormatMode($modeConfig, $configEntry, $component, $context, $evaluator), 'raw' => $this->renderRawMode($modeConfig, $configEntry, $component, $context, $evaluator), 'fields' => $this->renderFieldsMode($modeConfig, $configEntry, $component, $context, $evaluator), 'pagination' => $this->renderPaginationMode($modeConfig, $component, $context, $evaluator), default => '', }; // children 추가 $childrenHtml = $this->renderChildren($component, $context, $evaluator); if ($childrenHtml !== '') { $innerHtml .= $childrenHtml; } // 텍스트 콘텐츠 fallback if ($innerHtml === '') { $innerHtml = $this->resolveTextContent($component, $props, $context, $evaluator, $tag); } return "<{$tag}{$attrs}>{$innerHtml}"; } /** * 일반 태그로 렌더링합니다. * * @param string $tag HTML 태그 * @param array $component 컴포넌트 정의 * @param array $context 데이터 컨텍스트 * @param ExpressionEvaluator $evaluator 표현식 평가기 * @return string HTML 문자열 */ private function renderTag(string $tag, array $component, array $context, ExpressionEvaluator $evaluator): string { $props = $component['props'] ?? []; $attrs = $this->buildAttributes($props, $context, $evaluator); if (in_array($tag, $this->selfClosing)) { return "<{$tag}{$attrs}>"; } // 노드 레벨 text가 있으면 children보다 우선 (React DynamicRenderer와 동일 우선순위). // text 키가 있으면 해석 결과가 빈 문자열이어도 그것이 최종 값이다 — React 는 // text !== undefined 인 순간 children 경로로 내려가지 않는다(DynamicRenderer.tsx:2591,2639). $nodeText = $this->resolveNodeText($component, $context, $evaluator); if ($nodeText !== null) { $innerHtml = $nodeText; } else { $innerHtml = $this->renderChildren($component, $context, $evaluator); if ($innerHtml === '') { $innerHtml = $this->resolveTextContent($component, $props, $context, $evaluator, $tag); } } // dangerouslySetInnerHTML 처리 — 일반 화면과 동일 강도로 정화 후 삽입 if ($innerHtml === '' && isset($props['dangerouslySetInnerHTML'])) { $rawHtml = $evaluator->evaluate((string) $props['dangerouslySetInnerHTML'], $context); if ($rawHtml !== '') { $innerHtml = $this->htmlSanitizer()->sanitize($rawHtml); } } return "<{$tag}{$attrs}>{$innerHtml}"; } /** * iterate 타입: 배열 데이터를 순회하며 아이템별 HTML 생성 * * @param array $modeConfig render_modes 엔트리 * @param array $configEntry component_map 엔트리 * @param array $component 컴포넌트 정의 * @param array $context 데이터 컨텍스트 * @param ExpressionEvaluator $evaluator 표현식 평가기 * @return string HTML 문자열 */ private function renderIterateMode(array $modeConfig, array $configEntry, array $component, array $context, ExpressionEvaluator $evaluator): string { // source 해석: "$props_source" → configEntry의 props_source 키에서 실제 키 가져옴 $sourceKey = $this->resolveSourceKey($modeConfig['source'] ?? '', $configEntry); $props = $component['props'] ?? []; $dataExpr = $props[$sourceKey] ?? ''; if (! is_array($dataExpr) && $dataExpr === '') { return ''; } // props에 배열이 직접 전달된 경우 바로 사용 if (is_array($dataExpr)) { $data = $dataExpr; } else { // 표현식에서 배열 경로 추출 $normalizedPath = str_replace(['?.', '{{', '}}'], ['.', '', ''], (string) $dataExpr); $normalizedPath = trim($normalizedPath); if (str_contains($normalizedPath, '??')) { $normalizedPath = trim(explode('??', $normalizedPath, 2)[0]); } $data = data_get($context, $normalizedPath); } if (! is_array($data) || empty($data)) { return ''; } $itemTag = $modeConfig['item_tag'] ?? 'div'; $itemAttrs = $modeConfig['item_attrs'] ?? []; $itemContent = $modeConfig['item_content'] ?? null; $badgeField = $modeConfig['badge_field'] ?? null; $html = ''; foreach ($data as $item) { $attrStr = $itemAttrs ? $this->buildIterateItemAttributes($itemAttrs, $item) : ''; if ($itemContent !== null) { // 콘텐츠 기반 렌더링 (탭 리스트, 선택 목록 등) // item_attrs 가 함께 선언되면 속성과 라벨을 모두 그린다 — `` // 처럼 둘이 다 필요한 요소를 표현하기 위함이다. $content = $this->resolveFieldPattern($itemContent, $item); $evaluatedContent = $content === '' ? '' : e($evaluator->evaluate((string) $content, $context)); if ($evaluatedContent === '' && $attrStr === '') { continue; } $badge = ''; if ($badgeField && isset($item[$badgeField])) { $evaluatedBadge = $evaluator->evaluate((string) $item[$badgeField], $context); if ($evaluatedBadge !== '' && $evaluatedBadge !== '0') { $badge = ' ('.e($evaluatedBadge).')'; } } $html .= "<{$itemTag}{$attrStr}>".$evaluatedContent.$badge.""; } elseif ($itemAttrs) { // 속성 기반 렌더링 (이미지 갤러리 등) if (in_array($itemTag, $this->selfClosing)) { $html .= "<{$itemTag}{$attrStr}>"; } else { $html .= "<{$itemTag}{$attrStr}>"; } } else { // 단순 아이템 (문자열) $value = is_string($item) ? e($item) : ''; $html .= "<{$itemTag}>".$value.""; } } return $html; } /** * iterate 모드 아이템의 속성 문자열을 만듭니다. * * @param array $itemAttrs render_modes 의 item_attrs 정의 * @param mixed $item 현재 아이템 * @return string 속성 문자열 (앞에 공백 포함, 없으면 빈 문자열) */ private function buildIterateItemAttributes(array $itemAttrs, mixed $item): string { $attrStr = ''; foreach ($itemAttrs as $attrName => $fieldPattern) { $value = $this->resolveFieldPattern($fieldPattern, $item); if ($value === '') { continue; } // src 속성의 상대 경로를 절대 경로로 변환 if ($attrName === 'src' && ! str_starts_with($value, 'http')) { $value = url($value); } $attrStr .= " {$attrName}=\"".e($value).'"'; } return $attrStr; } /** * format 타입: 포맷 문자열의 {key} 플레이스홀더를 props로 치환 * * @param array $modeConfig render_modes 엔트리 * @param array $configEntry component_map 엔트리 * @param array $component 컴포넌트 정의 * @param array $context 데이터 컨텍스트 * @param ExpressionEvaluator $evaluator 표현식 평가기 * @return string HTML 문자열 */ private function renderFormatMode(array $modeConfig, array $configEntry, array $component, array $context, ExpressionEvaluator $evaluator): string { $format = $configEntry['format'] ?? ''; $defaults = $configEntry['defaults'] ?? []; $props = $component['props'] ?? []; if ($format === '') { return ''; } // 포맷 내 {key} 또는 {key.sub} 플레이스홀더를 props → seoVars → defaults 순서로 치환 // dot notation 지원: {author.nickname} → props['author'] 객체에서 'nickname' 추출 $seoVars = $this->seoVars; $result = preg_replace_callback('/\{([\w.]+)\}/', function ($matches) use ($props, $seoVars, $defaults, $context, $evaluator) { $key = $matches[1]; // dot notation 처리 (예: author.nickname) if (str_contains($key, '.')) { [$rootKey, $subPath] = explode('.', $key, 2); if (isset($props[$rootKey])) { $resolved = $evaluator->evaluateRaw((string) $props[$rootKey], $context); if (is_array($resolved)) { $value = data_get($resolved, $subPath, ''); return is_string($value) || is_numeric($value) ? e((string) $value) : ''; } } return e($defaults[$key] ?? ''); } // 1. 컴포넌트 props (최우선) if (isset($props[$key])) { $value = $evaluator->evaluate((string) $props[$key], $context); if ($value !== '') { return e($value); } } // 2. SEO 변수 (meta.seo.vars에서 해석된 값) if (isset($seoVars[$key]) && $seoVars[$key] !== '') { return e($seoVars[$key]); } // 3. seo-config.json defaults (최종 폴백) return e($defaults[$key] ?? ''); }, $format); return $result ?? ''; } /** * raw 타입: 콘텐츠를 `isHtml` 판정에 따라 평문 또는 정화된 HTML 로 출력 * * 일반 화면의 `HtmlContent` 컴포지트와 동일한 규칙입니다. * - `isHtml=false` → 평문 (전체 이스케이프) * - `isHtml=true`(기본) → 정화 후 HTML 로 출력 * * 정화·이스케이프를 생략하면 봇 화면에서만 사용자 작성 `