fix(core,basic): 검색 봇 화면 렌더러 패리티 결손 일괄 수정
일반 화면(React)과 봇 화면(PHP)은 같은 레이아웃을 각각 렌더하는데 봇 화면 쪽 지원 범위가 뒤처져, 확장이 교체한 본문이 통째로 빈 채 나갔다 (공개 이슈 ). 같은 결손 계열을 전수 조사해 함께 닫는다. - 확장 주입 props 를 렌더 진입 시점에 해석해 자손 전체에 상속 → 게시글·답글 원글·댓글·페이지·상품 설명 5개 지점 복구 - 사용자 작성 HTML 정화기 신설 — isHtml 판정에 따라 이스케이프/정화. 미정화 상태에서 봇 경로로 저장형 스크립트가 실제 실행됐다 - 노드 키 패리티: conditions / type:iterator / responsive 매칭 규칙 / children 문자열 / text 우선순위 / $t:defer / 레이아웃 초기 상태 - 표현식: 단일 바인딩 판정 교체, ||·&& 를 값 반환(JS 시맨틱)으로, 비교식이 raw 경로에서 null 이 되던 문제 - 데이터소스: 엔드포인트 표현식 전면 해석 + 레이아웃 8곳 선언 보강 - 봇 페이지 캐시 1회 전량 무효화 업그레이드 스텝 지원 범위 SSoT 표를 문서에 고정하고, 한쪽만 고치는 것을 막는 검사 룰과 레이아웃 전수 순회 테스트로 잠갔다.
This commit is contained in:
1 parent
b505ac7ba7
commit
0dfb45951b
34 files changed
+5244
-172
No files matched your search
+575
-60
@@ -4,6 +4,25 @@ 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)
|
||||
*/
|
||||
@@ -61,6 +80,21 @@ class ComponentHtmlMapper
|
||||
*/
|
||||
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'];
|
||||
|
||||
/**
|
||||
* 템플릿 제공 컴포넌트 매핑을 설정합니다.
|
||||
*
|
||||
@@ -157,6 +191,15 @@ class ComponentHtmlMapper
|
||||
$html = '';
|
||||
|
||||
foreach ($components as $component) {
|
||||
// children 배열 내 문자열 = 텍스트 노드
|
||||
// React DynamicRenderer 는 이 문자열을 그대로 반환한다(표현식·번역 미해석).
|
||||
// SEO 가 여기서 표현식을 해석하면 반대 방향의 패리티 격차가 생기므로,
|
||||
// 이스케이프만 하고 리터럴로 출력한다.
|
||||
if (is_string($component)) {
|
||||
$html .= e($component);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! is_array($component)) {
|
||||
continue;
|
||||
}
|
||||
@@ -176,27 +219,24 @@ class ComponentHtmlMapper
|
||||
*/
|
||||
private function renderComponent(array $component, array $context, ExpressionEvaluator $evaluator): string
|
||||
{
|
||||
// SEO는 데스크톱 뷰로 렌더링 (검색 봇 = 데스크톱)
|
||||
// responsive.desktop 오버라이드를 기본 속성에 병합
|
||||
if (isset($component['responsive']['desktop'])) {
|
||||
$desktop = $component['responsive']['desktop'];
|
||||
if (isset($desktop['props'])) {
|
||||
$component['props'] = array_merge($component['props'] ?? [], $desktop['props']);
|
||||
}
|
||||
if (isset($desktop['if'])) {
|
||||
$component['if'] = $desktop['if'];
|
||||
}
|
||||
if (isset($desktop['text'])) {
|
||||
$component['text'] = $desktop['text'];
|
||||
}
|
||||
}
|
||||
// Extension Point 주입 컴포넌트의 props를 평가 컨텍스트에 주입
|
||||
// LayoutExtensionService가 호스트 props를 주입 컴포넌트 최상위 extensionPointProps로
|
||||
// 부착하므로, 여기서 해석해 컨텍스트에 넣어야 {{extensionPointProps.xxx}}가 평가된다.
|
||||
// $context는 값 전달이므로 이 노드의 서브트리에만 상속되고 형제로 새지 않는다
|
||||
// (React DynamicRenderer의 extendedDataContext 전파와 동형).
|
||||
// if/iteration 평가에도 이미 반영되어야 하므로 가장 먼저 처리한다.
|
||||
$this->injectExtensionPointContext($component, $context, $evaluator);
|
||||
|
||||
// 조건부 렌더링 (if)
|
||||
if (isset($component['if'])) {
|
||||
$condition = $evaluator->evaluate($component['if'], $context);
|
||||
if ($condition === '' || $condition === 'false' || $condition === '0') {
|
||||
return '';
|
||||
}
|
||||
// type: "iterator" → iteration 속성으로 변환 (React DynamicRenderer와 동일 매핑)
|
||||
$component = $this->normalizeIteratorNode($component);
|
||||
|
||||
// SEO는 데스크톱 뷰로 렌더링 (검색 봇 = 데스크톱)
|
||||
// 데스크톱 폭에 매칭되는 responsive 오버라이드를 기본 속성에 병합
|
||||
$component = $this->applyResponsiveOverrides($component);
|
||||
|
||||
// 조건부 렌더링 (if / condition / conditions)
|
||||
if (! $this->shouldRender($component, $context, $evaluator)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 반복 렌더링 (iteration)
|
||||
@@ -260,9 +300,375 @@ class ComponentHtmlMapper
|
||||
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 엔트리
|
||||
@@ -298,7 +704,7 @@ class ComponentHtmlMapper
|
||||
|
||||
// 텍스트 콘텐츠 fallback
|
||||
if ($innerHtml === '') {
|
||||
$innerHtml = $this->resolveTextContent($component, $props, $context, $evaluator);
|
||||
$innerHtml = $this->resolveTextContent($component, $props, $context, $evaluator, $tag);
|
||||
}
|
||||
|
||||
return "<{$tag}{$attrs}>{$innerHtml}</{$tag}>";
|
||||
@@ -322,17 +728,26 @@ class ComponentHtmlMapper
|
||||
return "<{$tag}{$attrs}>";
|
||||
}
|
||||
|
||||
$innerHtml = $this->renderChildren($component, $context, $evaluator);
|
||||
// 노드 레벨 text가 있으면 children보다 우선 (React DynamicRenderer와 동일 우선순위).
|
||||
// text 키가 있으면 해석 결과가 빈 문자열이어도 그것이 최종 값이다 — React 는
|
||||
// text !== undefined 인 순간 children 경로로 내려가지 않는다(DynamicRenderer.tsx:2591,2639).
|
||||
$nodeText = $this->resolveNodeText($component, $context, $evaluator);
|
||||
|
||||
if ($innerHtml === '') {
|
||||
$innerHtml = $this->resolveTextContent($component, $props, $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 처리
|
||||
// dangerouslySetInnerHTML 처리 — 일반 화면과 동일 강도로 정화 후 삽입
|
||||
if ($innerHtml === '' && isset($props['dangerouslySetInnerHTML'])) {
|
||||
$rawHtml = $evaluator->evaluate((string) $props['dangerouslySetInnerHTML'], $context);
|
||||
if ($rawHtml !== '') {
|
||||
$innerHtml = $rawHtml;
|
||||
$innerHtml = $this->htmlSanitizer()->sanitize($rawHtml);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,39 +802,37 @@ class ComponentHtmlMapper
|
||||
|
||||
$html = '';
|
||||
foreach ($data as $item) {
|
||||
if ($itemAttrs) {
|
||||
// 속성 기반 렌더링 (이미지 갤러리 등)
|
||||
$attrStr = '';
|
||||
foreach ($itemAttrs as $attrName => $fieldPattern) {
|
||||
$value = $this->resolveFieldPattern($fieldPattern, $item);
|
||||
if ($value !== '') {
|
||||
// src 속성의 상대 경로를 절대 경로로 변환
|
||||
if ($attrName === 'src' && ! str_starts_with($value, 'http')) {
|
||||
$value = url($value);
|
||||
}
|
||||
$attrStr .= " {$attrName}=\"".e($value).'"';
|
||||
$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}>";
|
||||
}
|
||||
} elseif ($itemContent !== null) {
|
||||
// 콘텐츠 기반 렌더링 (탭 리스트 등)
|
||||
$content = $this->resolveFieldPattern($itemContent, $item);
|
||||
if ($content !== '') {
|
||||
$evaluatedContent = e($evaluator->evaluate((string) $content, $context));
|
||||
$badge = '';
|
||||
if ($badgeField && isset($item[$badgeField])) {
|
||||
$evaluatedBadge = $evaluator->evaluate((string) $item[$badgeField], $context);
|
||||
if ($evaluatedBadge !== '' && $evaluatedBadge !== '0') {
|
||||
$badge = ' <span>('.e($evaluatedBadge).')</span>';
|
||||
}
|
||||
}
|
||||
$html .= "<{$itemTag}>".$evaluatedContent.$badge."</{$itemTag}>";
|
||||
}
|
||||
} else {
|
||||
// 단순 아이템 (문자열)
|
||||
$value = is_string($item) ? e($item) : '';
|
||||
@@ -430,6 +843,34 @@ class ComponentHtmlMapper
|
||||
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로 치환
|
||||
*
|
||||
@@ -492,7 +933,14 @@ class ComponentHtmlMapper
|
||||
}
|
||||
|
||||
/**
|
||||
* raw 타입: 원본 HTML/텍스트를 그대로 출력
|
||||
* raw 타입: 콘텐츠를 `isHtml` 판정에 따라 평문 또는 정화된 HTML 로 출력
|
||||
*
|
||||
* 일반 화면의 `HtmlContent` 컴포지트와 동일한 규칙입니다.
|
||||
* - `isHtml=false` → 평문 (전체 이스케이프)
|
||||
* - `isHtml=true`(기본) → 정화 후 HTML 로 출력
|
||||
*
|
||||
* 정화·이스케이프를 생략하면 봇 화면에서만 사용자 작성 `<script>` 가 살아남아,
|
||||
* 주소에 봇 파라미터를 붙이는 것만으로 실행됩니다.
|
||||
*
|
||||
* @param array $modeConfig render_modes 엔트리
|
||||
* @param array $configEntry component_map 엔트리
|
||||
@@ -515,7 +963,28 @@ class ComponentHtmlMapper
|
||||
return '';
|
||||
}
|
||||
|
||||
return $evaluator->evaluate((string) $contentExpr, $context);
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1077,18 +1546,22 @@ class ComponentHtmlMapper
|
||||
* @param ExpressionEvaluator $evaluator 표현식 평가기
|
||||
* @return string 텍스트 콘텐츠
|
||||
*/
|
||||
private function resolveTextContent(array $component, array $props, array $context, ExpressionEvaluator $evaluator): string
|
||||
private function resolveTextContent(array $component, array $props, array $context, ExpressionEvaluator $evaluator, string $tag = ''): string
|
||||
{
|
||||
// 1. 컴포넌트 레벨 text 속성 (최우선)
|
||||
if (isset($component['text'])) {
|
||||
$text = $evaluator->evaluate((string) $component['text'], $context);
|
||||
if ($text !== '') {
|
||||
return e($text);
|
||||
}
|
||||
// 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 !== '') {
|
||||
@@ -1100,6 +1573,40 @@ class ComponentHtmlMapper
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 반복 렌더링을 처리합니다.
|
||||
*
|
||||
@@ -1134,6 +1641,14 @@ class ComponentHtmlMapper
|
||||
$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, [
|
||||
|
||||
Reference in new issue
Block a user