Files
Gnuboard7/app/Seo/ComponentHtmlMapper.php
T
HeuJung 5ba7a83597 feat(core,engine): 부트스트랩 리소스 정적 게시(bake) 및 폴백 체계 도입
공개 제보 https://github.com/gnuboard/g7/issues/122 대응 — 초기 부트스트랩
리소스(다국어 병합·컴포넌트 정의·라우트·확장 번들·템플릿 dist)를 캐시 버전
디렉토리(public/build/ext/{v}/)에 실파일로 게시해 웹서버가 rewrite 전에 직접
서빙한다. 부트 임계 경로의 PHP 왕복을 제거하고(실측 TTFB 131~144ms → 1~7ms),
미게시·부분게시·GC 직후에는 fetch·태그·번들 3계층이 종전 API 로 즉시 폴백한다.

- 게시: 원자적 tmp→rename→manifest(존재=완료), 캐시 락 단일 실행, 인라인 GC
 (현재+직전 1개), incrementExtensionCacheVersion 단일 지점 terminating 트리거
 + blade 자가 치유 + 설치기 태스크(best_effort) + 일일 cleanup 스케줄,
 sudo 업데이트 대비 소유권 정상화(normalizeOwnership)·prune/백업 제외
- 프론트(engine-v1.61.0): blade 주입 cache_version 1급 시드(이중 부트 로드 제거),
 fetchStaticFirst 즉시 폴백, ComponentRegistry 버전 키드 매니페스트,
 ModuleAssetLoader 번들 정적→레거시 폴백, asset-url-recovery staticToLegacy 역변환
- 폴백 API 품질: lang/components/routes ETag+304 + 환경 분기 Cache-Control,
 열화 라우트 스냅샷 공개 캐시 금지(서버측 캐시 회피와 대칭), 게시 .htaccess
 mod_deflate + nginx gzip 스니펫(압축 전송량 회귀 방지)
- SEO 정합: 봇 HTML 은 GC 대상 정적 URL 미사용(allowStatic:false), props $switch
 봇측 해석 구현(engine-v1.56.0 패리티), 패리티 룰 expression-dialect 그룹 신설,
 상주 allow 헤더 제거로 잠금 복원, _comment* 접두 주석 키 분류
- 검증: 전 수정 red→green 4단계, Playwright 라이브 21건, Chrome MCP 24축+M1~M3,
 봇 curl 3축, 캐시 저장소(file/redis/database) 축 판정, 히스토리·공개이슈·커밋
 이력 전수 재조사 반영
- 코어 7.0.10, engine-v1.61.0. kill-switch: G7_STATIC_CACHE=false
2026-08-25 17:02:53 +09:00

2109 lines
79 KiB
PHP

<?php
namespace App\Seo;
class ComponentHtmlMapper
{
/**
* SEO 렌더링 기준 뷰포트 폭 (px)
*
* 검색 봇은 데스크톱으로 간주하므로 responsive 오버라이드 매칭에 이 값을 사용합니다.
*/
private const DESKTOP_VIEWPORT_WIDTH = 1024;
/**
* responsive 브레이크포인트 프리셋 → [min, max] 폭 범위 (px)
*
* React ResponsiveManager.ts 의 BREAKPOINT_PRESETS 와 동일한 값입니다.
*/
private const BREAKPOINT_PRESETS = [
'mobile' => [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 는 `<select>` 안에 `<option>` 라벨을 그리므로, 봇 화면에서만 `value` 가
* 글자로 노출되면(예: `<select>created_at_desc</select>`) 두 화면이 어긋나고
* 색인에도 내부 식별자가 섞입니다.
*/
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}</{$tag}>";
}
/**
* 일반 태그로 렌더링합니다.
*
* @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}</{$tag}>";
}
/**
* 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 가 함께 선언되면 속성과 라벨을 모두 그린다 — `<option value="x">라벨</option>`
// 처럼 둘이 다 필요한 요소를 표현하기 위함이다.
$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 = ' <span>('.e($evaluatedBadge).')</span>';
}
}
$html .= "<{$itemTag}{$attrStr}>".$evaluatedContent.$badge."</{$itemTag}>";
} elseif ($itemAttrs) {
// 속성 기반 렌더링 (이미지 갤러리 등)
if (in_array($itemTag, $this->selfClosing)) {
$html .= "<{$itemTag}{$attrStr}>";
} else {
$html .= "<{$itemTag}{$attrStr}></{$itemTag}>";
}
} else {
// 단순 아이템 (문자열)
$value = is_string($item) ? e($item) : '';
$html .= "<{$itemTag}>".$value."</{$itemTag}>";
}
}
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 로 출력
*
* 정화·이스케이프를 생략하면 봇 화면에서만 사용자 작성 `<script>` 가 살아남아,
* 주소에 봇 파라미터를 붙이는 것만으로 실행됩니다.
*
* @param array $modeConfig render_modes 엔트리
* @param array $configEntry component_map 엔트리
* @param array $component 컴포넌트 정의
* @param array $context 데이터 컨텍스트
* @param ExpressionEvaluator $evaluator 표현식 평가기
* @return string HTML 문자열
*/
private function renderRawMode(array $modeConfig, array $configEntry, array $component, array $context, ExpressionEvaluator $evaluator): string
{
// source 해석
$sourceKey = $this->resolveSourceKey($modeConfig['source'] ?? '', $configEntry);
$props = $component['props'] ?? [];
// text 속성 우선, 그 다음 props_source
$contentExpr = $component['text'] ?? $props[$sourceKey] ?? $props['text'] ?? '';
if ($contentExpr === '') {
return '';
}
$content = $evaluator->evaluate((string) $contentExpr, $context);
if ($content === '') {
return '';
}
// isHtml 미지정은 HTML 로 간주 (HtmlContent 의 기본값 isHtml = true 와 동일)
if (! $this->evaluateBooleanExpression($props['isHtml'] ?? true, $context, $evaluator)) {
return e($content);
}
return $this->htmlSanitizer()->sanitize($content);
}
/**
* HTML 정화기를 반환합니다 (지연 생성).
*
* @return HtmlSanitizer 정화기
*/
private function htmlSanitizer(): HtmlSanitizer
{
return $this->htmlSanitizer ??= new HtmlSanitizer;
}
/**
* fields 타입: 객체 props에서 필드를 추출하여 HTML 생성
*
* 컴포지트 컴포넌트(ProductCard 등)가 받는 객체 prop에서
* seo-config.json에 선언된 필드 목록을 추출하여 SEO용 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 renderFieldsMode(array $modeConfig, array $configEntry, array $component, array $context, ExpressionEvaluator $evaluator): string
{
$source = $modeConfig['source'] ?? '';
$props = $component['props'] ?? [];
// $all_props: 모든 props를 표현식 해석하여 데이터 객체로 사용
if ($source === '$all_props') {
$data = $this->resolveAllProps($props, $context, $evaluator);
} else {
$sourceKey = $this->resolveSourceKey($source, $configEntry);
$dataExpr = $props[$sourceKey] ?? '';
$data = $this->resolveObjectFromExpression($dataExpr, $context);
}
if (! is_array($data) || empty($data)) {
return '';
}
// fields 렌더링 중 evaluator/context 임시 저장 ($t: 번역 해석용)
$this->fieldsEvaluator = $evaluator;
$this->fieldsContext = $context;
try {
$fields = $modeConfig['fields'] ?? [];
$html = '';
foreach ($fields as $field) {
$entry = $this->renderFieldEntry($field, $data);
if ($entry !== '') {
$html .= $entry."\n";
}
}
// link 래핑: 모든 필드를 <a> 태그로 감쌈
if ($html !== '' && isset($modeConfig['link']['href'])) {
$linkHref = $this->resolveFieldsLink($modeConfig['link'], $data);
if ($linkHref !== null) {
$html = '<a href="'.e($linkHref).'">'."\n".$html.'</a>'."\n";
}
}
return $html;
} finally {
$this->fieldsEvaluator = null;
$this->fieldsContext = [];
}
}
/**
* pagination 모드: 컴포넌트 props에서 currentPage/totalPages를 읽어 페이지 링크를 생성합니다.
*
* seo-config.json 설정 예:
* "Pagination": { "tag": "nav", "render": "pagination_links", "current_page_prop": "currentPage", "total_pages_prop": "totalPages" }
*
* @param array $modeConfig 렌더 모드 설정 (max_links 등)
* @param array $component 컴포넌트 정의
* @param array $context 데이터 컨텍스트
* @param ExpressionEvaluator $evaluator 표현식 평가기
* @return string HTML 문자열
*/
private function renderPaginationMode(array $modeConfig, array $component, array $context, ExpressionEvaluator $evaluator): string
{
$props = $component['props'] ?? [];
// props에서 currentPage, totalPages 해석
$currentPageProp = $modeConfig['current_page_prop'] ?? 'currentPage';
$totalPagesProp = $modeConfig['total_pages_prop'] ?? 'totalPages';
$currentPage = 1;
$totalPages = 1;
if (isset($props[$currentPageProp])) {
$val = $evaluator->evaluate((string) $props[$currentPageProp], $context);
if (is_numeric($val)) {
$currentPage = (int) $val;
}
}
if (isset($props[$totalPagesProp])) {
$val = $evaluator->evaluate((string) $props[$totalPagesProp], $context);
if (is_numeric($val)) {
$totalPages = (int) $val;
}
}
if ($totalPages <= 1) {
return '';
}
// 현재 URL 경로 (route.path)
$basePath = $context['route']['path'] ?? '/';
$maxLinks = (int) ($modeConfig['max_links'] ?? 10);
// 표시할 페이지 범위 계산
$start = max(1, $currentPage - (int) floor($maxLinks / 2));
$end = min($totalPages, $start + $maxLinks - 1);
$start = max(1, $end - $maxLinks + 1);
$html = '';
for ($i = $start; $i <= $end; $i++) {
$href = e($basePath.'?page='.$i);
if ($i === $currentPage) {
$html .= '<span>'.$i.'</span>';
} else {
$html .= '<a href="'.$href.'">'.$i.'</a>';
}
}
return $html;
}
/**
* fields 모드의 단일 필드 엔트리를 HTML로 렌더링합니다.
*
* @param array $field 필드 정의 (tag, content, attrs, if, iterate 등)
* @param array $data 소스 객체 데이터
* @return string HTML 문자열
*/
private function renderFieldEntry(array $field, array $data): string
{
$tag = $field['tag'] ?? 'span';
// 조건부 렌더링 (if)
if (isset($field['if'])) {
$condValue = $this->resolveFieldPattern($field['if'], $data);
if ($condValue === '' || $condValue === '0' || $condValue === 'false') {
return '';
}
}
// iterate: 배열 필드를 순회하여 아이템별 HTML 생성
if (isset($field['iterate'])) {
return $this->renderFieldIterate($field, $data, $tag);
}
// 속성 기반 렌더링 (img 등)
if (isset($field['attrs'])) {
return $this->renderFieldWithAttrs($field, $data, $tag);
}
// children: 중첩 필드 그룹
if (isset($field['children']) && is_array($field['children'])) {
return $this->renderFieldChildren($field, $data, $tag);
}
// 콘텐츠 기반 렌더링
if (isset($field['content'])) {
$content = $this->resolveFieldContent($field['content'], $data);
if ($content === '') {
return '';
}
$classAttr = isset($field['class']) ? ' class="'.e($field['class']).'"' : '';
return "<{$tag}{$classAttr}>".e($content)."</{$tag}>";
}
return '';
}
/**
* fields 모드의 iterate 엔트리를 렌더링합니다.
*
* 배열 필드를 순회하여 아이템별 HTML 태그를 생성합니다.
* 예: labels 배열의 각 요소를 <span> 태그로 출력
*
* @param array $field 필드 정의
* @param array $data 소스 객체 데이터
* @param string $wrapperTag 래퍼 태그
* @return string HTML 문자열
*/
private function renderFieldIterate(array $field, array $data, string $wrapperTag): string
{
$iterateKey = $field['iterate'];
$items = data_get($data, $iterateKey);
if (! is_array($items) || empty($items)) {
return '';
}
$itemTag = $field['item_tag'] ?? 'span';
$itemContent = $field['item_content'] ?? null;
$itemAttrs = $field['item_attrs'] ?? [];
$innerHtml = '';
foreach ($items as $item) {
if ($itemContent !== null && is_array($item)) {
$content = $this->resolveFieldPattern($itemContent, $item);
if ($content !== '') {
// item_attrs: 아이템별 속성 해석 (예: href="/board/{slug}")
$attrStr = '';
foreach ($itemAttrs as $attrName => $attrPattern) {
$attrValue = $this->resolveFieldContent($attrPattern, $item);
if ($attrValue !== '') {
$attrStr .= " {$attrName}=\"".e($attrValue).'"';
}
}
$innerHtml .= "<{$itemTag}{$attrStr}>".e($content)."</{$itemTag}>";
}
} elseif (is_string($item)) {
$innerHtml .= "<{$itemTag}>".e($item)."</{$itemTag}>";
}
}
if ($innerHtml === '') {
return '';
}
$classAttr = isset($field['class']) ? ' class="'.e($field['class']).'"' : '';
return "<{$wrapperTag}{$classAttr}>{$innerHtml}</{$wrapperTag}>";
}
/**
* fields 모드의 children 그룹 엔트리를 렌더링합니다.
*
* 자식 필드를 재귀적으로 renderFieldEntry()를 호출하여 렌더링하고,
* 래퍼 태그로 감쌉니다. 모든 자식이 빈 결과이면 래퍼 태그를 출력하지 않습니다.
*
* @param array $field 필드 정의 (children 키 포함)
* @param array $data 소스 객체 데이터
* @param string $tag 래퍼 HTML 태그
* @return string HTML 문자열
*/
private function renderFieldChildren(array $field, array $data, string $tag): string
{
$innerHtml = '';
foreach ($field['children'] as $child) {
$innerHtml .= $this->renderFieldEntry($child, $data);
}
if ($innerHtml === '') {
return '';
}
$classAttr = isset($field['class']) ? ' class="'.e($field['class']).'"' : '';
return "<{$tag}{$classAttr}>{$innerHtml}</{$tag}>";
}
/**
* fields 모드의 link 설정을 해석하여 URL을 반환합니다.
*
* href 패턴에서 {field} 를 데이터 값으로 치환하고,
* base_url이 있으면 앞에 붙입니다. $global: 접두사는 globalResolver로 해석합니다.
*
* @param array $linkConfig link 설정 (href, base_url 등)
* @param array $data 소스 객체 데이터
* @return string|null 해석된 URL (null이면 링크 미생성)
*/
private function resolveFieldsLink(array $linkConfig, array $data): ?string
{
$hrefPattern = $linkConfig['href'] ?? '';
if ($hrefPattern === '') {
return null;
}
$baseUrl = '';
if (isset($linkConfig['base_url'])) {
$ref = $linkConfig['base_url'];
if (str_starts_with($ref, '$var:')) {
// SEO vars에서 해석 (meta.seo.vars로 정의된 변수)
$varName = substr($ref, strlen('$var:'));
$baseUrl = $this->seoVars[$varName] ?? '';
} elseif (str_starts_with($ref, '$global:')) {
$globalKey = substr($ref, strlen('$global:'));
if ($this->globalResolver) {
$resolved = ($this->globalResolver)('_global.'.$globalKey);
$baseUrl = $resolved ?? '';
}
} else {
$baseUrl = $ref;
}
// base_url에 / 접두사 보장 (route_path 등은 / 없이 저장됨)
if ($baseUrl !== '' && ! str_starts_with($baseUrl, '/')) {
$baseUrl = '/'.$baseUrl;
}
}
// href 패턴 내 {field} 중 해석 불가한 것이 있으면 링크 미생성
$hasUnresolved = false;
$href = preg_replace_callback('/\{(.+?)\}/', function ($matches) use ($data, &$hasUnresolved) {
$fields = explode('|', $matches[1]);
foreach ($fields as $field) {
$field = trim($field);
$value = data_get($data, $field);
if ($value !== null && $value !== '') {
return (string) $value;
}
}
$hasUnresolved = true;
return '';
}, $hrefPattern);
if ($hasUnresolved || $href === '') {
return null;
}
// base_url이 "/" 인 경우 이중 슬래시 방지
if ($baseUrl === '/') {
$baseUrl = '';
}
return $baseUrl.$href;
}
/**
* fields 모드의 속성 기반 엔트리를 렌더링합니다.
*
* img 등 속성 기반 태그를 생성합니다.
*
* @param array $field 필드 정의
* @param array $data 소스 객체 데이터
* @param string $tag HTML 태그
* @return string HTML 문자열
*/
private function renderFieldWithAttrs(array $field, array $data, string $tag): string
{
$attrStr = '';
foreach ($field['attrs'] as $attrName => $fieldPattern) {
$value = $this->resolveFieldPattern($fieldPattern, $data);
if ($value !== '') {
// src 속성의 상대 경로를 절대 경로로 변환
if ($attrName === 'src' && ! str_starts_with($value, 'http')) {
$value = url($value);
}
$attrStr .= " {$attrName}=\"".e($value).'"';
}
}
$classAttr = isset($field['class']) ? ' class="'.e($field['class']).'"' : '';
if (in_array($tag, $this->selfClosing)) {
return "<{$tag}{$classAttr}{$attrStr}>";
}
// content가 있으면 태그 내부에 렌더링
$innerHtml = '';
if (isset($field['content'])) {
$innerHtml = e($this->resolveFieldContent($field['content'], $data));
}
return "<{$tag}{$classAttr}{$attrStr}>{$innerHtml}</{$tag}>";
}
/**
* 필드 콘텐츠를 해석합니다 ({field} 패턴 + 리터럴 텍스트 혼합 지원).
*
* @param string $contentPattern 콘텐츠 패턴 (예: "{discount_rate}%", "{name}")
* @param array $data 소스 객체 데이터
* @return string 해석된 콘텐츠
*/
private function resolveFieldContent(string $contentPattern, array $data): string
{
// $t: 번역 키 해석 (evaluator가 있는 경우)
if (str_contains($contentPattern, '$t:') && $this->fieldsEvaluator) {
$contentPattern = preg_replace_callback('/\$t:([\w.\-]+(?:\|[\w.\-]+=[\w.\-{}]+)*)/', function ($matches) {
return $this->fieldsEvaluator->evaluate('$t:'.$matches[1], $this->fieldsContext);
}, $contentPattern);
}
// {field|alt_field} 단독 패턴 → resolveFieldPattern 사용
if (preg_match('/^\{(.+?)\}$/', $contentPattern)) {
return $this->resolveFieldPattern($contentPattern, $data);
}
// {field} + 리터럴 혼합 패턴 → 각 {field}를 치환
$result = preg_replace_callback('/\{(.+?)\}/', function ($matches) use ($data) {
$fields = explode('|', $matches[1]);
foreach ($fields as $field) {
$field = trim($field);
$value = data_get($data, $field);
if ($value !== null && $value !== '') {
return (string) $value;
}
}
return '';
}, $contentPattern);
return $result ?? '';
}
/**
* 표현식에서 객체/배열 데이터를 해석합니다.
*
* {{variable}} 형태의 표현식에서 경로를 추출하고
* context에서 해당 데이터를 찾아 반환합니다.
*
* @param mixed $expr 표현식 (문자열 또는 배열)
* @return mixed 해석된 데이터 (배열/객체 또는 null)
*/
private function resolveObjectFromExpression(mixed $expr, array $context): mixed
{
// 이미 배열인 경우 바로 반환
if (is_array($expr)) {
return $expr;
}
if (! is_string($expr) || $expr === '') {
return null;
}
// {{path}} 형태에서 경로 추출
$normalizedPath = str_replace(['?.', '{{', '}}'], ['.', '', ''], $expr);
$normalizedPath = trim($normalizedPath);
// ?? 이후 제거
if (str_contains($normalizedPath, '??')) {
$normalizedPath = trim(explode('??', $normalizedPath, 2)[0]);
}
return data_get($context, $normalizedPath);
}
/**
* $props_source 참조를 실제 키로 해석합니다.
*
* @param string $sourceRef source 참조 ($props_source 또는 직접 키)
* @param array $configEntry component_map 엔트리
* @return string 해석된 소스 키
*/
private function resolveSourceKey(string $sourceRef, array $configEntry): string
{
if ($sourceRef === '$props_source') {
return $configEntry['props_source'] ?? 'content';
}
return $sourceRef !== '' ? $sourceRef : 'content';
}
/**
* 컴포넌트의 모든 props를 표현식 해석하여 데이터 객체로 반환합니다.
*
* source: "$all_props" 모드에서 사용되며, 각 prop 값의 {{expression}}을
* context에서 해석하여 flat 데이터 객체를 구성합니다.
*
* @param array $props 컴포넌트 props (표현식 포함)
* @param array $context 데이터 컨텍스트
* @param ExpressionEvaluator $evaluator 표현식 평가기
* @return array 해석된 데이터 객체
*/
private function resolveAllProps(array $props, array $context, ExpressionEvaluator $evaluator): array
{
$data = [];
foreach ($props as $key => $value) {
if (is_string($value) && str_contains($value, '{{')) {
// 표현식 해석: 문자열/숫자는 evaluate, 배열/객체는 evaluateRaw
$resolved = $evaluator->evaluateRaw($value, $context);
$data[$key] = $resolved;
} elseif ($this->isSwitchDefinition($value)) {
// $switch 선언적 분기 (React resolveObject 와 동일 위치에서 해석)
$data[$key] = $this->resolveSwitchValue($value, $context, $evaluator);
} elseif (is_array($value)) {
// 중첩 객체 (예: socialLinks): 재귀적으로 해석
$data[$key] = $this->resolveAllPropsRecursive($value, $context, $evaluator);
} else {
$data[$key] = $value;
}
}
return $data;
}
/**
* 중첩 props 객체를 재귀적으로 해석합니다.
*
* @param array $values 중첩 객체/배열
* @param array $context 데이터 컨텍스트
* @param ExpressionEvaluator $evaluator 표현식 평가기
* @return array 해석된 데이터
*/
private function resolveAllPropsRecursive(array $values, array $context, ExpressionEvaluator $evaluator): array
{
$result = [];
foreach ($values as $k => $v) {
if (is_string($v) && str_contains($v, '{{')) {
$result[$k] = $evaluator->evaluateRaw($v, $context);
} elseif ($this->isSwitchDefinition($v)) {
$result[$k] = $this->resolveSwitchValue($v, $context, $evaluator);
} elseif (is_array($v)) {
$result[$k] = $this->resolveAllPropsRecursive($v, $context, $evaluator);
} else {
$result[$k] = $v;
}
}
return $result;
}
/**
* 값이 `$switch` 선언적 분기 객체인지 판정합니다.
*
* React `DataBindingEngine.isSwitchExpression` 과 동일 — `$switch` 와 `$cases`
* 키를 모두 가진 객체(연관 배열)만 해당한다.
*
* @param mixed $value 판정 대상 값
* @return bool $switch 정의 여부
*/
private function isSwitchDefinition(mixed $value): bool
{
return is_array($value)
&& array_key_exists('$switch', $value)
&& array_key_exists('$cases', $value);
}
/**
* `$switch` 선언적 분기 객체를 해석합니다.
*
* React `DataBindingEngine.resolveSwitch` 와 동일 의미론 (engine-v1.56.0 패리티):
* ① `$switch` 키 표현식을 평가해 문자열 키로 정규화(trim, 실패 시 빈 문자열)
* ② `$cases` 에서 키 일치 값 선택, 없으면 `$default`, 그것도 없으면 null(React undefined)
* ③ 결과가 `{{}}` 포함 문자열이면 재해석, 객체면 재귀 해석(중첩 $switch 포함)
*
* 레이아웃 최상위 `computed` 의 $switch 는 `SeoRenderer::resolveComputedSwitch` 가
* 별도 처리한다 — 이 메서드는 노드 props 값 축 담당.
*
* @param array $definition $switch 정의 { "$switch", "$cases", "$default"? }
* @param array $context 데이터 컨텍스트
* @param ExpressionEvaluator $evaluator 표현식 평가기
* @return mixed 해석된 값 (매칭·기본값 모두 없으면 null)
*/
private function resolveSwitchValue(array $definition, array $context, ExpressionEvaluator $evaluator): mixed
{
try {
$keyValue = trim((string) $evaluator->evaluate((string) ($definition['$switch'] ?? ''), $context));
} catch (\Throwable) {
$keyValue = '';
}
$cases = is_array($definition['$cases'] ?? null) ? $definition['$cases'] : [];
if ($keyValue !== '' && array_key_exists($keyValue, $cases)) {
$result = $cases[$keyValue];
} elseif (array_key_exists('$default', $definition)) {
$result = $definition['$default'];
} else {
return null;
}
if (is_string($result)) {
return str_contains($result, '{{') ? $evaluator->evaluate($result, $context) : $result;
}
if ($this->isSwitchDefinition($result)) {
return $this->resolveSwitchValue($result, $context, $evaluator);
}
if (is_array($result)) {
return $this->resolveAllPropsRecursive($result, $context, $evaluator);
}
return $result;
}
/**
* {field|alt_field} 패턴에서 아이템 값을 해석합니다.
*
* @param string $pattern 필드 패턴 (예: "{url|src}", "{alt}")
* @param mixed $item 데이터 아이템
* @return string 해석된 값
*/
private function resolveFieldPattern(string $pattern, mixed $item): string
{
// {field|alt_field} 패턴 추출
if (preg_match('/^\{(.+?)\}$/', $pattern, $matches)) {
$fields = explode('|', $matches[1]);
if (is_string($item)) {
return $item;
}
if (is_array($item)) {
foreach ($fields as $field) {
$field = trim($field);
$value = data_get($item, $field);
if ($value !== null && $value !== '') {
return (string) $value;
}
}
}
return '';
}
// 패턴이 아닌 경우 그대로 반환
return $pattern;
}
/**
* children을 렌더링합니다.
*
* @param array $component 컴포넌트 정의
* @param array $context 데이터 컨텍스트
* @param ExpressionEvaluator $evaluator 표현식 평가기
* @return string HTML 문자열
*/
private function renderChildren(array $component, array $context, ExpressionEvaluator $evaluator): string
{
if (isset($component['children']) && is_array($component['children'])) {
return $this->render($component['children'], $context, $evaluator);
}
return '';
}
/**
* 텍스트 콘텐츠를 해석합니다 (컴포넌트 레벨 + props 레벨).
*
* @param array $component 컴포넌트 정의
* @param array $props 컴포넌트 props
* @param array $context 데이터 컨텍스트
* @param ExpressionEvaluator $evaluator 표현식 평가기
* @return string 텍스트 콘텐츠
*/
private function resolveTextContent(array $component, array $props, array $context, ExpressionEvaluator $evaluator, string $tag = ''): string
{
// 1. 컴포넌트 레벨 text 속성 (최우선) — 키가 있으면 빈 값이어도 그것이 최종 값
$nodeText = $this->resolveNodeText($component, $context, $evaluator);
if ($nodeText !== null) {
return $nodeText;
}
$skipValueProp = in_array(strtolower($tag), self::VALUE_NOT_TEXT_TAGS, true);
// 2. props 레벨 텍스트 추출 (seo-config.json의 text_props)
foreach ($this->textProps as $textProp) {
if ($textProp === 'value' && $skipValueProp) {
continue;
}
if (isset($props[$textProp])) {
$text = $evaluator->evaluate((string) $props[$textProp], $context);
if ($text !== '') {
return e($text);
}
}
}
return '';
}
/**
* 노드 레벨 text 속성을 해석합니다.
*
* @param array $component 컴포넌트 정의
* @param array $context 데이터 컨텍스트
* @param ExpressionEvaluator $evaluator 표현식 평가기
* @return string 이스케이프된 텍스트 (없으면 빈 문자열)
*/
private function resolveNodeText(array $component, array $context, ExpressionEvaluator $evaluator): ?string
{
if (! array_key_exists('text', $component)) {
return null;
}
$expression = (string) $component['text'];
// JSX 시맨틱: bool / null 은 렌더되지 않는다.
// React 는 단일 바인딩 text 를 raw 값으로 받아 그대로 자식에 넘기므로
// {false} / {null} 이 화면에 나오지 않는다 (DynamicRenderer.tsx 의 renderChildren).
// 문자열화하면 'false' 라는 글자가 봇 화면에만 노출된다.
//
// 판정에만 evaluateRaw 를 쓰고 출력은 evaluate 로 만든다 — evaluate 가 파이프·인라인
// `$t:` 토큰 후처리를 담당하므로, 이를 건너뛰면 `?? '$t:key'` 같은 폴백 문자열의
// 번역 토큰이 봇 화면에 그대로 남는다.
$resolved = $evaluator->evaluateRaw($expression, $context);
if (is_bool($resolved) || $resolved === null) {
return '';
}
$text = $evaluator->evaluate($expression, $context);
return $text === '' ? '' : e($text);
}
/**
* 반복 렌더링을 처리합니다.
*
* @param array $component 컴포넌트 정의
* @param array $context 데이터 컨텍스트
* @param ExpressionEvaluator $evaluator 표현식 평가기
* @return string HTML 문자열
*/
private function renderIteration(array $component, array $context, ExpressionEvaluator $evaluator): string
{
$iteration = $component['iteration'];
$dataPath = $iteration['source'] ?? $iteration['data'] ?? '';
$itemVar = $iteration['item_var'] ?? 'item';
$indexVar = $iteration['index_var'] ?? 'index';
// 데이터 경로 평가
$dataStr = $evaluator->evaluate($dataPath, $context);
$data = is_string($dataStr) ? json_decode($dataStr, true) : null;
// 직접 배열 경로 해석도 시도
if ($data === null) {
$normalizedPath = str_replace(['?.', '{{', '}}'], ['.', '', ''], $dataPath);
$normalizedPath = trim($normalizedPath);
$data = data_get($context, $normalizedPath);
}
if (! is_array($data)) {
return '';
}
// iteration 속성 제거한 컴포넌트 복사
$templateComponent = $component;
unset($templateComponent['iteration']);
// responsive 오버라이드가 항목 렌더링 시 iteration을 다시 주입해
// 무한 재귀가 되지 않도록 함께 제거한다.
if (isset($templateComponent['responsive']) && is_array($templateComponent['responsive'])) {
foreach (array_keys($templateComponent['responsive']) as $breakpoint) {
unset($templateComponent['responsive'][$breakpoint]['iteration']);
}
}
$html = '';
foreach (array_values($data) as $index => $item) {
$iterContext = array_merge($context, [
$itemVar => $item,
$indexVar => $index,
// `{item_var}_index` 자동 변수 — React 반복 렌더 경로와 동일하게 제공한다.
$itemVar.'_index' => $index,
]);
$html .= $this->renderComponent($templateComponent, $iterContext, $evaluator);
}
return $html;
}
/**
* props를 HTML 속성 문자열로 변환합니다.
*
* @param array $props 컴포넌트 props
* @param array $context 데이터 컨텍스트
* @param ExpressionEvaluator $evaluator 표현식 평가기
* @return string HTML 속성 문자열
*/
private function buildAttributes(array $props, array $context, ExpressionEvaluator $evaluator): string
{
$attrs = '';
foreach ($props as $key => $value) {
$htmlAttr = $this->attrMap[$key] ?? $key;
if (! in_array($htmlAttr, $this->allowedAttrs)) {
continue;
}
// $switch 선언적 분기 객체 (engine-v1.56.0 React 패리티) — 해석하지 않으면
// 배열이라는 이유로 속성이 조용히 사라진다 (예외·경고 없음)
if ($this->isSwitchDefinition($value)) {
$value = $this->resolveSwitchValue($value, $context, $evaluator);
}
if (is_string($value)) {
$evaluated = $evaluator->evaluate($value, $context);
if ($evaluated !== '') {
$attrs .= " {$htmlAttr}=\"".e($evaluated).'"';
}
} elseif (is_bool($value) && $value) {
$attrs .= " {$htmlAttr}";
}
}
return $attrs;
}
/**
* classMap 속성을 해석하여 className에 병합합니다.
*
* 프론트엔드 엔진의 classMap 기능과 동일:
* - base: 항상 적용되는 기본 클래스
* - variants: key 값에 따라 선택되는 클래스 매핑
* - key: 동적으로 평가되는 표현식
* - default: 일치하는 variant가 없을 때 적용할 클래스
*
* 결과는 기존 className과 공백으로 결합됩니다.
*
* @param array $component 컴포넌트 정의 (classMap 포함)
* @param array $context 데이터 컨텍스트
* @param ExpressionEvaluator $evaluator 표현식 평가기
* @return array classMap이 className으로 해석된 컴포넌트
*/
private function resolveClassMap(array $component, array $context, ExpressionEvaluator $evaluator): array
{
$classMap = $component['classMap'];
if (! is_array($classMap)) {
return $component;
}
$base = $classMap['base'] ?? '';
$variants = $classMap['variants'] ?? [];
$keyExpr = $classMap['key'] ?? '';
$default = $classMap['default'] ?? '';
// key 표현식 평가
$keyValue = '';
if ($keyExpr !== '') {
$keyValue = $evaluator->evaluate($keyExpr, $context);
}
// variants에서 매칭되는 클래스 찾기
$variantClass = $variants[$keyValue] ?? $default;
// base + variant 클래스 결합
$classMapResult = trim($base.' '.$variantClass);
// 기존 className과 병합
$existingClass = $component['props']['className'] ?? '';
if ($existingClass !== '') {
// 기존 className도 표현식일 수 있으므로 평가는 buildAttributes에서 수행
// 여기서는 정적 부분만 병합
if (str_contains($existingClass, '{{')) {
$existingClass = $evaluator->evaluate($existingClass, $context);
}
$component['props']['className'] = trim($existingClass.' '.$classMapResult);
} else {
$component['props']['className'] = $classMapResult;
}
// classMap은 처리 완료이므로 제거 (buildAttributes에서 무시하도록)
unset($component['classMap']);
return $component;
}
/**
* Icon 컴포넌트의 name prop을 Font Awesome class로 변환합니다.
*
* seo-config.json의 name_to_class 템플릿 (예: "fas fa-{name}")에 따라
* name prop을 CSS class로 변환하고, 기존 className과 병합합니다.
*
* @param array $configEntry component_map 엔트리
* @param array $component 컴포넌트 정의
* @param array $context 데이터 컨텍스트
* @param ExpressionEvaluator $evaluator 표현식 평가기
* @return array 변환된 컴포넌트
*/
private function transformIconProps(array $configEntry, array $component, array $context, ExpressionEvaluator $evaluator): array
{
$props = $component['props'] ?? [];
$nameValue = $props['name'] ?? '';
if ($nameValue === '') {
return $component;
}
// name 표현식 평가 ({{icon_name}} 등)
$resolvedName = $evaluator->evaluate((string) $nameValue, $context);
if ($resolvedName === '') {
return $component;
}
// fa- 접두사 제거 (중복 방지)
$resolvedName = preg_replace('/^fa-/', '', $resolvedName);
// name_to_class 템플릿으로 CSS class 생성
$template = $configEntry['name_to_class'];
$iconClass = str_replace('{name}', $resolvedName, $template);
// 기존 className과 병합
$existingClass = $props['className'] ?? '';
if ($existingClass !== '') {
$resolvedExisting = $evaluator->evaluate((string) $existingClass, $context);
if ($resolvedExisting !== '') {
$iconClass .= ' '.$resolvedExisting;
}
}
// className 설정, name prop 제거 (HTML name 속성으로 출력 방지)
$component['props']['className'] = $iconClass;
unset($component['props']['name']);
// aria-label 자동 생성 (접근성)
if (! isset($props['aria-label'])) {
$component['props']['aria-label'] = str_replace('-', ' ', $resolvedName);
}
// role="img" 추가 (접근성)
if (! isset($props['role'])) {
$component['props']['role'] = 'img';
}
return $component;
}
/**
* 컴포넌트의 actions에서 링크 변환 대상 액션을 추출합니다.
*
* click 이벤트의 navigate/openWindow 핸들러만 추출하며,
* sequence 내부에 중첩된 navigate/openWindow도 탐색합니다.
* replace: true인 navigate는 제외합니다 (필터/페이지네이션).
*
* @param array $component 컴포넌트 정의
* @return array|null ['handler' => string, 'params' => array] 또는 null
*/
private function extractLinkAction(array $component): ?array
{
$actions = $component['actions'] ?? [];
if (empty($actions) || ! is_array($actions)) {
return null;
}
foreach ($actions as $action) {
if (! is_array($action)) {
continue;
}
// click 이벤트만 처리 (type 미지정 = click, event 미지정 = click)
$eventType = $action['type'] ?? $action['event'] ?? 'click';
if ($eventType !== 'click') {
continue;
}
$handler = $action['handler'] ?? '';
$params = $action['params'] ?? [];
// 직접 navigate/openWindow
if ($handler === 'navigate' || $handler === 'openWindow') {
// replace: true인 navigate는 제외
if ($handler === 'navigate' && ! empty($params['replace'])) {
continue;
}
return ['handler' => $handler, 'params' => $params];
}
// sequence 내부 탐색
if ($handler === 'sequence') {
$seqActions = $action['actions'] ?? $params['actions'] ?? [];
foreach ($seqActions as $seqAction) {
if (! is_array($seqAction)) {
continue;
}
$seqHandler = $seqAction['handler'] ?? '';
$seqParams = $seqAction['params'] ?? [];
if ($seqHandler === 'navigate' || $seqHandler === 'openWindow') {
if ($seqHandler === 'navigate' && ! empty($seqParams['replace'])) {
continue;
}
return ['handler' => $seqHandler, 'params' => $seqParams];
}
}
}
}
return null;
}
/**
* navigate/openWindow params의 path를 해석하여 href URL을 생성합니다.
*
* _global 참조는 globalResolver 콜백으로 선치환하고,
* 나머지 표현식은 ExpressionEvaluator로 해석합니다.
* 미해석 {{}} 토큰이 남아있으면 null을 반환합니다 (graceful skip).
*
* @param array $params navigate/openWindow params
* @param array $context 데이터 컨텍스트
* @param ExpressionEvaluator $evaluator 표현식 평가기
* @return string|null 해석된 URL 또는 null (해석 불가 시)
*/
private function resolveNavigateHref(array $params, array $context, ExpressionEvaluator $evaluator): ?string
{
$pathExpr = $params['path'] ?? '';
if ($pathExpr === '') {
return null;
}
$pathExpr = (string) $pathExpr;
// _global 참조를 globalResolver로 선치환
if (str_contains($pathExpr, '_global')) {
if (! $this->globalResolver) {
// globalResolver 미설정 → _global 해석 불가 → skip
return null;
}
$resolveFailed = false;
$pathExpr = (string) preg_replace_callback(
'/\{\{([^}]*_global\.[^}]*)\}\}/',
function ($matches) use (&$resolveFailed) {
$resolved = ($this->globalResolver)($matches[1]);
if ($resolved === null) {
$resolveFailed = true;
}
return $resolved !== null ? $resolved : $matches[0];
},
$pathExpr
);
if ($resolveFailed) {
return null;
}
}
// ExpressionEvaluator로 나머지 표현식 해석
$href = $evaluator->evaluate($pathExpr, $context);
// 미해석 {{}} 토큰이 남아있으면 skip
if (str_contains($href, '{{') || str_contains($href, '}}')) {
return null;
}
// 빈 문자열이면 skip
if ($href === '') {
return null;
}
// query params 빌드
$query = $params['query'] ?? [];
if (! empty($query) && is_array($query)) {
$resolvedQuery = [];
foreach ($query as $key => $value) {
$resolvedValue = $evaluator->evaluate((string) $value, $context);
// 미해석 표현식 skip
if (str_contains($resolvedValue, '{{') || str_contains($resolvedValue, '}}')) {
return null;
}
if ($resolvedValue !== '') {
$resolvedQuery[$key] = $resolvedValue;
}
}
if (! empty($resolvedQuery)) {
$href .= '?'.http_build_query($resolvedQuery);
}
}
return $href;
}
/**
* 렌더링된 HTML에 navigate 링크를 적용합니다.
*
* 태그 유형에 따라 변환/래핑/주입 전략을 선택합니다:
* - button → <a>로 변환 (class 보존)
* - a + href 없음 → href 주입
* - a + href 있음 → 스킵
* - div/section 등 → <a>로 래핑
* - self-closing → <a>로 래핑
* - Fragment (빈 태그) → 스킵
*
* @param string $html 렌더링된 HTML
* @param string $tag HTML 태그명
* @param string $href 링크 URL
* @param string $handler 핸들러명 (navigate|openWindow)
* @param array $component 컴포넌트 정의
* @return string 링크가 적용된 HTML
*/
private function applyNavigateLink(string $html, string $tag, string $href, string $handler, array $component): string
{
if ($html === '' || $tag === '') {
return $html;
}
$targetAttr = $handler === 'openWindow' ? ' target="_blank"' : '';
$escapedHref = e($href);
// a 태그: href 주입 또는 스킵
if ($tag === 'a') {
// 이미 href가 있으면 스킵 (명시적 href 우선)
if (preg_match('/\bhref\s*=/', $html)) {
return $html;
}
// href 주입: <a → <a href="..."
return preg_replace(
'/^<a(\s|>)/',
'<a href="'.$escapedHref.'"'.$targetAttr.'$1',
$html,
1
);
}
// button → <a>로 변환 (class 보존, HTML 유효성)
if ($tag === 'button') {
$html = preg_replace('/^<button(\s?)/', '<a href="'.$escapedHref.'"'.$targetAttr.'$1', $html, 1);
$html = preg_replace('/<\/button>$/', '</a>', $html, 1);
// type 속성 제거 (<a>에는 type="button" 불필요)
$html = preg_replace('/\s*type="[^"]*"/', '', $html, 1);
return $html;
}
// self-closing 태그 → <a>로 래핑
if (in_array($tag, $this->selfClosing)) {
return '<a href="'.$escapedHref.'"'.$targetAttr.'>'.$html.'</a>';
}
// div, section, article 등 블록 요소 → <a>로 래핑
return '<a href="'.$escapedHref.'"'.$targetAttr.'>'.$html.'</a>';
}
}