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:
HeuJung
2026-07-27 10:03:06 +09:00
parent b505ac7ba7
commit 0dfb45951b
34 changed files with 5244 additions and 172 deletions
+28
View File
@@ -8,6 +8,8 @@
### Added
#### 사이트맵
- 게시글·상품이 수백만 건으로 늘어나도 사이트맵을 안정적으로 생성하도록 개선했습니다. 예전에는 사이트맵을 만들 때 전체 데이터를 한꺼번에 메모리에 올리다가 데이터가 많은 사이트에서 생성이 실패했지만, 이제 나눠 읽어 처리하므로 메모리 부족 없이 완성됩니다. (#79 @jiwonpapa 님께서 제보해주셨습니다.)
- 사이트맵을 검색엔진 규격(파일당 5만 URL / 50MB)에 맞춰 여러 파일로 자동 분할하고, 이를 묶는 색인 파일을 함께 만듭니다. 한 파일에 담을 URL 수는 SEO 설정에서 조정할 수 있습니다.
- 게시글·상품·페이지를 공개/비공개로 바꾸거나 삭제하면 사이트맵에서 해당 항목만 자동으로 더하거나 빼도록 바꿨습니다. 전체를 다시 만들지 않고 바뀐 부분만 반영하므로 항상 최신 상태가 빠르게 유지됩니다.
@@ -17,6 +19,8 @@
- 사이트맵의 갱신 빈도(changefreq) 값을 검색엔진 표준 어휘(always/hourly/daily/weekly/monthly/yearly/never)로 맞춰, 규격에 없는 값은 사이트맵에 출력되지 않도록 했습니다.
- 관리자가 사이트맵 전체 재생성을 실행하면, 완료되거나 실패했을 때 실행한 관리자에게 알림을 보냅니다. 재생성이 오래 걸려 화면을 떠나 있어도 결과를 놓치지 않습니다. (기본은 앱 내 알림이며 관리자 알림 설정에서 이메일도 켤 수 있습니다. 매일 자동 생성이나 글·상품 변경에 따른 재생성은 알림을 보내지 않습니다.)
#### 설치·서버 환경
- 설치할 때 `root` 같은 데이터베이스 최고 권한 계정을 입력하면 설치가 진행되지 않고, 왜 사용할 수 없는지와 어떻게 해야 하는지를 함께 안내합니다. 최고 권한 계정 정보가 유출되면 데이터베이스 전체가 위험해지기 때문이며, 사이트 전용 데이터베이스 계정을 새로 만들어 필요한 권한만 부여해 입력하시면 됩니다. 읽기 전용 데이터베이스를 따로 쓰는 경우에도 동일하게 적용됩니다.
- 일부 서버 설정에서 모든 페이지가 백지로 나오던 문제에 대응했습니다. 서버에 `.js`·`.css`·`.json` 주소를 가로채는 정적 파일 최적화 설정(aaPanel·CyberPanel·Plesk 기본 템플릿 등)이 있으면 G7 의 일부 주소가 PHP 에 전달되지 못해 화면이 뜨지 않았습니다. 이제 G7 이 확장자 없는 주소도 함께 제공하며, 화면이 뜨지 않으면 브라우저가 이를 감지해 자동으로 전환하므로 서버 설정을 바꾸지 않아도 사이트가 표시됩니다. (sir.kr 커뮤니티의 hang 님께서 제보해주셨습니다.)
- 자산 파일 주소 방식을 설치 마법사가 자동으로 감지해 반영하고, 설치 후에도 관리자 > 환경설정 > 일반에서 바꿀 수 있습니다. 설치 2단계 서버 요구사항 화면에서 감지 결과를 미리 확인할 수 있고, 3단계에서 직접 고를 수도 있습니다. 화면이 아예 뜨지 않는 상황을 위해 `php artisan g7:asset-url-mode` 명령으로도 확인·전환할 수 있습니다. 검색엔진 봇은 자바스크립트를 실행하지 않으므로, 이 설정을 확정해 두면 봇에게도 올바른 주소가 전달됩니다.
@@ -28,21 +32,45 @@
### Fixed
#### 검색엔진 봇 화면
- 검색엔진 봇에게만 게시글·답글의 원글·댓글·페이지·상품 설명의 본문이 통째로 빈 채 전달되던 문제를 수정했습니다. 위지윅 편집기 플러그인이 켜져 있으면 본문 영역이 플러그인 화면으로 교체되는데, 봇용 화면을 만드는 쪽이 그 교체 규칙을 몰라 내용 없는 껍데기만 만들었습니다. 미리보기용 요약(메타 태그)에는 본문이 들어가 있어 겉으로는 정상으로 보였습니다. 이제 일반 화면과 봇용 화면이 같은 본문을 출력합니다. 업그레이드 시 이미 만들어져 있던 봇용 화면은 한 번 비워져 새로 만들어집니다. (#86 @koojunho 님께서 제보해주셨습니다.)
- 봇용 화면이 자료를 불러오는 주소에 검색어 같은 값을 끼워 넣어 둔 경우, 그 값이 해석되지 않아 목록이 통째로 비어 있던 문제를 수정했습니다. 예전에는 주소 안의 글 번호만 해석했고 검색어·화면 초기값은 그대로 남아 조회에 실패했습니다.
- 봇용 화면이 일반 화면과 다르게 해석하던 문법들을 일반 화면과 동일하게 맞췄습니다. 여러 조건을 묶은 표시 조건, 반복 목록의 다른 표기 방식, 글자만 넣은 조각, 데스크톱 전용으로 지정한 하위 구성·반복 목록, 지연 표기한 번역, 화면 시작 시 정해지는 초기값이 해당됩니다. 조건이 거짓인데도 내용이 노출되거나, 화면에는 보이는 내용이 봇에게만 사라지던 차이가 없어집니다.
- 봇용 화면에서 값 대신 `true`/`false` 가 출력되던 문제를 수정했습니다. 두 값 중 채워진 쪽을 쓰는 표기(`||`)가 값이 아니라 참·거짓으로 계산되어, 검색 화면 주소에 검색어 대신 `true` 가 들어가는 등의 증상이 있었습니다. 화면에 넣은 값이 참·거짓이거나 비어 있을 때도 봇용 화면에만 `false` 같은 글자가 그대로 노출되던 문제를 함께 수정했습니다.
- 검색엔진 봇용 화면에 넣는 사용자 작성 본문을 일반 화면과 같은 기준으로 정리하도록 수정했습니다. 예전에는 봇용 화면에만 본문이 아무 검사 없이 들어가, 글에 넣어 둔 스크립트나 외부 프레임이 봇용 주소로 접속했을 때 실제로 실행될 수 있었습니다. 또 서식 없이 글자로만 저장한 글의 태그가 봇용 화면에서만 서식으로 해석되어 두 화면의 본문이 달라 보였습니다. 이제 서식 없는 글은 글자 그대로, 서식 있는 글은 위험한 요소를 걷어낸 뒤 표시되며, 일반 화면과 결과가 같습니다. 게시글·답글·페이지·상품 설명 모두에 적용됩니다.
- 봇용 화면의 선택 상자(정렬·분류 등)에 항목 이름 대신 내부 식별자가 글자로 노출되던 문제를 수정했습니다. 이제 일반 화면과 같이 선택 항목의 이름이 표시됩니다.
- 봇용 화면에만 일반 화면에 없는 내용이 나타나거나, 반대로 있어야 할 내용이 사라지던 경우를 정리했습니다. 값이 비어 있을 때 대신 다른 내용을 채워 넣던 동작, 화면 폭 조건이 여러 개 겹칠 때 적용 순서가 달라지던 동작, 알아볼 수 없는 형태의 표시 조건을 봇용 화면에서만 숨기던 동작이 해당됩니다.
#### 화면 표시·목록 상태
- 목록 표의 각 칸에 넣은 날짜·숫자 서식이 적용되지 않아 값이 비어 보이던 문제를 수정했습니다. 같은 서식을 일반 화면에서 쓰면 정상이었지만 목록 표의 칸 안에서는 값이 사라지거나(날짜 서식) 서식이 빠진 원래 값이 그대로 나왔습니다(숫자·대문자 서식). 목록 표와 카드 목록, 펼침 영역 등 반복해서 그려지는 모든 자리에서 서식이 정상 적용됩니다. (#87 @glitter-gim 님께서 제보해주셨습니다.)
- 목록 화면에서 글이나 항목을 열어 보고 돌아올 때 보고 있던 페이지·검색어·필터가 사라지던 문제를 화면 엔진 차원에서 함께 수정했습니다. '현재 주소의 조건을 유지한다'고 지정해도 덧붙일 값이 없으면 유지가 아예 동작하지 않아 조건이 통째로 사라지던 동작을 바로잡았습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
#### 확장·데이터베이스 안정성
- 데이터베이스 연결이 일시적으로 끊기거나 확장 설치가 중간에 실패한 직후 확장 목록을 다시 만들면, 설치된 모듈·플러그인 정보가 통째로 비워지면서 게시판·쇼핑몰 등 모든 확장 화면이 500 오류를 내던 문제를 수정했습니다. 이제 확장 폴더가 그대로 남아 있는데 목록만 비어 있으면 기존 정보를 지우지 않고 보존하며, 왜 갱신하지 않았는지 로그에 남깁니다.
- 확장 목록이 손상된 상태에서 복구 명령을 실행하면 목록은 되살아나지만 확장 기능 연결이 비어 있는 채로 남아, 명령을 두 번 실행해야 완전히 복구되던 문제를 수정했습니다. 이제 한 번 실행으로 모두 복구됩니다.
- 데이터베이스 계정 설정이 잘못되었을 때 아무 설명 없이 빈 화면만 뜨던 문제를 수정했습니다. 이전에는 최고 권한 계정을 쓰거나 사용자명이 비어 있으면 사이트 기능이 통째로 동작을 멈추면서도 화면에는 아무 안내가 없어 원인을 알 수 없었습니다. 이제 무엇이 잘못되었고 어떻게 고쳐야 하는지 설명하는 전용 안내 화면이 나타납니다. 서버 관리 명령은 평소대로 실행되므로 이 상태에서도 설정을 고칠 수 있습니다.
#### 설정 저장·입력 검증
- SEO 설정의 캐시 항목(SEO 캐시 사용·유지 시간, Sitemap 캐시 유지 시간)이 저장해도 적용되지 않던 문제를 수정했습니다. 이제 고급 설정의 캐시 값이 기준이 되고, SEO 설정에서 값을 지정하면 그 값이 우선하며, 비워두면 고급 설정을 따릅니다. 업그레이드 시 기존에 기본값과 다르게 지정해 두셨던 값은 그대로 살아나 이제부터 실제로 적용되므로 SEO 캐시 동작이 달라질 수 있습니다. 기본값 그대로였던 항목은 "지정 안 함"으로 정리되어 동작이 바뀌지 않습니다.
- 한글로 쓴 설명이 글자 수 제한에 걸리지 않는데도 "너무 깁니다" 로 거부되던 문제를 수정했습니다. 글자 수를 바이트 단위로 세는 바람에 한글 한 글자가 세 글자로 계산되어, 500자 제한에서 167자만 넘어도 저장이 막혔습니다. 역할·권한·모듈·플러그인·템플릿 설명에 모두 해당합니다.
- 사용하던 언어팩을 끈 뒤 기존에 작성해 둔 내용을 수정하려 하면 저장이 통째로 막히던 문제를 수정했습니다. 꺼진 언어로 입력해 둔 번역이 남아 있으면 "지원하지 않는 언어" 로 거부되었습니다. 이제 그 번역은 그대로 보존한 채 수정할 수 있고, 언어팩을 다시 켜면 번역도 함께 되살아납니다.
- 알림 템플릿에서 제목을 비워 둘 수 있는데도 저장할 때 제목을 요구하던 문제를 수정했습니다. 제목 개념이 없는 발송 수단에서도 템플릿을 저장할 수 있습니다.
- 알림 정의에 존재하지 않는 발송 수단 이름이 그대로 저장되던 문제를 수정했습니다. 이제 사용할 수 없는 수단은 저장 시 안내와 함께 막히며, 플러그인이 추가한 수단은 정상적으로 선택할 수 있습니다. 수단을 제공하던 플러그인을 껐더라도 이미 저장된 설정은 계속 수정할 수 있습니다.
- 등록한 일정이 하나라도 있으면 일정 관리 목록이 열리지 않고 "총 0개"로만 표시되던 문제를 수정했습니다. 실행 이력 화면도 동일한 원인으로 열리지 않았으며, 이제 두 화면 모두 정상적으로 목록을 보여줍니다.
#### 안내 문구·주소 처리
- 일부 안내가 실제 문장 대신 내부 식별자(`messages.auth.unauthenticated` 같은 문자열)로 표시되던 문제를 수정했습니다. 로그인하지 않았거나 역할 권한이 없어 요청이 거절될 때, 메뉴에 역할을 지정할 때, 확장 식별자를 잘못 입력했을 때, 활동 로그를 기간·유형으로 검색할 때가 해당됩니다.
- 플러그인을 GitHub 주소나 ZIP 파일로 설치할 때의 입력 안내가 내부 식별자로 표시되던 문제를 수정했습니다. 주소 형식이 잘못됐거나, ZIP이 아닌 파일을 올렸거나, 파일 용량을 초과했을 때 정상 문장으로 안내합니다.
- 레이아웃 편집기 화면이 정상 동작하는데도 서버가 "페이지 없음"(404)으로 응답하던 문제를 수정했습니다. 화면은 그대로 보였지만 브라우저 캐싱과 서버 접속 기록에서 없는 페이지로 취급되던 상태입니다.
- 주소 끝에 슬래시가 붙은 형태(예: `/admin/`)로 접속하면 화면이 "페이지 없음"(404)으로 뜨던 문제를 수정했습니다. 특히 로그인이 풀린 상태에서 이렇게 접속하면 로그인 화면으로 넘어가지 않고 404가 나타났습니다. 이제 끝 슬래시가 있어도 정상적으로 같은 페이지로 연결됩니다.
#### 메뉴·플러그인 설정 무결성
- 메뉴를 수정할 때 자기 자신의 하위 메뉴 아래로 옮길 수 있던 문제를 수정했습니다. 이렇게 옮기면 메뉴 트리가 끊어져 관리자 화면에서 되돌릴 수 없었습니다. 메뉴 순서 변경 화면과 동일한 기준이 적용됩니다.
- 플러그인 설정을 저장할 때, 그 플러그인이 제공하지 않는 항목까지 설정 파일에 함께 기록되던 문제를 수정했습니다. 이제 플러그인이 실제로 제공하는 설정 항목만 저장됩니다.
+575 -60
View File
@@ -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, [
+25 -13
View File
@@ -15,12 +15,18 @@ class DataSourceResolver
* @param array $routeParams 라우트 파라미터 (예: ['id' => '123', 'slug' => 'clothes'])
* @param string $locale 로케일
* @param array $queryParams URL 쿼리 파라미터 (예: ['page' => '2', 'sort' => 'latest'])
* @param array $extraContext 엔드포인트/params 표현식 평가에 사용할 추가 컨텍스트 (_global 등)
* @return array data_source ID → 응답 데이터 매핑
*/
public function resolve(array $dataSources, array $seoDataSourceIds, array $routeParams, string $locale, array $queryParams = []): array
public function resolve(array $dataSources, array $seoDataSourceIds, array $routeParams, string $locale, array $queryParams = [], array $extraContext = []): array
{
$context = [];
$evalContext = array_merge($extraContext, [
'route' => array_merge($extraContext['route'] ?? [], $routeParams),
'query' => $queryParams,
]);
foreach ($dataSources as $ds) {
$dsId = $ds['id'] ?? '';
@@ -34,11 +40,11 @@ class DataSourceResolver
continue;
}
// 라우트 파라미터 치환 (예: {{route.id}} → 123)
$endpoint = $this->resolveEndpoint($endpoint, $routeParams);
// 엔드포인트 표현식 치환 ({{route.id}}, {{query.q}}, {{_global.xxx}})
$endpoint = $this->resolveEndpoint($endpoint, $evalContext);
// params 해석 ({{query.xxx}}, {{route.xxx}} 표현식 치환)
$resolvedParams = $this->resolveParams($ds['params'] ?? [], $routeParams, $queryParams);
$resolvedParams = $this->resolveParams($ds['params'] ?? [], $routeParams, $queryParams, $extraContext);
// API 호출
$data = $this->callApi($endpoint, $ds, $locale, $resolvedParams);
@@ -51,16 +57,21 @@ class DataSourceResolver
/**
* 엔드포인트의 표현식을 치환합니다.
*
* `{{route.xxx}}` 뿐 아니라 `{{query.xxx}}` / `{{_global.xxx}}` 등 컨텍스트 표현식을
* 모두 해석합니다 — 검색 결과처럼 엔드포인트 문자열 안에서 쿼리·전역 상태를 보간하는
* 데이터소스가 있기 때문입니다. 미해석 시 표현식이 URL에 그대로 남아 호출이 실패합니다.
*
* @param string $endpoint 원본 엔드포인트
* @param array $routeParams 라우트 파라미터
* @param array $evalContext 평가 컨텍스트 (route/query/_global 등)
* @return string 치환된 엔드포인트
*/
private function resolveEndpoint(string $endpoint, array $routeParams): string
private function resolveEndpoint(string $endpoint, array $evalContext): string
{
// {{route.xxx}} 및 {{route?.xxx}} 패턴 치환 (optional chaining 포함)
return (string) preg_replace_callback('/\{\{route\??\.(\w+)\}\}/', function ($matches) use ($routeParams) {
return $routeParams[$matches[1]] ?? '';
}, $endpoint);
if (! str_contains($endpoint, '{{')) {
return $endpoint;
}
return app(ExpressionEvaluator::class)->evaluate($endpoint, $evalContext);
}
/**
@@ -72,19 +83,20 @@ class DataSourceResolver
* @param array $params 원본 params 정의
* @param array $routeParams 라우트 파라미터
* @param array $queryParams 쿼리 파라미터
* @param array $extraContext 추가 평가 컨텍스트 (_global 등)
* @return array 해석된 params (키 → 값)
*/
private function resolveParams(array $params, array $routeParams, array $queryParams): array
private function resolveParams(array $params, array $routeParams, array $queryParams, array $extraContext = []): array
{
if (empty($params)) {
return [];
}
$resolved = [];
$evalContext = [
$evalContext = array_merge($extraContext, [
'route' => $routeParams,
'query' => $queryParams,
];
]);
$evaluator = app(ExpressionEvaluator::class);
+130 -18
View File
@@ -83,8 +83,9 @@ class ExpressionEvaluator
public function evaluateRaw(string $expression, array $context): mixed
{
// 단일 {{expr}} 패턴인 경우 원본 값 반환
if (preg_match('/^\{\{(.+?)\}\}$/', trim($expression), $matches)) {
$expr = trim($matches[1]);
$trimmed = trim($expression);
if ($this->isSingleBinding($trimmed)) {
$expr = trim(substr($trimmed, 2, -2));
// 파이프 표현식: 원본 값을 파이프로 변환 후 반환
if (PipeRegistry::hasPipes($expr)) {
@@ -101,6 +102,14 @@ class ExpressionEvaluator
return $value;
}
// 비교·논리 연산은 참/거짓 값으로 돌려준다.
// `resolveToValue()` 는 경로/리터럴 해석기라 `(a ?? 'text') === 'html'` 같은
// 비교식을 null 로 되돌린다. 그 null 이 확장 주입 props 의 기본값으로 흘러들면
// (예: `isHtml ?? true`) 레이아웃이 선언한 판정이 통째로 뒤집힌다.
if ($this->isBooleanExpression($expr)) {
return $this->coerceBooleanString($this->evaluate($trimmed, $context));
}
// 연산자/함수 호출/리터럴이 포함된 표현식은 resolveToValue로 처리
if (str_contains($expr, '??') || str_contains($expr, '?') || preg_match('/[\w$]+\s*\(/', $expr)
|| str_starts_with($expr, '{') || str_contains($expr, '...')) {
@@ -114,6 +123,105 @@ class ExpressionEvaluator
return $this->evaluate($expression, $context);
}
/**
* 표현식이 비교·논리 연산인지 판정합니다.
*
* 삼항(`? :`)과 화살표 함수(`=>`)는 결과가 참/거짓이 아니므로 제외합니다
* (`??` 와 `?.` 은 삼항이 아니라 제외 대상에서 먼저 걷어냅니다).
*
* @param string $expr `{{}}` 를 벗겨낸 표현식
* @return bool 비교·논리 연산이면 true
*/
private function isBooleanExpression(string $expr): bool
{
$normalized = str_replace(['??', '?.'], '', $expr);
if (str_contains($normalized, '=>') || str_contains($normalized, '?')) {
return false;
}
if (str_starts_with(trim($expr), '!')) {
return true;
}
return preg_match('/(===|!==|==|!=|>=|<=|>|<|&&|\|\|)/', $normalized) === 1;
}
/**
* 참/거짓 문자열을 bool 로 되돌립니다.
*
* `||` 처럼 피연산자 값을 그대로 돌려주는 연산도 있으므로, 참/거짓 문자열이
* 아니면 원본 문자열을 유지합니다.
*
* @param string $value 평가 결과 문자열
* @return mixed bool 또는 원본 문자열
*/
private function coerceBooleanString(string $value): mixed
{
return match ($value) {
'true' => true,
'false' => false,
default => $value,
};
}
/**
* 평가 결과 문자열의 truthy 여부를 판정합니다.
*
* 논리 연산자(`||`, `&&`)의 단락 평가 기준입니다.
*
* @param string $value 평가 결과 문자열
* @return bool truthy 이면 true
*/
private function isTruthyValue(string $value): bool
{
return $value !== '' && $value !== 'false' && $value !== '0';
}
/**
* 표현식이 단일 {{expr}} 바인딩 하나로만 구성되는지 판정합니다.
*
* `{{a}} - {{b}}` 처럼 바인딩이 둘 이상이면 false 를 반환해야 합니다.
* 정규식 `/^\{\{(.+?)\}\}$/` 는 양끝 앵커 때문에 이런 다중 바인딩도 매치되어
* 내부가 `a}} - {{b` 로 잘못 해석되므로, 중괄호 깊이를 직접 추적합니다.
* 객체 리터럴(`{{ {outer: {inner: 'v'}} }}`)처럼 내부에 `}}` 가 등장하는
* 경우까지 정확히 구분하기 위함입니다.
*
* @param string $trimmed 앞뒤 공백이 제거된 표현식
* @return bool 단일 바인딩이면 true
*/
private function isSingleBinding(string $trimmed): bool
{
$length = strlen($trimmed);
if ($length < 5 || ! str_starts_with($trimmed, '{{') || ! str_ends_with($trimmed, '}}')) {
return false;
}
// 여는 `{{` 를 깊이 2 로 두고 스캔 — 마지막 문자 이전에 깊이가 0 이 되면 바인딩이 둘 이상
$depth = 2;
for ($i = 2; $i < $length; $i++) {
$char = $trimmed[$i];
if ($char === '{') {
$depth++;
continue;
}
if ($char !== '}') {
continue;
}
$depth--;
if ($depth === 0) {
return $i === $length - 1;
}
}
return false;
}
/**
* ?? 연산자를 포함한 표현식을 원본 값으로 평가합니다.
*
@@ -156,6 +264,7 @@ class ExpressionEvaluator
// 표현식으로 재귀 해석
return $this->resolvePath($fallback, $context);
}
/**
* SEO 메타 표현식을 컨텍스트와 함께 평가하여 최종 문자열을 반환합니다.
*
@@ -196,7 +305,7 @@ class ExpressionEvaluator
// 인라인 $t:key 토큰 해석 ({{}} 외부 텍스트 내 $t:key|param=value 패턴)
if (str_contains($result, '$t:')) {
$result = preg_replace_callback('/\$t:([\w.\-]+(?:\|[\w.\-]+=[\w.\-{}]+)*)/', function ($matches) use ($context) {
$result = preg_replace_callback('/\$t:(?:defer:)?([\w.\-]+(?:\|[\w.\-]+=[\w.\-{}]+)*)/', function ($matches) use ($context) {
return $this->resolveTranslation('$t:'.$matches[1], $context);
}, $result);
}
@@ -213,7 +322,11 @@ class ExpressionEvaluator
/**
* $t: 번역 키를 해석합니다.
*
* @param string $expression 번역 표현식 ($t:key 또는 $t:key|param=value)
* `$t:defer:key` 형태(프론트엔드에서 렌더 시점까지 해석을 미루는 표기)도
* 동일한 키로 취급합니다 — SEO는 서버에서 한 번에 해석하므로 지연 개념이
* 없고, `defer:` 를 키에 포함시키면 사전 조회가 실패해 빈 문자열이 됩니다.
*
* @param string $expression 번역 표현식 ($t:key, $t:defer:key 또는 $t:key|param=value)
* @param array $context 데이터 컨텍스트 (파라미터 값의 {{}} 표현식 해석용)
* @return string 번역된 문자열
*/
@@ -221,6 +334,10 @@ class ExpressionEvaluator
{
$key = substr($expression, 3);
if (str_starts_with($key, 'defer:')) {
$key = substr($key, 6);
}
// 파라미터 분리: $t:key|param1=value1|param2=value2
$params = [];
if (str_contains($key, '|')) {
@@ -386,36 +503,31 @@ class ExpressionEvaluator
}
// 2. 논리 OR: a || b
// JS 시맨틱 — boolean이 아니라 "첫 truthy 피연산자"를 반환한다.
// ({{query?.q || ''}} 같은 표현식이 값 대신 'true'가 되는 것을 방지)
if (str_contains($expr, '||')) {
$parts = $this->splitOutsideBrackets($expr, '||');
if (count($parts) === 2) {
$leftVal = $this->evaluateExpression(trim($parts[0]), $context);
if ($leftVal !== '' && $leftVal !== 'false' && $leftVal !== '0') {
return 'true';
}
$rightVal = $this->evaluateExpression(trim($parts[1]), $context);
if ($rightVal !== '' && $rightVal !== 'false' && $rightVal !== '0') {
return 'true';
if ($this->isTruthyValue($leftVal)) {
return $leftVal;
}
return 'false';
return $this->evaluateExpression(trim($parts[1]), $context);
}
}
// 3. 논리 AND: a && b
// JS 시맨틱 — 좌변이 falsy면 좌변을, 아니면 우변을 반환한다.
if (str_contains($expr, '&&')) {
$parts = $this->splitOutsideBrackets($expr, '&&');
if (count($parts) === 2) {
$leftVal = $this->evaluateExpression(trim($parts[0]), $context);
if ($leftVal === '' || $leftVal === 'false' || $leftVal === '0') {
return 'false';
}
$rightVal = $this->evaluateExpression(trim($parts[1]), $context);
if ($rightVal === '' || $rightVal === 'false' || $rightVal === '0') {
return 'false';
if (! $this->isTruthyValue($leftVal)) {
return $leftVal;
}
return 'true';
return $this->evaluateExpression(trim($parts[1]), $context);
}
}
+300
View File
@@ -0,0 +1,300 @@
<?php
namespace App\Seo;
use Illuminate\Support\Facades\Log;
/**
* 봇 화면에 삽입되는 사용자 작성 HTML 을 정화합니다.
*
* 일반 화면(React)은 `HtmlContent` 컴포지트가 DOMPurify 로 정화한 결과를 그리므로,
* 봇 화면도 **같은 강도**로 정화해야 두 화면의 출력이 일치합니다. 정화하지 않으면
* 봇 화면에서만 `<script>` / `onerror` 등이 살아 있어, 주소에 봇 파라미터를 붙이는
* 것만으로 저장된 스크립트가 실행됩니다.
*
* 차단 목록은 아래 파일의 DOMPurify 설정을 옮긴 것입니다 — 한쪽만 고치면 두 화면의
* 정화 강도가 어긋나므로 **함께 갱신**해야 합니다.
*
* @see templates/_bundled/sirsoft-basic/src/components/composite/HtmlContent.tsx
* @see docs/backend/seo-system.md "봇 화면의 HTML 정화"
*/
class HtmlSanitizer
{
/**
* 정화 대상 HTML 을 감쌀 래퍼 요소 id.
*
* DOM 파서가 요구하는 문서 골격을 씌운 뒤 이 요소의 자식만 되돌려 받습니다.
*/
private const ROOT_ID = 'g7-seo-sanitize-root';
/**
* 요소와 내용을 통째로 제거할 태그 목록.
*
* 스크립트 실행 · 외부 콘텐츠 삽입 · 문서 구조 조작처럼 내부 텍스트를 남기는 것조차
* 위험하거나 무의미한 태그입니다. (DOMPurify 의 `FORBID_CONTENTS` 계열 동작)
*/
private const DROP_WITH_CONTENT = [
'script', 'noscript', 'style', 'template',
'iframe', 'frame', 'frameset',
'title', 'head',
'svg', 'math',
'plaintext', 'xmp', 'listing',
'noframes', 'noembed',
'audio', 'video',
];
/**
* 요소만 벗기고 내부 텍스트는 남길 태그 목록.
*
* 본문 텍스트를 품고 있을 수 있어 통째로 버리면 콘텐츠가 사라지는 태그입니다.
* (DOMPurify 의 `KEEP_CONTENT: true` 기본 동작)
*/
private const UNWRAP_KEEP_CONTENT = [
'object', 'embed', 'applet', 'portal',
'link', 'meta', 'base',
'form', 'input', 'textarea', 'select', 'option', 'button',
'body', 'html',
'source', 'track', 'canvas',
'details', 'dialog',
'marquee', 'slot',
];
/**
* 제거할 속성 목록 (`on*` 이벤트 핸들러는 전부 별도로 제거).
*/
private const FORBID_ATTRS = [
'formaction', 'xlink:href', 'action', 'srcdoc', 'ping',
];
/**
* URL 값을 담는 속성 목록 (스킴 검사 대상).
*/
private const URL_ATTRS = ['href', 'src', 'srcset', 'poster', 'background', 'cite', 'longdesc'];
/**
* URL 속성에 허용하는 스킴.
*/
private const ALLOWED_SCHEMES = ['http', 'https', 'mailto', 'tel', 'sms', 'ftp', 'cid', 'xmpp', 'callto'];
/**
* `data:` URL 중 허용하는 형태 (이미지 한정 — svg 는 스크립트 실행 벡터라 제외).
*/
private const ALLOWED_DATA_URL = '/^data:image\/(png|jpe?g|gif|webp|avif|bmp);/i';
/**
* 사용자 작성 HTML 을 정화해 반환합니다.
*
* 파싱에 실패하면 정화되지 않은 HTML 을 내보내지 않고 전체를 이스케이프합니다
* (실패 시 안전한 쪽으로 기웁니다).
*
* @param string $html 정화할 HTML
* @return string 정화된 HTML
*/
public function sanitize(string $html): string
{
if (trim($html) === '') {
return '';
}
if (! class_exists(\DOMDocument::class)) {
return e($html);
}
$document = new \DOMDocument('1.0', 'UTF-8');
$previous = libxml_use_internal_errors(true);
try {
$loaded = $document->loadHTML(
'<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><div id="'
.self::ROOT_ID.'">'.$html.'</div></body></html>',
LIBXML_NOERROR | LIBXML_NOWARNING
);
if (! $loaded) {
return e($html);
}
$root = $document->getElementById(self::ROOT_ID);
if (! $root instanceof \DOMElement) {
return e($html);
}
$this->cleanNode($root);
return $this->innerHtml($document, $root);
} catch (\Throwable $e) {
Log::warning('봇 화면 HTML 정화 실패 — 전체 이스케이프로 대체', [
'error' => $e->getMessage(),
]);
return e($html);
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
}
/**
* 노드와 그 자손을 재귀적으로 정화합니다.
*
* @param \DOMNode $node 대상 노드
*/
private function cleanNode(\DOMNode $node): void
{
// 자식 목록을 먼저 복사한다 — 순회 중 DOM 을 변경하므로 라이브 NodeList 를 쓰면 항목을 건너뛴다.
$children = [];
foreach ($node->childNodes as $child) {
$children[] = $child;
}
foreach ($children as $child) {
if ($child instanceof \DOMElement) {
$this->cleanElement($child);
continue;
}
// 주석은 봇 화면에 남길 이유가 없고 조건부 주석 등 파서 혼란 요인이 된다.
if ($child instanceof \DOMComment) {
$child->parentNode?->removeChild($child);
}
}
}
/**
* 단일 요소를 정화합니다 (제거 / 언랩 / 속성 정리).
*
* @param \DOMElement $element 대상 요소
*/
private function cleanElement(\DOMElement $element): void
{
$tag = strtolower($element->tagName);
if (in_array($tag, self::DROP_WITH_CONTENT, true)) {
$element->parentNode?->removeChild($element);
return;
}
if (in_array($tag, self::UNWRAP_KEEP_CONTENT, true)) {
$this->cleanNode($element);
$this->unwrap($element);
return;
}
$this->cleanAttributes($element);
$this->cleanNode($element);
}
/**
* 요소를 제거하고 자식 노드를 부모의 같은 자리에 올립니다.
*
* @param \DOMElement $element 대상 요소
*/
private function unwrap(\DOMElement $element): void
{
$parent = $element->parentNode;
if ($parent === null) {
return;
}
while ($element->firstChild !== null) {
$parent->insertBefore($element->firstChild, $element);
}
$parent->removeChild($element);
}
/**
* 요소의 속성을 정리합니다.
*
* @param \DOMElement $element 대상 요소
*/
private function cleanAttributes(\DOMElement $element): void
{
$attributes = [];
foreach ($element->attributes as $attribute) {
$attributes[] = $attribute->nodeName;
}
foreach ($attributes as $name) {
$lower = strtolower($name);
// 이벤트 핸들러 전량 제거 (onerror, onload, onanimationend, ...)
if (str_starts_with($lower, 'on')) {
$element->removeAttribute($name);
continue;
}
if (in_array($lower, self::FORBID_ATTRS, true)) {
$element->removeAttribute($name);
continue;
}
if (in_array($lower, self::URL_ATTRS, true)
&& ! $this->isAllowedUrl($element->getAttribute($name))) {
$element->removeAttribute($name);
}
}
// 외부 링크에 rel 보강 (일반 화면의 HtmlContent 후처리와 동일)
if (strtolower($element->tagName) === 'a' && ! $element->hasAttribute('rel')) {
$href = $element->getAttribute('href');
if (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) {
$element->setAttribute('rel', 'noopener noreferrer');
}
}
}
/**
* URL 속성 값이 허용된 스킴인지 판정합니다.
*
* 상대 경로 · 앵커 · 프로토콜 상대 경로는 허용하고, `javascript:` 처럼 스크립트를
* 실행할 수 있는 스킴은 차단합니다.
*
* @param string $url 검사할 URL
* @return bool 허용 여부
*/
private function isAllowedUrl(string $url): bool
{
$trimmed = trim($url);
if ($trimmed === '') {
return true;
}
// 제어문자를 끼워 넣어 스킴 검사를 우회하는 시도를 막는다 (`java\tscript:` 등)
$normalized = strtolower(preg_replace('/[\x00-\x20]+/', '', $trimmed) ?? '');
if (preg_match(self::ALLOWED_DATA_URL, $normalized) === 1) {
return true;
}
// 스킴이 없으면 상대 경로 / 앵커 / 쿼리 → 허용
if (preg_match('/^([a-z][a-z0-9+.-]*):/', $normalized, $matches) !== 1) {
return true;
}
return in_array($matches[1], self::ALLOWED_SCHEMES, true);
}
/**
* 요소의 내부 HTML 을 문자열로 반환합니다.
*
* @param \DOMDocument $document 소속 문서
* @param \DOMElement $element 대상 요소
* @return string 내부 HTML
*/
private function innerHtml(\DOMDocument $document, \DOMElement $element): string
{
$html = '';
foreach ($element->childNodes as $child) {
$html .= $document->saveHTML($child);
}
return $html;
}
}
+86 -27
View File
@@ -95,6 +95,39 @@ class SeoRenderer implements SeoRendererInterface
$queryParams = $request->query();
// 레이아웃 초기 상태를 프론트엔드(TemplateApp)와 동일한 순서로 반영한다:
// 레이아웃 최상위 initLocal/state·initGlobal(없는 키만) → init_actions setState
// initActions (camelCase, LayoutService 병합 결과) 또는 init_actions (snake_case, 원본 JSON) 둘 다 지원
$initActions = $mergedLayout['initActions'] ?? $mergedLayout['init_actions'] ?? [];
// 데이터소스 엔드포인트/params 표현식이 참조할 수 있도록 _global 을 먼저 구성한다.
// (예: 검색 결과 엔드포인트의 {{_global.searchActiveTab ?? 'all'}})
$preContext = [
'route' => array_merge($routeParams, ['path' => $url]),
'query' => $queryParams,
'_global' => $this->buildGlobalContext(),
];
// 레이아웃 최상위 initGlobal → _global (기존 키는 보존)
foreach ($this->resolveInitStateBlock($mergedLayout['initGlobal'] ?? [], $preContext) as $key => $value) {
if (! array_key_exists($key, $preContext['_global'])) {
$preContext['_global'][$key] = $value;
}
}
// init_actions의 setState(target: global) → _global
$globalFromActions = $this->resolveInitActionState($initActions, 'global', $preContext);
// target: global 인 setState가 _local 하위 객체를 싣는 경우 _local로 병합
// (프론트엔드 setGlobalState가 globalState._local을 갱신하는 동작과 동일)
$localFromGlobalActions = [];
if (isset($globalFromActions['_local']) && is_array($globalFromActions['_local'])) {
$localFromGlobalActions = $globalFromActions['_local'];
unset($globalFromActions['_local']);
}
$preContext['_global'] = array_merge($preContext['_global'], $globalFromActions);
$context = [];
if (! empty($seoDataSourceIds)) {
$context = $this->dataSourceResolver->resolve(
@@ -102,25 +135,30 @@ class SeoRenderer implements SeoRendererInterface
$seoDataSourceIds,
$routeParams,
$locale,
$queryParams
$queryParams,
$preContext
);
}
// route 정보를 context에 추가 (path: 현재 URL 경로, + 동적 파라미터)
$context['route'] = array_merge($routeParams, ['path' => $url]);
// route/query/_global 을 데이터소스 결과와 합쳐 렌더 컨텍스트를 구성
$context['route'] = $preContext['route'];
$context['query'] = $preContext['query'];
$context['_global'] = $preContext['_global'];
// query 파라미터도 context에 추가
$context['query'] = $queryParams;
// 레이아웃 최상위 initLocal (없으면 state — 하위 호환) → _local
// _local 은 데이터소스 결과를 참조할 수 있어야 하므로 호출 이후에 평가한다.
$local = $this->resolveInitStateBlock(
$mergedLayout['initLocal'] ?? $mergedLayout['state'] ?? [],
$context
);
// SEO 컨텍스트에 _global/_local 추가
// 프론트엔드에서 window.G7Config로 주입되는 설정을 서버사이드에서도 동일하게 제공
$context['_global'] = $this->buildGlobalContext();
$local = array_merge(
$local,
$this->resolveInitActionState($initActions, 'local', $context),
$localFromGlobalActions
);
// init_actions의 setState(target: local)을 평가하여 _local 초기화
// 프론트엔드에서 init_actions로 설정하는 _local 상태를 SEO 렌더링에서도 동일하게 반영
// initActions (camelCase, LayoutService 병합 결과) 또는 init_actions (snake_case, 원본 JSON) 둘 다 지원
$initActions = $mergedLayout['initActions'] ?? $mergedLayout['init_actions'] ?? [];
$context['_local'] = $this->resolveInitLocalState($initActions, $context);
$context['_local'] = $local;
// 5.1. initGlobal 매핑: 데이터소스 결과를 _global 경로에 주입
// 프론트엔드에서 data_source의 initGlobal 설정으로 _global에 매핑하는 것과 동일
@@ -909,28 +947,52 @@ class SeoRenderer implements SeoRendererInterface
}
/**
* init_actions의 setState(target: local)을 평가하여 _local 초기값을 반환합니다.
* 레이아웃 최상위 초기 상태 블록(initLocal/state/initGlobal)의 값을 평가합니다.
*
* 프론트엔드에서 init_actions 실행 시 setState로 설정하는 _local 상태를
* 프론트엔드 TemplateApp이 레이아웃 레벨 initLocal/initGlobal을 상태에 적용하는 것과
* 동일하게, 각 값의 {{}} 표현식을 해석해 반환합니다.
*
* @param mixed $block 초기 상태 블록 (키 → 값)
* @param array $context 현재 컨텍스트 (route, query 등 포함)
* @return array 평가된 초기 상태
*/
private function resolveInitStateBlock(mixed $block, array $context): array
{
if (! is_array($block) || $block === []) {
return [];
}
$resolved = [];
foreach ($block as $key => $value) {
$resolved[$key] = $this->resolveInitActionValue($value, $context);
}
return $resolved;
}
/**
* init_actions의 setState를 대상(target)별로 평가하여 초기값을 반환합니다.
*
* 프론트엔드에서 init_actions 실행 시 setState로 설정하는 _local/_global 상태를
* SEO 렌더링에서도 동일하게 반영합니다. 이를 통해 탭 상태, 페이지네이션 초기값 등
* _local 기반 조건부 렌더링이 SEO에서도 정상 동작합니다.
* 상태 기반 조건부 렌더링이 SEO에서도 정상 동작합니다.
*
* 처리 대상:
* - handler: "setState" + params.target: "local" (또는 target 미지정)
* - handler: "setState" + params.target 이 $target 과 일치 (target 미지정은 local)
* - params 내 {{}} 표현식을 ExpressionEvaluator로 평가
* - 배열 리터럴, 객체 리터럴 등 정적 값은 그대로 사용
*
* 스킵 대상:
* - handler가 setState가 아닌 항목 (loadFromLocalStorage, closeModal 등)
* - target이 "global"인 항목
*
* @param array $initActions 레이아웃의 init_actions 배열
* @param string $target 대상 상태 (`local` 또는 `global`)
* @param array $context 현재 컨텍스트 (route, query 등 포함)
* @return array _local 초기값
* @return array 평가된 초기값
*/
private function resolveInitLocalState(array $initActions, array $context): array
private function resolveInitActionState(array $initActions, string $target, array $context): array
{
$local = [];
$state = [];
// setState에서 제외할 메타 키 (상태 값이 아닌 핸들러 제어용 키)
$metaKeys = ['target', 'handler', 'comment'];
@@ -942,10 +1004,7 @@ class SeoRenderer implements SeoRendererInterface
}
$params = $action['params'] ?? [];
$target = $params['target'] ?? 'local';
// global 대상은 스킵 (_global은 buildGlobalContext + applyInitGlobalMapping이 담당)
if ($target === 'global') {
if (($params['target'] ?? 'local') !== $target) {
continue;
}
@@ -954,11 +1013,11 @@ class SeoRenderer implements SeoRendererInterface
continue;
}
$local[$key] = $this->resolveInitActionValue($value, $context);
$state[$key] = $this->resolveInitActionValue($value, $context);
}
}
return $local;
return $state;
}
/**
+104 -2
View File
@@ -937,6 +937,21 @@ config에 없는 컴포넌트는 `<div>` fallback으로 렌더링됩니다.
- 좌측이 배열/객체면 원본 타입 유지 (문자열 변환 없음)
- 복합 표현식 (`&&`, `||` 포함) 시에는 일반 `evaluate()` 위임
### ExpressionEvaluator — `||` / `&&` 논리 연산자
일반 화면과 동일하게 **값을 반환**합니다. `true` / `false` 라는 글자로 바뀌지 않습니다.
| 표현식 | 좌측 값 | 결과 |
|--------|--------|------|
| `{{query.q \|\| ''}}` | `"검색어"` | `"검색어"` |
| `{{query.q \|\| ''}}` | 빈 값 | `""` |
| `{{a \|\| b}}` | `a` 가 빈 값 | `b` 의 값 |
| `{{a && b}}` | `a` 가 빈 값 | `a` 의 값 |
| `{{a && b}}` | `a` 가 값 있음 | `b` 의 값 |
- 빈 값 판정: `''`, `'false'`, `'0'`
- 이 규칙은 데이터소스 엔드포인트 보간(`?q={{query?.q \|\| ''}}`)에서도 그대로 적용됩니다. 값 대신 `true` 가 들어가면 봇이 엉뚱한 목록을 받게 됩니다.
### ExpressionEvaluator — 삼항 연산자
`condition ? trueExpr : falseExpr` 형태의 삼항 연산자를 지원합니다. JS 우선순위에 맞게 `||`/`&&`보다 먼저 분리됩니다.
@@ -1039,6 +1054,93 @@ config에 없는 컴포넌트는 `<div>` fallback으로 렌더링됩니다.
- `default`: 매칭 없을 때 기본 클래스
- 기존 `className`과 병합 가능
### ComponentHtmlMapper — Extension Point props 해석
레이아웃의 `extension_point` 노드를 확장이 교체(또는 추가)하면, 호스트가 선언한 `props` 가 주입 컴포넌트의 최상위 키 `extensionPointProps` 로 전달됩니다. 주입 컴포넌트는 `{{extensionPointProps.content}}` 형태로 그 값을 참조합니다.
SEO 렌더링은 `renderComponent()` 진입 시점에 이 값을 해석해 데이터 컨텍스트의 `extensionPointProps` 에 넣습니다.
- 해석 시점: `responsive` 병합·`if` 판정·`iteration` 전개보다 **먼저** — 조건식과 반복 소스도 이 값을 참조할 수 있어야 하기 때문입니다.
- 적용 범위: 해당 노드와 **자손 전체**. 형제 노드에는 전달되지 않습니다.
- 반복 내부: 반복 항목마다 재해석하지 않고 바깥 컨텍스트 기준으로 한 번만 해석한 뒤 상속합니다.
- 값 해석: 문자열 표현식은 원본 타입을 유지한 채 해석됩니다(불리언·숫자·배열 보존). 중첩 객체는 재귀 해석합니다.
`extensionPointCallbacks` 는 **의도적으로 해석하지 않습니다**. SEO 파이프라인에는 액션 실행기가 없고, 액션 정의 배열을 컨텍스트에 넣으면 HTML 에 직렬화 파편이 노출될 수 있습니다. 봇에게 보여야 할 내용을 콜백 경유로 만들지 마세요.
미해석 시 증상: 확장이 교체한 본문 영역이 내용 없는 빈 요소(`<div></div>`)로 출력되고, 메타 태그에는 본문이 들어가 **메타와 화면 본문이 어긋납니다**.
호스트가 넘기는 판정 값(예: `isHtml`)은 비교식으로 쓰이는 경우가 많습니다(`{{(post.data?.content_mode ?? 'text') === 'html'}}`). 비교·논리 연산의 결과는 참/거짓 값으로 해석되므로 주입 컴포넌트의 `{{extensionPointProps.isHtml ?? true}}` 같은 기본값 폴백이 의도대로 동작합니다.
### 봇 화면의 HTML 정화
사용자가 작성한 본문(게시글·답변글·페이지·상품 설명)은 **일반 화면과 같은 강도로 정화한 뒤** 봇 화면에 넣습니다. 정화를 생략하면 봇 화면에서만 저장된 스크립트가 살아남아, 주소에 봇 파라미터를 붙이는 것만으로 실행됩니다.
판정 규칙은 일반 화면의 `HtmlContent` 컴포지트와 같습니다.
| `isHtml` | 봇 화면 출력 | 일반 화면 |
|---|---|---|
| `false` | 전체 이스케이프 — 태그가 글자로 보입니다 | 동일 (평문 렌더링) |
| `true` 또는 미지정 | 위험 요소를 제거한 뒤 HTML 로 출력 | 동일 (DOMPurify) |
정화 규칙 (`app/Seo/HtmlSanitizer.php`)
- 스크립트 실행·외부 콘텐츠 삽입·문서 구조 조작 태그는 제거합니다. 본문 텍스트를 품을 수 있는 태그는 요소만 벗기고 글자는 남깁니다.
- `on*` 이벤트 핸들러 속성은 전부 제거합니다(목록에 없는 신규 이벤트 포함).
- `href`/`src` 등 URL 속성은 허용 스킴만 남깁니다. `javascript:` 는 제어문자를 끼워 넣은 우회 형태까지 차단하고, `data:` 는 이미지 형식만 허용합니다.
- 외부 링크에는 `rel="noopener noreferrer"` 를 보강합니다.
- 파싱에 실패하면 정화되지 않은 HTML 을 내보내지 않고 전체를 이스케이프합니다.
차단 목록의 기준은 일반 화면 컴포넌트의 DOMPurify 설정입니다(`templates/_bundled/sirsoft-basic/src/components/composite/HtmlContent.tsx`). **한쪽만 바꾸면 두 화면의 정화 강도가 어긋나므로 함께 갱신**하세요. 계약은 `tests/Unit/Seo/HtmlSanitizerTest.php` 와 `tests/Unit/Seo/ExtensionPointPropsRenderingTest.php` 가 잠급니다.
`purifyConfig` prop(일반 화면의 DOMPurify 설정 오버라이드)은 봇 화면에서 해석하지 않습니다 — 기본 정화 규칙만 적용됩니다.
### 데이터소스 화이트리스트
봇 렌더링은 `meta.seo.data_sources` 에 적힌 id 만 미리 조회합니다. **화면이 쓰는 데이터소스는 빠짐없이 선언**하세요. 빠지면 그 값은 늘 비어 있고, 그 값을 조건으로 삼는 블록이 통째로 사라져 머리말과 꼬리말만 있는 화면이 색인됩니다.
봇에게 의미가 없어 일부러 제외하는 경우(브라우저 저장값이 필요한 목록, 로그인 사용자 전용 데이터 등)는 `tests/Unit/Seo/SeoLayoutDataSourceDeclarationTest.php` 의 면제 목록에 사유와 함께 등록합니다. 그 테스트가 번들 템플릿 레이아웃을 전수 순회해 미선언 참조를 차단합니다.
### SEO 렌더러 지원 노드 키 (SSoT)
일반 화면(React)과 봇 화면(PHP)은 같은 레이아웃 JSON 을 각각 렌더합니다. 한쪽에만 기능을 추가하면 봇 화면에서 그 부분이 조용히 사라지므로, 지원 범위를 아래 표로 고정합니다. 이 표가 두 렌더러 지원 범위의 단일 기준(SSoT)입니다.
"봇 화면 처리 위치" 는 파일 경로 + 메서드로 적습니다. 줄 번호는 리팩터링마다 어긋나 오히려 잘못된 근거가 되므로 넣지 않습니다 — 메서드명으로 찾으세요.
| 노드 키 | 일반 화면 | 봇 화면 | 봇 화면 처리 위치 | 비고 |
|---------|----------|--------|------------------|------|
| `name` / `type` / `props` / `children` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::renderComponent` / `renderTag` / `renderChildren` | |
| `text` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::resolveNodeText` | `children` 보다 우선 (양쪽 동일). 키가 있으면 값이 비어도 `children` 으로 폴백하지 않음. `true`/`false`/`null` 로 평가되면 아무것도 출력하지 않음 (JSX 시맨틱). 단, 자체 렌더링을 가진 집합 컴포넌트(아래 `render` 모드)에서는 컴포넌트 출력이 먼저다 — 일반 화면에서도 `Select` 는 `options` 로 스스로 그리고 `text` 를 쓰지 않는다 |
| children 배열 내 문자열 | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::render` | 이스케이프만 하고 리터럴 출력 — 표현식·번역 미해석 (React 동일) |
| `if` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::evaluateBooleanExpression` | 거짓 판정: `''`, `false`, `0`, `null`, `undefined` (대소문자·공백 무시) |
| `condition` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::shouldRender` | `if` 의 별칭 |
| `conditions` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::evaluateConditions` | 문자열 / `{and:[]}` / `{or:[]}` / `[{if:…}]` 체인. 빈 AND=참, 빈 OR=거짓. 어느 형식도 아니면 **렌더링**(양쪽 동일 — 숨기면 봇 화면에서만 사라짐) |
| `iteration` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::renderIteration` | `item_var` / `index_var` 별칭 포함 |
| `type: "iterator"` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::normalizeIteratorNode` | `data`/`itemName`/`indexName` → `iteration` 변환 |
| `classMap` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::resolveClassMap` | |
| `responsive` | 전체 브레이크포인트 | 데스크톱 폭만 | `app/Seo/ComponentHtmlMapper.php::applyResponsiveOverrides` / `matchingBreakpointKey` | 봇=데스크톱 고정. `props`/`if`/`text`/`children`/`iteration` 오버라이드 반영. 매칭 키가 여럿이면 **하나만** 적용 — 커스텀 범위 > 프리셋, 좁은 범위 > 넓은 범위 (양쪽 동일) |
| `extensionPointProps` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::injectExtensionPointContext` | 자손 상속 |
| `default` (extension_point 호스트) | ✅ | ✅ | 렌더 전 단계에서 처리 | 확장이 꺼져 있을 때 쓰는 기본 children — 주입 단계에서 교체·전개되어 렌더러에는 남지 않음 |
| `callbacks` (extension_point 호스트) | ✅ | ❌ (의도) | 미처리 | 주입 단계에서 `extensionPointCallbacks` 로 부착 — 봇 화면에는 액션 실행기가 없음 |
| `computed` / `extends` / 파셜 | ✅ | ✅ | `app/Seo/SeoRenderer.php::resolveComputed` / `app/Services/LayoutService.php::getLayout` | 레이아웃 병합·설치 시점에 처리 |
| `$t:` / `$t:defer:` | ✅ | ✅ | `app/Seo/ExpressionEvaluator.php::resolveTranslation` | 봇 화면은 지연 개념이 없어 동일 키로 해석 |
| `actions` | ✅ | 링크만 | `app/Seo/ComponentHtmlMapper.php::extractLinkAction` | 페이지 이동/새 창 열기 액션만 `<a href>` 로 승격 |
| `extensionPointCallbacks` | ✅ | ❌ (의도) | 미처리 | 액션 실행기 부재 |
| `lifecycle` / `dataKey` / `trackChanges` | ✅ | ❌ (무해) | 미처리 | 입력·생명주기 전용 |
| `slot` / `slotOrder` | ✅ | ❌ (무해) | `app/Services/LayoutService.php::replaceSlots` 에서 선처리 | 레이아웃 병합 단계에서 제거 |
| `sortable` / `itemTemplate` / `expandChildren` / `component_layout` | ✅ | ❌ (무해) | 미처리 | 조작 후 노출되는 화면 |
| `isolatedState` / `$parent` / `_isolated` | ✅ | ❌ (무해) | 미처리 | 격리·모달 부모 컨텍스트 |
| `blur_until_loaded` / 노드 최상위 `style` | ✅ | ❌ (무해) | 미처리 | 표현 전용 |
| 노드 최상위에 잘못 놓인 컴포넌트 prop (`size` 등) | ❌ | ❌ | 미처리 | 양쪽 모두 `props` 객체만 읽으므로 무시됨 — 값을 적용하려면 `props` 안으로 옮겨야 함 |
| `props.isHtml` (콘텐츠 노드) | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::renderRawMode` | 거짓이면 이스케이프, 참(기본)이면 정화 후 HTML — "봇 화면의 HTML 정화" 참조 |
| `props.value` (폼 제어) | 속성 | 속성 | `app/Seo/ComponentHtmlMapper.php::resolveTextContent` | `select`/`option`/`input`/`textarea` 등에서는 글자로 승계하지 않음 — 선택 목록은 `options` 로 항목 라벨을 그림 |
| `props.purifyConfig` | ✅ | ❌ (의도) | 미처리 | 봇 화면은 기본 정화 규칙만 적용 |
| `modals` | ✅ | ❌ (무해) | `SeoRenderer` 가 `components` 만 렌더 | 봇 화면은 모달을 렌더하지 않음 |
| 레이아웃 최상위 `state` / `initLocal` / `initGlobal` | ✅ | ✅ | `app/Seo/SeoRenderer.php::resolveInitStateBlock` / `resolveInitActionState` | `init_actions` 의 상태 설정(로컬/전역)도 반영 |
이 표는 `tests/Unit/Seo/SeoNodeKeyParityTest.php` 의 분류 목록과 동기 유지합니다. 표를 바꾸면 그 테스트의 목록도 함께 바꿔야 합니다. 반대로 레이아웃에 새 노드 키가 등장하면 그 테스트가 실패하므로, 봇 화면에서 해석이 필요한지 판단한 뒤 양쪽을 갱신하세요.
확장 포인트 쪽 서술은 [layout-extensions.md "Extension Point 데이터 전달"](../extension/layout-extensions.md), 레이아웃 작성자 관점 요약은 [layout-json.md](../frontend/layout-json.md) 를 참조하세요.
### DataSourceResolver — params 쿼리 파라미터 해석
data_source 정의에 `params` 필드가 있으면 해당 값을 해석하여 API 호출 시 쿼리 파라미터로 전달합니다.
@@ -1081,9 +1183,9 @@ data_source 정의에 `params` 필드가 있으면 해당 값을 해석하여 AP
| 타입 | 역할 | 설명 |
|------|------|------|
| `iterate` | 배열 데이터 순회 → 아이템별 HTML 생성 | `item_tag`, `item_attrs`, `item_content`, `badge_field` |
| `iterate` | 배열 데이터 순회 → 아이템별 HTML 생성 | `item_tag`, `item_attrs`, `item_content`, `badge_field`. `item_attrs` 와 `item_content` 를 함께 선언하면 속성과 라벨을 모두 그립니다(`<option value="…">라벨</option>`) |
| `format` | 포맷 문자열 `{key}` 플레이스홀더 치환 | `format`, `defaults` (component_map 엔트리에서 정의) |
| `raw` | 원본 HTML/텍스트 그대로 출력 | `source` (이스케이프 없음) |
| `raw` | 사용자 작성 콘텐츠 출력 | `source`. `isHtml` prop 판정에 따라 이스케이프 또는 정화 후 출력 — "봇 화면의 HTML 정화" 참조 |
| `fields` | 객체 prop에서 필드 추출 → 개별 HTML 생성 | `fields` (컴포지트 컴포넌트 SEO 렌더링용) |
| `pagination` | 페이지네이션 링크 생성 | `max_links` (기본 10), 현재 페이지 `<span>` + 나머지 `<a href="?page=N">` |
+17
View File
@@ -363,6 +363,23 @@ v1.17.0 이전: modals 내부의 extension_point는 처리되지 않았음
| `props` | object | 주입 컴포넌트에 전달할 데이터. 표현식 평가됨. 플러그인에서 `{{extensionPointProps.xxx}}`로 접근 |
| `callbacks` | object | 주입 컴포넌트에 전달할 액션 객체. 평가 없이 그대로 전달. 플러그인에서 `{{extensionPointCallbacks.xxx}}`로 접근 |
전달된 값은 주입 컴포넌트와 그 **자손 전체**에서 참조할 수 있습니다.
#### 검색 봇 화면에서의 동작
일반 화면과 봇용 화면(SEO)은 같은 레이아웃을 각각 렌더합니다. 확장 포인트 데이터 전달의 지원 범위는 두 화면이 다릅니다. 봇 화면의 `props` 해석은 **7.0.6** 부터 동작합니다 — 그 이전 버전에서는 확장이 교체한 영역이 봇에게 빈 요소로 나갔습니다.
| 항목 | 일반 화면 | 봇 화면 |
|------|----------|--------|
| `{{extensionPointProps.xxx}}` | 해석됨 | 해석됨 |
| `{{extensionPointCallbacks.xxx}}` | 해석됨 | **해석되지 않음** |
봇 화면에는 액션 실행기가 없으므로 콜백은 의도적으로 전달하지 않습니다. **검색 결과에 노출되어야 할 내용을 콜백 경유로 만들지 마세요** — 봇에게는 빈 값이 됩니다. 본문·제목처럼 색인되어야 하는 값은 `props` 로 전달합니다.
사용자 작성 콘텐츠를 넘기는 확장 포인트라면 콘텐츠와 함께 **평문/HTML 판정 값(`isHtml`)도 전달**하세요. 두 화면 모두 이 값에 따라 평문으로 이스케이프하거나 위험 요소를 제거한 뒤 출력합니다. 판정 값을 넘기지 않으면 HTML 로 간주됩니다.
봇 화면의 노드 문법 지원 범위 전체는 [seo-system.md "SEO 렌더러 지원 노드 키"](../backend/seo-system.md)를, 정화 규칙은 같은 문서의 "봇 화면의 HTML 정화" 를 참조하세요.
### 모듈에서 Extension Point에 주입
```json
+4
View File
@@ -915,6 +915,10 @@ const renderChildren = useMemo(() => {
| `props` | object | ❌ | 주입 컴포넌트에 전달할 데이터 (표현식 평가) |
| `callbacks` | object | ❌ | 주입 컴포넌트에 전달할 액션 객체 (평가 없이 전달) |
전달된 값은 주입 컴포넌트와 그 자손 전체에서 참조할 수 있습니다.
검색 봇용 화면에서는 `extensionPointProps` 만 해석되고 `extensionPointCallbacks` 는 해석되지 않습니다. 색인되어야 할 내용은 `props` 로 전달하세요. 사용자 작성 콘텐츠를 넘길 때는 평문/HTML 판정 값(`isHtml`)을 함께 전달합니다 — 두 화면 모두 이 값에 따라 이스케이프하거나 위험 요소를 제거한 뒤 출력합니다. 봇 화면의 노드 문법 지원 범위는 [seo-system.md "SEO 렌더러 지원 노드 키"](../backend/seo-system.md), 정화 규칙은 같은 문서의 "봇 화면의 HTML 정화" 를 참조하세요.
---
## Deprecated 속성
@@ -8,11 +8,25 @@
### Fixed
#### 검색·봇 화면
- 통합 검색 결과의 상품 이름이 표시되지 않던 문제를 수정했습니다. 검색어 강조 표시 방식이 잘못 지정되어 상품 이름 자리가 비어 있었습니다.
- 검색엔진 봇에게 보여줄 자료 목록에서 빠져 있던 항목을 채웠습니다. 게시글의 이전글·다음글 링크, 인기글 목록, 상품 문의, 추천·신상품 목록, 검색 결과, 회원 프로필의 활동 통계·최근 글, 회원 작성글 목록이 봇 화면에서 비어 있던 문제가 해소됩니다. (#86 @koojunho 님께서 제보해주셨습니다.)
- 봇 화면의 선택 상자에 항목 이름 대신 내부 식별자가 글자로 노출되던 문제를 수정했습니다. 이제 일반 화면과 같이 선택 항목의 이름이 표시됩니다.
#### 목록 상태 유지
- 게시글 목록에서 글을 열어 이전글·다음글로 넘어가면 보고 있던 페이지 번호와 검색어·분류가 사라져, 이어서 '목록'을 누르면 1페이지로 되돌아가던 문제를 수정했습니다. 이제 목록 ↔ 상세 ↔ 이전/다음 글 ↔ 글쓰기·수정 어디로 오가든, 글쓰기를 취소하고 돌아올 때도 보고 있던 목록 상태가 그대로 유지됩니다. 마이페이지 주문·마일리지 목록과 상품 목록(상품 상세에서 목록으로 돌아오는 경우 포함)도 동일하게 개선됐습니다. 검색 초기화와 탭 전환은 종전처럼 조건을 비웁니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 상품 목록에서 상품을 열고 상세의 탭(상세정보·리뷰·문의)을 누르면 보고 있던 페이지·정렬·검색어·분류가 주소에서 사라져, 이어서 '상품 목록'을 눌러도 1페이지로 되돌아가던 문제를 수정했습니다. 이제 탭을 오가도 목록 상태가 그대로 유지됩니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
#### 장바구니·오류 안내
- 상품 상세에서 옵션을 선택해 장바구니에 담거나 바로 구매할 때, 표시 언어(로케일)나 관리자의 옵션 이름 수정과 무관하게 선택한 옵션이 정확히 담기도록 개선했습니다. 특정 상황에서 "해당 옵션 조합을 찾을 수 없습니다" 오류로 담기가 실패하던 문제가 해소됩니다.
- 저장이나 삭제가 실패했을 때 아무 안내 없이 조용히 넘어가던 화면들을 수정했습니다. 이제 실패하면 그 이유가 화면에 표시되므로, 반영되지 않은 변경을 성공한 것으로 오해하지 않습니다.
- 오류 안내가 뜨기는 하지만 내용이 비어 있던 문제를 수정했습니다. 서버가 알려 준 실패 사유가 그대로 표시됩니다.
#### 화면 표시
- 알림 목록·장바구니 화면의 아이콘이 의도한 크기보다 크거나 작게 보이던 문제를 수정했습니다.
- 다크 모드에서 장바구니의 선택 상자·삭제 아이콘, 일부 버튼의 색 대비가 낮아 잘 보이지 않던 문제를 수정했습니다.
- 장바구니에서 옵션을 변경할 때 옵션 목록이 아직 준비되지 않은 상태면 화면이 비어 보이던 문제를 수정했습니다.
@@ -6,6 +6,7 @@
"meta": {
"seo": {
"enabled": true,
"data_sources": ["popularPosts"],
"page_type": "boards",
"extensions": [
{ "type": "module", "id": "sirsoft-board" }
@@ -9,7 +9,7 @@
"extensions": [
{ "type": "module", "id": "sirsoft-board" }
],
"data_sources": ["post"],
"data_sources": ["post", "navigation"],
"page_type": "post",
"toggle_setting": "$module_settings:sirsoft-board:seo.seo_post_detail",
"vars": {
@@ -36,10 +36,12 @@
"text": "{{product.category_name}}"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "font-medium text-gray-900 dark:text-white truncate" },
"html": "{{product.name_highlighted ?? product.name}}"
"type": "composite",
"name": "HtmlContent",
"props": {
"content": "{{product.name_highlighted ?? product.name}}",
"className": "font-medium text-gray-900 dark:text-white truncate [&_mark]:bg-yellow-200 [&_mark]:dark:bg-yellow-500/40 [&_mark]:px-0.5 [&_mark]:rounded"
}
}
]
},
@@ -9,7 +9,7 @@
"extensions": [
{ "type": "module", "id": "sirsoft-ecommerce" }
],
"data_sources": [],
"data_sources": ["searchResults"],
"page_type": "search",
"toggle_setting": "$module_settings:sirsoft-ecommerce:seo.seo_search_result",
"vars": {
@@ -9,7 +9,7 @@
"extensions": [
{ "type": "module", "id": "sirsoft-ecommerce" }
],
"data_sources": ["products", "categories"],
"data_sources": ["products", "categories", "popularProducts", "newProducts"],
"page_type": "shop_index",
"toggle_setting": "$module_settings:sirsoft-ecommerce:seo.seo_shop_index",
"vars": {
@@ -170,7 +170,7 @@
"name": "Icon",
"props": {
"name": "shopping-cart",
"className": "w-6 h-6"
"className": "text-2xl"
}
},
{
@@ -12,7 +12,7 @@
"extensions": [
{ "type": "module", "id": "sirsoft-ecommerce" }
],
"data_sources": ["product", "popularProducts", "reviews"],
"data_sources": ["product", "popularProducts", "reviews", "qna"],
"page_type": "product",
"toggle_setting": "$module_settings:sirsoft-ecommerce:seo.seo_product_detail",
"vars": {
@@ -6,7 +6,7 @@
"meta": {
"seo": {
"enabled": true,
"data_sources": ["userProfile"],
"data_sources": ["userProfile", "userPosts"],
"priority": 0.3,
"changefreq": "weekly",
"og": {
@@ -207,7 +207,7 @@
"name": "Icon",
"props": {
"name": "file-circle-xmark",
"size": "3x",
"size": "2xl",
"className": "text-gray-300 dark:text-gray-600 mb-4"
}
},
@@ -6,7 +6,7 @@
"meta": {
"seo": {
"enabled": true,
"data_sources": ["profile"],
"data_sources": ["profile", "postStats", "recentPosts"],
"priority": 0.4,
"changefreq": "monthly",
"og": {
@@ -613,7 +613,7 @@
"name": "Icon",
"props": {
"name": "file-circle-xmark",
"size": "3x",
"size": "2xl",
"className": "text-gray-300 dark:text-gray-600 mb-4"
}
},
@@ -35,6 +35,13 @@
"item_content": "{label}",
"badge_field": "badge"
},
"select_options": {
"type": "iterate",
"source": "$props_source",
"item_tag": "option",
"item_attrs": { "value": "{value}" },
"item_content": "{label|label_key|text|name}"
},
"text_format": {
"type": "format"
},
@@ -185,7 +192,12 @@
"Th": { "tag": "th" },
"Td": { "tag": "td" },
"Label": { "tag": "label" },
"Select": { "tag": "select" },
"Select": {
"tag": "select",
"render": "select_options",
"props_source": "options"
},
"Option": { "tag": "option" },
"Textarea": { "tag": "textarea" },
"Strong": { "tag": "strong" },
"Em": { "tag": "em" },
+19 -12
View File
@@ -2,6 +2,8 @@
namespace Tests\Feature\Seo;
use App\Seo\SeoMetaResolver;
use Illuminate\Support\Facades\Config;
use Tests\TestCase;
/**
@@ -158,9 +160,14 @@ class SeoPageRenderingTest extends TestCase
$this->assertArrayHasKey('priority', $seo, 'search/index.json seo에 priority 키가 존재해야 합니다');
$this->assertLessThanOrEqual(0.5, $seo['priority'], '검색 페이지 SEO priority는 0.5 이하여야 합니다');
// data_sources 확인 (검색 결과는 SEO 데이터소스 불필요)
// data_sources 확인 — 봇에게도 실제 검색 결과를 보여준다.
// 종전에는 비워 두어 봇에게 빈 껍데기 페이지가 나갔다 (수집은 허용하면서 내용은 없는 상태).
$this->assertArrayHasKey('data_sources', $seo, 'search/index.json seo에 data_sources 키가 존재해야 합니다');
$this->assertEmpty($seo['data_sources'], '검색 페이지의 SEO data_sources는 비어있어야 합니다');
$this->assertContains(
'searchResults',
$seo['data_sources'],
'검색 페이지 SEO data_sources에 searchResults가 포함되어야 합니다 (미포함 시 봇에게 빈 결과 목록이 나갑니다)'
);
// structured_data 확인 (SearchResultsPage)
$this->assertArrayHasKey('structured_data', $seo, 'search/index.json seo에 structured_data 키가 존재해야 합니다');
@@ -231,20 +238,20 @@ class SeoPageRenderingTest extends TestCase
public function test_all_enabled_layouts_resolve_without_throw_under_multilingual_settings(): void
{
// 코어 설정을 다국어 JSON array 로 시뮬레이션 (운영자가 MultilingualInput 사용 시)
\Illuminate\Support\Facades\Config::set('g7_settings.core.general.site_name', [
Config::set('g7_settings.core.general.site_name', [
'ko' => '한글몰', 'en' => 'KoreanMall',
]);
\Illuminate\Support\Facades\Config::set('g7_settings.core.seo.og_default_site_name', '');
\Illuminate\Support\Facades\Config::set('g7_settings.core.seo.og_image_default_width', 1200);
\Illuminate\Support\Facades\Config::set('g7_settings.core.seo.og_image_default_height', 630);
\Illuminate\Support\Facades\Config::set('g7_settings.core.seo.twitter_default_card', 'summary_large_image');
\Illuminate\Support\Facades\Config::set('g7_settings.core.seo.twitter_default_site', '');
\Illuminate\Support\Facades\Config::set('g7_settings.core.seo.meta_title_suffix', '');
\Illuminate\Support\Facades\Config::set('g7_settings.core.seo.meta_description', '');
\Illuminate\Support\Facades\Config::set('g7_settings.core.seo.meta_keywords', '');
Config::set('g7_settings.core.seo.og_default_site_name', '');
Config::set('g7_settings.core.seo.og_image_default_width', 1200);
Config::set('g7_settings.core.seo.og_image_default_height', 630);
Config::set('g7_settings.core.seo.twitter_default_card', 'summary_large_image');
Config::set('g7_settings.core.seo.twitter_default_site', '');
Config::set('g7_settings.core.seo.meta_title_suffix', '');
Config::set('g7_settings.core.seo.meta_description', '');
Config::set('g7_settings.core.seo.meta_keywords', '');
app()->setLocale('ko');
$resolver = app(\App\Seo\SeoMetaResolver::class);
$resolver = app(SeoMetaResolver::class);
$rii = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->layoutBasePath));
$errors = [];
@@ -0,0 +1,360 @@
/**
* E2E: 검색 봇에게 전달되는 화면(SEO 렌더링)에 본문이 포함되는지
*
* 회귀 (#86): 위지윅 편집기 플러그인이 본문 영역을 확장으로 교체하는데, 봇용 화면을
* 만드는 서버 렌더러가 그 교체 규칙(호스트 props 전달)을 몰라 본문을 빈 요소로
* 내보냈다. 메타 태그에는 본문이 들어가지만 화면 본문 영역은 `<div></div>` 였다.
*
* 단위 테스트(ExtensionPointPropsRenderingTest)가 렌더러 계약을 잠그지만, 결함이
* "레이아웃 파일 + 확장 자산 + 실제 데이터" 가 만나는 실환경에서만 드러났으므로
* 실제 HTTP 응답으로도 잠근다.
*
* 브라우저 렌더링이 아니라 봇이 받는 원본 HTML 을 검사해야 하므로 `request` 픽스처로
* 직접 요청한다(React 가 실행되면 결함이 가려진다).
*
* @scenario ep_site=board_post, injection=extension_active, nesting=direct_child, host_gate=if_true, content_kind=html_mode
* @effects ep_props_resolved_into_context, subtree_inherits_ep_props
*/
import { test, expect, type APIRequestContext } from '@playwright/test';
/** 봇 렌더링을 강제하는 쿼리 파라미터 (검색엔진 escaped fragment 규약). */
const BOT_QUERY = '_escaped_fragment_=';
/**
* 봇 User-Agent (BotDetector 라이브러리 매칭 경로).
*
* 봇 판정은 escaped fragment 쿼리와 UA 두 경로로 진입한다. 한쪽만 검증하면 다른 경로에서
* 본문이 비어도 드러나지 않으므로 두 경로를 모두 잠근다.
*/
const BOT_USER_AGENT =
'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)';
/** 확장 주입 본문이 비어 있을 때 나오던 회귀 시그니처. */
const EMPTY_INJECTED_BODY = '<div></div><div class="hidden"></div>';
/** 댓글 본문 영역을 식별하는 className (게시판 답글 확장 포인트). */
const REPLY_CONTENT_MARKER = 'prose-sm dark:prose-invert';
/**
* 봇 렌더링 응답(HTML + 캐시 헤더)을 가져옵니다.
*
* @param request Playwright 요청 컨텍스트
* @param path 대상 경로 (쿼리 포함 가능)
* @returns 응답 본문 HTML 과 X-SEO-Cache 헤더 값
*/
async function fetchBotResponse(
request: APIRequestContext,
path: string,
transport: 'escaped_fragment' | 'bot_user_agent' = 'escaped_fragment'
): Promise<{ html: string; cache: string | undefined }> {
const separator = path.includes('?') ? '&' : '?';
const url = transport === 'escaped_fragment' ? `${path}${separator}${BOT_QUERY}` : path;
const response = await request.get(
url,
transport === 'bot_user_agent' ? { headers: { 'User-Agent': BOT_USER_AGENT } } : undefined
);
expect(response.status(), `${path} 봇 렌더링 응답 코드 (${transport})`).toBe(200);
const headers = response.headers();
return {
html: await response.text(),
// 헤더명은 서버가 소문자로 정규화한다
cache: headers['x-seo-cache'],
};
}
/**
* 봇 렌더링 HTML 을 가져옵니다.
*
* 응답이 봇 렌더러를 실제로 거쳤는지(캐시 헤더 존재) 함께 확인합니다 — 헤더가 없으면
* 일반 SPA 응답을 받은 것이라 본문 검증이 무의미해집니다.
*
* @param request Playwright 요청 컨텍스트
* @param path 대상 경로 (쿼리 포함 가능)
* @returns 응답 본문 HTML
*/
async function fetchBotHtml(
request: APIRequestContext,
path: string,
transport: 'escaped_fragment' | 'bot_user_agent' = 'escaped_fragment'
): Promise<string> {
const { html, cache } = await fetchBotResponse(request, path, transport);
expect(cache, `${path} 응답이 봇 렌더러를 거치지 않았습니다 (X-SEO-Cache 헤더 없음)`).toBeDefined();
expect(['HIT', 'MISS']).toContain(cache);
return html;
}
/**
* 봇 HTML 의 `<body>` 부분만 잘라냅니다.
*
* 메타 태그에는 본문이 들어 있으므로, 화면 본문 영역 검증은 body 로 한정해야 한다.
*
* @param html 전체 HTML
* @returns body 이후 문자열
*/
function bodyOf(html: string): string {
const index = html.indexOf('<body>');
return index === -1 ? html : html.slice(index);
}
test.describe('봇 렌더링 본문 노출', () => {
/**
* @scenario ep_site=board_post, injection=extension_active, nesting=direct_child, host_gate=if_true, content_kind=html_mode
* @effects ep_props_resolved_into_context, subtree_inherits_ep_props, escaped_fragment_request_renders_content
*/
test('게시글 본문이 메타뿐 아니라 봇 화면 본문 영역에도 출력된다 @smoke', async ({ request }) => {
// 목록에서 실제 게시글 경로를 얻는다 — 고정 ID 하드코딩 회피(환경별 데이터 상이).
const listHtml = await fetchBotHtml(request, '/board/notice');
const paths = [...bodyOf(listHtml).matchAll(/href="(\/board\/notice\/\d+)"/g)].map((m) => m[1]);
expect(paths.length, '공지 게시판 목록에서 게시글 링크를 찾지 못했습니다').toBeGreaterThan(0);
// 가려진 글/비밀글은 본문이 없는 것이 정상이므로, description 이 채워진 글을 고른다.
let checked = false;
for (const path of paths.slice(0, 5)) {
const postHtml = await fetchBotHtml(request, path);
const description = postHtml.match(/<meta name="description" content="([^"]{10,})"/);
if (description === null) {
continue;
}
const body = bodyOf(postHtml);
// 본문 영역이 빈 껍데기로 나가던 회귀 시그니처
expect(body, `${path} 의 본문 영역이 비어 있습니다`).not.toContain(EMPTY_INJECTED_BODY);
// 메타에 담긴 본문 앞부분이 화면 본문 영역에도 있어야 한다 (메타/본문 일치)
const firstWords = description[1].replace(/&[a-z]+;/g, ' ').trim().slice(0, 10);
expect(body, `${path}: 메타에는 있는 본문이 화면 본문 영역에 없습니다`).toContain(firstWords);
checked = true;
break;
}
expect(checked, '본문이 있는 공개 게시글을 찾지 못했습니다').toBe(true);
});
/**
* @scenario ep_site=board_reply, injection=extension_active, nesting=inside_iteration, host_gate=if_true, content_kind=html_mode
* @effects iteration_scoped_ep_props, subtree_inherits_ep_props
*/
test('댓글(답글) 본문이 봇 화면에 출력된다', async ({ request }) => {
// 답글이 달린 글을 목록에서 찾는다 — 고정 ID 하드코딩 회피.
const listHtml = await fetchBotHtml(request, '/board/qna');
const paths = [...bodyOf(listHtml).matchAll(/href="(\/board\/qna\/\d+)"/g)].map((m) => m[1]);
expect(paths.length, 'Q&A 게시판 목록에서 게시글 링크를 찾지 못했습니다').toBeGreaterThan(0);
let checked = false;
for (const path of paths.slice(0, 8)) {
const body = bodyOf(await fetchBotHtml(request, path));
// 댓글 본문 영역의 className (레이아웃의 답글 확장 포인트)
const replyIndex = body.indexOf(REPLY_CONTENT_MARKER);
if (replyIndex === -1) {
continue;
}
const replyBlock = body.slice(replyIndex, replyIndex + 600);
// 확장 주입 본문이 비어 있으면 여기가 <div></div> 로 나온다
expect(replyBlock, `${path} 의 댓글 본문이 비어 있습니다`).not.toContain(EMPTY_INJECTED_BODY);
// 댓글 영역 안에 실제 텍스트가 있어야 한다
expect(replyBlock, `${path} 의 댓글 영역에 텍스트가 없습니다`).toMatch(/>[^<>]{2,}</);
checked = true;
break;
}
test.skip(!checked, '답글이 달린 공개 게시글이 없어 건너뜁니다');
});
/**
* @scenario ep_site=page, injection=extension_active, nesting=direct_child, host_gate=if_true, content_kind=html_mode
* @effects ep_props_resolved_into_context
*/
test('페이지 본문이 봇 화면에 출력된다', async ({ request }) => {
const html = await fetchBotHtml(request, '/page/about');
const body = bodyOf(html);
expect(body).not.toContain(EMPTY_INJECTED_BODY);
// 페이지 제목이 h1 으로, 본문이 그 아래에 실제 텍스트로 나와야 한다
expect(body).toMatch(/<h1[^>]*>[^<]+<\/h1>/);
});
/**
* @scenario ep_site=product_detail, injection=extension_active, nesting=direct_child, host_gate=if_true, content_kind=html_mode
* @effects ep_props_resolved_into_context
*/
test('상품 상세설명이 봇 화면에 출력된다', async ({ request }) => {
const listHtml = await fetchBotHtml(request, '/shop/products');
const match = bodyOf(listHtml).match(/href="(\/shop\/products\/[^"]+)"/);
test.skip(match === null, '상품 목록에 상품이 없어 건너뜁니다');
const html = await fetchBotHtml(request, match![1]);
expect(bodyOf(html)).not.toContain(EMPTY_INJECTED_BODY);
});
/**
* @effects every_used_data_source_is_declared, escaped_fragment_request_renders_content
*/
test('검색 결과 목록이 봇 화면에 출력된다', async ({ request }) => {
// 결과가 존재하는 검색어를 목록 화면에서 얻는다 — 고정 키워드 하드코딩 회피.
const listHtml = await fetchBotHtml(request, '/board/event');
const titleMatch = bodyOf(listHtml).match(/<a[^>]*href="\/board\/event\/\d+"[^>]*>[\s\S]{0,400}?([가-힣]{2,})/);
test.skip(titleMatch === null, '검색어로 쓸 게시글 제목을 찾지 못해 건너뜁니다');
const keyword = titleMatch![1];
const body = bodyOf(await fetchBotHtml(request, `/search?q=${encodeURIComponent(keyword)}`));
// 결과 목록이 비어 있으면 봇에게 빈 껍데기 페이지가 나간다
expect(body, `"${keyword}" 검색 결과에 게시글 링크가 없습니다`).toMatch(/href="\/board\/[^"]+\/\d+"/);
expect(body, '검색 결과 건수 표시가 없습니다').toContain('검색 결과');
});
/**
* @effects escaped_fragment_request_renders_content
*/
test('봇 응답에 미해석 표현식이 남지 않는다', async ({ request }) => {
for (const path of ['/', '/board/notice', '/page/about']) {
const body = bodyOf(await fetchBotHtml(request, path));
// {{...}} 가 그대로 남아 있으면 표현식 해석 실패
expect(body, `${path} 에 미해석 표현식이 남아 있습니다`).not.toMatch(/\{\{[^}]+\}\}/);
// $t: 토큰이 남아 있으면 번역 해석 실패
expect(body, `${path} 에 미해석 번역 토큰이 남아 있습니다`).not.toContain('$t:');
}
});
/**
* @effects cached_response_keeps_content
*/
test('같은 주소를 두 번 요청하면 캐시가 적중하고 본문은 동일하다', async ({ request }) => {
const first = await fetchBotResponse(request, '/page/about');
const second = await fetchBotResponse(request, '/page/about');
expect(first.cache, '첫 요청에 X-SEO-Cache 헤더가 없습니다').toBeDefined();
expect(second.cache, '두 번째 요청이 캐시에 적중하지 않았습니다').toBe('HIT');
// 캐시된 응답도 본문이 비어 있으면 안 된다 (#86 은 빈 본문이 캐시에 굳는 결함이었다)
expect(bodyOf(second.html)).not.toContain(EMPTY_INJECTED_BODY);
expect(bodyOf(second.html)).toBe(bodyOf(first.html));
});
/**
* 봇 User-Agent 로만 진입해도 동일한 본문이 나와야 한다.
*
* escaped fragment 쿼리만 검증하면, 실제 검색엔진이 쓰는 UA 경로에서 본문이 비어도
* 드러나지 않는다 — 판정 경로가 둘인데 잠금은 하나였던 상태.
*
* @effects bot_user_agent_request_renders_content
*/
test('봇 User-Agent 요청에도 본문이 출력된다', async ({ request }) => {
const body = bodyOf(await fetchBotHtml(request, '/page/about', 'bot_user_agent'));
expect(body, '봇 UA 응답의 본문 영역이 비어 있습니다').not.toContain(EMPTY_INJECTED_BODY);
expect(body).toMatch(/<h1[^>]*>[^<]+<\/h1>/);
expect(body, '봇 UA 응답에 미해석 표현식이 남아 있습니다').not.toMatch(/\{\{[^}]+\}\}/);
});
/**
* 두 봇 판정 경로가 같은 본문을 낸다.
*
* @effects both_transports_render_same_content
*/
test('escaped fragment 경로와 봇 User-Agent 경로의 본문이 동일하다', async ({ request }) => {
const viaQuery = bodyOf(await fetchBotHtml(request, '/page/about', 'escaped_fragment'));
const viaAgent = bodyOf(await fetchBotHtml(request, '/page/about', 'bot_user_agent'));
expect(viaAgent, '봇 판정 경로에 따라 본문이 달라집니다').toBe(viaQuery);
});
/**
* 봇 화면에는 실행 가능한 마크업이 남지 않아야 한다.
*
* 회귀: 사용자 작성 본문이 정화 없이 삽입돼, 주소에 봇 파라미터를 붙이는 것만으로
* 저장된 스크립트가 실행됐다(실측: `window.__probe` 설정 확인).
*
* @scenario content_kind=html_mode_with_dangerous_markup, injection=extension_active
* @effects html_mode_content_sanitized, text_mode_content_escaped
*/
test('봇 화면 본문에 실행 가능한 마크업이 남지 않는다', async ({ request }) => {
const paths = ['/board/free', '/board/notice', '/page/about', '/shop/products'];
for (const listPath of paths) {
const listBody = bodyOf(await fetchBotHtml(request, listPath));
const detail = listBody.match(/href="(\/(?:board\/[a-z0-9_-]+\/\d+|shop\/products\/[^"]+))"/);
const targets = detail === null ? [listPath] : [listPath, detail[1]];
for (const target of targets) {
const body = bodyOf(await fetchBotHtml(request, target));
// 본문 영역에는 스크립트 태그가 없어야 한다 (봇 화면은 정적 HTML 이다)
expect(body, `${target} 본문에 script 태그가 있습니다`).not.toMatch(/<script[\s>]/i);
// 인라인 이벤트 핸들러가 남으면 정화가 동작하지 않은 것
// (이스케이프된 글자 속의 `onerror=` 는 무해하므로 여는 태그 안쪽만 본다)
expect(body, `${target} 본문에 인라인 이벤트 핸들러가 있습니다`).not.toMatch(
/<[a-z][^<>]*\son[a-z]+\s*=/i
);
// 외부 프레임 삽입도 차단 대상
expect(body, `${target} 본문에 iframe 이 있습니다`).not.toMatch(/<iframe[\s>]/i);
// javascript: 스킴 링크
expect(body, `${target} 본문에 javascript: 링크가 있습니다`).not.toMatch(/href="\s*javascript:/i);
}
}
});
/**
* 선택 목록은 선택값이 아니라 항목 라벨을 노출한다.
*
* 회귀: 봇 화면의 `<select>` 안에 내부 식별자(정렬 키 등)가 글자로 나가고 있었다.
*
* @effects form_control_value_not_exposed_as_text
*/
test('선택 상자에 내부 식별자가 글자로 노출되지 않는다', async ({ request }) => {
for (const path of ['/board/notice', '/shop/products', '/search?q=a']) {
const body = bodyOf(await fetchBotHtml(request, path));
for (const [, inner] of body.matchAll(/<select[^>]*>([\s\S]*?)<\/select>/g)) {
const directText = inner.replace(/<option[\s\S]*?<\/option>/g, '').trim();
expect(directText, `${path}: select 안에 항목이 아닌 글자가 있습니다 — "${directText}"`).toBe('');
}
}
});
/**
* 회원 작성글 목록 화면이 봇에게 빈 껍데기로 나가지 않는다.
*
* 회귀: 화면 전체가 목록 데이터 조건 아래 있는데 그 데이터소스가 봇 조회 목록에서
* 빠져 있어, 머리말과 꼬리말만 남은 화면이 색인됐다.
*
* @effects every_used_data_source_is_declared
*/
test('회원 작성글 목록이 봇 화면에 출력된다', async ({ request }) => {
// 봇 화면에는 프로필 링크가 없으므로(작성자 이름이 링크가 아님) 공개 목록에서
// 회원 식별자만 얻는다 — 검증은 그 뒤 실제 봇 응답 HTML 로 한다.
const postsResponse = await request.get('/api/modules/sirsoft-board/boards/notice/posts?per_page=5');
expect(postsResponse.status(), '게시글 목록 조회 실패').toBe(200);
const payload = await postsResponse.json();
const uuid = (payload?.data?.data ?? [])
.map((post: { author?: { uuid?: string } }) => post?.author?.uuid)
.find((value: string | undefined) => typeof value === 'string' && value.length > 0);
test.skip(uuid === undefined, '작성자가 있는 게시글이 없어 건너뜁니다');
const body = bodyOf(await fetchBotHtml(request, `/users/${uuid}/posts`));
// 목록이 통째로 사라지면 게시판/게시글 링크가 하나도 없다
expect(body, '작성글 목록에 게시글 링크가 없습니다').toMatch(/href="\/board\/[^"]+\/\d+"/);
});
});
+643 -1
View File
@@ -70,7 +70,8 @@ class ComponentHtmlMapperTest extends TestCase
'Th' => ['tag' => 'th'],
'Td' => ['tag' => 'td'],
'Label' => ['tag' => 'label'],
'Select' => ['tag' => 'select'],
'Select' => ['tag' => 'select', 'render' => 'select_options', 'props_source' => 'options'],
'Option' => ['tag' => 'option'],
'Textarea' => ['tag' => 'textarea'],
'Strong' => ['tag' => 'strong'],
'Em' => ['tag' => 'em'],
@@ -130,6 +131,13 @@ class ComponentHtmlMapperTest extends TestCase
'item_content' => '{label}',
'badge_field' => 'badge',
],
'select_options' => [
'type' => 'iterate',
'source' => '$props_source',
'item_tag' => 'option',
'item_attrs' => ['value' => '{value}'],
'item_content' => '{label|label_key|text|name}',
],
'text_format' => [
'type' => 'format',
],
@@ -2299,6 +2307,510 @@ class ComponentHtmlMapperTest extends TestCase
$this->assertStringContainsString('alt="기본 대체 텍스트"', $html);
}
// =========================================================================
// React 렌더러 패리티 — 노드 문법 (condition/conditions/iterator/text/responsive/if)
// =========================================================================
/**
* condition 속성이 if의 별칭으로 동작합니다.
*/
public function test_condition_property_acts_as_if_alias(): void
{
$shown = $this->render([
['type' => 'basic', 'name' => 'Div', 'condition' => '{{flag}}', 'text' => '보임'],
], ['flag' => true]);
$hidden = $this->render([
['type' => 'basic', 'name' => 'Div', 'condition' => '{{flag}}', 'text' => '숨김'],
], ['flag' => false]);
$this->assertStringContainsString('보임', $shown);
$this->assertSame('', $hidden);
}
/**
* conditions AND 그룹은 모든 조건이 참일 때만 렌더링됩니다.
*/
public function test_conditions_and_group(): void
{
$component = [
'type' => 'basic',
'name' => 'Div',
'conditions' => ['and' => ['{{a}}', '{{b}}']],
'text' => '표시',
];
$this->assertStringContainsString('표시', $this->render([$component], ['a' => true, 'b' => true]));
$this->assertSame('', $this->render([$component], ['a' => true, 'b' => false]));
}
/**
* conditions OR 그룹은 하나라도 참이면 렌더링됩니다.
*/
public function test_conditions_or_group(): void
{
$component = [
'type' => 'basic',
'name' => 'Div',
'conditions' => ['or' => ['{{a}}', '{{b}}']],
'text' => '표시',
];
$this->assertStringContainsString('표시', $this->render([$component], ['a' => false, 'b' => true]));
$this->assertSame('', $this->render([$component], ['a' => false, 'b' => false]));
}
/**
* conditions 중첩 그룹(AND 안의 OR)이 평가됩니다.
*/
public function test_conditions_nested_group(): void
{
$component = [
'type' => 'basic',
'name' => 'Div',
'conditions' => ['and' => ['{{a}}', ['or' => ['{{b}}', '{{c}}']]]],
'text' => '표시',
];
$this->assertStringContainsString('표시', $this->render([$component], ['a' => true, 'b' => false, 'c' => true]));
$this->assertSame('', $this->render([$component], ['a' => true, 'b' => false, 'c' => false]));
}
/**
* conditions if/else 체인은 하나라도 매칭되면 렌더링됩니다.
*/
public function test_conditions_branch_chain(): void
{
$component = [
'type' => 'basic',
'name' => 'Div',
'conditions' => [
['if' => "{{role === 'admin'}}"],
['if' => "{{role === 'manager'}}"],
],
'text' => '표시',
];
$this->assertStringContainsString('표시', $this->render([$component], ['role' => 'manager']));
$this->assertSame('', $this->render([$component], ['role' => 'guest']));
}
/**
* conditions if/else 체인의 else 브랜치(if 없음)는 항상 매칭됩니다.
*/
public function test_conditions_else_branch_always_matches(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'conditions' => [['if' => '{{never}}'], []],
'text' => '표시',
],
], ['never' => false]);
$this->assertStringContainsString('표시', $html);
}
/**
* 빈 AND 그룹은 true, 빈 OR 그룹은 false 로 평가됩니다 (React와 동일).
*/
public function test_conditions_empty_groups(): void
{
$and = $this->render([
['type' => 'basic', 'name' => 'Div', 'conditions' => ['and' => []], 'text' => 'AND'],
]);
$or = $this->render([
['type' => 'basic', 'name' => 'Div', 'conditions' => ['or' => []], 'text' => 'OR'],
]);
$this->assertStringContainsString('AND', $and);
$this->assertSame('', $or);
}
/**
* type: "iterator" 노드가 iteration 으로 변환되어 반복 렌더링됩니다.
*/
public function test_iterator_type_is_converted_to_iteration(): void
{
$html = $this->render([
[
'type' => 'iterator',
'name' => 'Div',
'data' => '{{items}}',
'itemName' => 'entry',
'children' => [
['type' => 'basic', 'name' => 'Span', 'text' => '{{entry.label}}'],
],
],
], ['items' => [['label' => '하나'], ['label' => '둘']]]);
$this->assertStringContainsString('하나', $html);
$this->assertStringContainsString('둘', $html);
}
/**
* type: "iterator" 의 itemName 을 생략하면 기본 별칭 item 이 사용됩니다.
*/
public function test_iterator_type_defaults_item_var(): void
{
$html = $this->render([
[
'type' => 'iterator',
'name' => 'Div',
'data' => '{{items}}',
'children' => [
['type' => 'basic', 'name' => 'Span', 'text' => '{{item.label}}'],
],
],
], ['items' => [['label' => '기본별칭']]]);
$this->assertStringContainsString('기본별칭', $html);
}
/**
* children 배열 내 문자열은 텍스트 노드로 출력됩니다.
*/
public function test_string_child_is_rendered_as_text_node(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'children' => ['문자열 조각', ['type' => 'basic', 'name' => 'Span', 'text' => '요소']],
],
]);
$this->assertStringContainsString('문자열 조각', $html);
$this->assertStringContainsString('<span>요소</span>', $html);
}
/**
* children 배열 내 문자열은 HTML 이스케이프되어 출력됩니다.
*/
public function test_string_child_is_escaped(): void
{
$html = $this->render([
['type' => 'basic', 'name' => 'Div', 'children' => ['<b>굵게</b>']],
]);
$this->assertStringContainsString('&lt;b&gt;', $html);
$this->assertStringNotContainsString('<b>', $html);
}
/**
* children 배열 내 문자열의 표현식은 해석하지 않고 리터럴로 둡니다.
*
* React DynamicRenderer 가 문자열 자식을 그대로 반환하므로(표현식 미해석),
* SEO 가 해석하면 반대 방향의 패리티 격차가 생깁니다.
*/
public function test_string_child_expression_is_not_resolved(): void
{
$html = $this->render([
['type' => 'basic', 'name' => 'Div', 'children' => ['{{value}}']],
], ['value' => '해석되면 안 됨']);
$this->assertStringContainsString('{{value}}', $html);
$this->assertStringNotContainsString('해석되면 안 됨', $html);
}
/**
* text 와 children 이 함께 있으면 text 가 우선합니다 (React와 동일 우선순위).
*/
public function test_node_text_takes_precedence_over_children(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'text' => '텍스트 우선',
'children' => [['type' => 'basic', 'name' => 'Span', 'text' => '자식']],
],
]);
$this->assertStringContainsString('텍스트 우선', $html);
$this->assertStringNotContainsString('자식', $html);
}
/**
* text 키가 있으면 해석 결과가 비어도 children 으로 폴백하지 않습니다.
*
* React 는 `text !== undefined` 인 순간 children 경로로 내려가지 않는다
* (DynamicRenderer.tsx:2591,2639). 폴백하면 봇 화면에만 없는 내용이 나타난다.
*/
public function test_empty_node_text_does_not_fall_back_to_children(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'text' => '{{missing.path}}',
'children' => [['type' => 'basic', 'name' => 'Span', 'text' => '자식폴백']],
],
], ['other' => 'x']);
$this->assertStringNotContainsString('자식폴백', $html);
}
/**
* `?? '$t:key'` 폴백으로 나온 번역 토큰이 text 에서 해석됩니다.
*
* bool/null 판정 때문에 text 해석 경로를 바꾸더라도, 파이프·인라인 `$t:` 후처리를
* 건너뛰면 안 된다 — 건너뛰면 봇 화면에 `$t:home.welcome_description` 이 그대로 남는다.
*/
public function test_translation_token_in_fallback_literal_is_resolved_in_node_text(): void
{
$this->evaluator->setTranslations(['home.welcome_description' => '환영합니다']);
$html = $this->render([
[
'type' => 'basic',
'name' => 'P',
'text' => "{{settings?.site_description ?? '\$t:home.welcome_description'}}",
],
], ['settings' => []]);
$this->assertStringContainsString('환영합니다', $html);
$this->assertStringNotContainsString('$t:', $html);
}
/**
* bool / null 로 평가되는 text 는 렌더되지 않습니다 (JSX 시맨틱).
*
* React 는 단일 바인딩 text 를 raw 값으로 받아 그대로 자식에 넘기므로 {false} 가
* 화면에 나오지 않는다. 문자열화하면 봇 화면에만 'false' 라는 글자가 노출된다.
*/
public function test_boolean_and_null_node_text_are_not_rendered(): void
{
$context = ['flags' => ['on' => true, 'off' => false, 'nothing' => null]];
foreach (['{{flags.off}}' => 'false', '{{flags.on}}' => 'true'] as $expression => $forbidden) {
$html = $this->render([
['type' => 'basic', 'name' => 'Span', 'text' => $expression],
], $context);
$this->assertStringNotContainsString($forbidden, $html, "text={$expression} 가 문자열로 노출되었습니다.");
}
$html = $this->render([
['type' => 'basic', 'name' => 'Span', 'text' => '{{flags.nothing}}'],
], $context);
$this->assertStringNotContainsString('null', $html);
}
/**
* 알 수 없는 conditions 형식은 렌더링됩니다 (React evaluateRenderCondition 동형).
*
* 여기서 숨기면 봇 화면에서만 노드가 사라진다 — #86 과 동일한 실패 모드.
*/
public function test_unknown_conditions_format_still_renders(): void
{
$cases = [
'빈 배열' => [],
'문자열 리스트' => ['{{flag}}'],
'if/then 없는 객체 리스트' => [['foo' => 1]],
'and/or 없는 객체' => ['foo' => 1],
];
foreach ($cases as $label => $conditions) {
$html = $this->render([
[
'type' => 'basic',
'name' => 'Span',
'conditions' => $conditions,
'text' => '렌더됨',
],
], ['flag' => true]);
$this->assertStringContainsString('렌더됨', $html, "conditions={$label} 이 숨겨졌습니다.");
}
}
/**
* 데스크톱에 매칭되는 responsive 키가 여러 개면 커스텀 범위가 프리셋을 이깁니다.
*
* React ResponsiveManager::getMatchingKey() 와 동일한 우선순위. 매칭 키를 전부
* 순차 병합하면 JSON 키 선언 순서에 따라 봇 화면 출력이 달라진다.
*/
public function test_responsive_custom_range_wins_over_preset(): void
{
$node = [
'type' => 'basic',
'name' => 'Div',
'text' => '기본',
'responsive' => [
'desktop' => ['text' => '프리셋'],
'1024-' => ['text' => '커스텀'],
],
];
$this->assertStringContainsString('커스텀', $this->render([$node]));
// 선언 순서를 뒤집어도 결과가 같아야 한다
$node['responsive'] = [
'1024-' => ['text' => '커스텀'],
'desktop' => ['text' => '프리셋'],
];
$html = $this->render([$node]);
$this->assertStringContainsString('커스텀', $html);
$this->assertStringNotContainsString('프리셋', $html);
}
/**
* 커스텀 범위끼리는 좁은 범위가 넓은 범위를 이깁니다.
*/
public function test_responsive_narrower_range_wins(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'text' => '기본',
'responsive' => [
'1024-' => ['text' => '넓은범위'],
'1024-1279' => ['text' => '좁은범위'],
],
],
]);
$this->assertStringContainsString('좁은범위', $html);
$this->assertStringNotContainsString('넓은범위', $html);
}
/**
* min > max 인 잘못된 범위 키는 무시됩니다 (React parseRange 와 동일).
*/
public function test_responsive_invalid_range_key_is_ignored(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'text' => '기본',
'responsive' => [
'1500-1000' => ['text' => '잘못된범위'],
],
],
]);
$this->assertStringContainsString('기본', $html);
$this->assertStringNotContainsString('잘못된범위', $html);
}
/**
* responsive.desktop 의 children 오버라이드가 적용됩니다.
*/
public function test_responsive_desktop_children_override(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'children' => [['type' => 'basic', 'name' => 'Span', 'text' => '기본자식']],
'responsive' => [
'desktop' => [
'children' => [['type' => 'basic', 'name' => 'Span', 'text' => '데스크톱자식']],
],
],
],
]);
$this->assertStringContainsString('데스크톱자식', $html);
$this->assertStringNotContainsString('기본자식', $html);
}
/**
* responsive.desktop 의 iteration 오버라이드가 적용됩니다.
*/
public function test_responsive_desktop_iteration_override(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'responsive' => [
'desktop' => [
'iteration' => ['source' => '{{items}}', 'item_var' => 'row'],
],
],
'children' => [['type' => 'basic', 'name' => 'Span', 'text' => '{{row.label}}']],
],
], ['items' => [['label' => 'A'], ['label' => 'B']]]);
$this->assertSame(2, substr_count($html, '<span>'));
}
/**
* 데스크톱 폭을 포함하는 숫자 범위 responsive 키가 적용됩니다.
*/
public function test_responsive_numeric_range_matching_desktop_is_applied(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'text' => '기본',
'responsive' => ['1024-' => ['text' => '넓은화면']],
],
]);
$this->assertStringContainsString('넓은화면', $html);
}
/**
* 데스크톱 폭 밖의 responsive 키(모바일/좁은 범위)는 무시됩니다.
*/
public function test_responsive_non_desktop_breakpoints_are_ignored(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'text' => '기본',
'responsive' => [
'mobile' => ['text' => '모바일'],
'portable' => ['text' => '포터블'],
'0-639' => ['text' => '좁은화면'],
],
],
]);
$this->assertStringContainsString('기본', $html);
$this->assertStringNotContainsString('모바일', $html);
$this->assertStringNotContainsString('포터블', $html);
$this->assertStringNotContainsString('좁은화면', $html);
}
/**
* if 결과 문자열 "null" / "undefined" 는 거짓으로 판정됩니다 (React와 동일).
*/
public function test_if_treats_null_and_undefined_strings_as_falsy(): void
{
foreach (['null', 'undefined', 'NULL', ' false ', '0'] as $value) {
$html = $this->render([
['type' => 'basic', 'name' => 'Div', 'if' => '{{flag}}', 'text' => '표시'],
], ['flag' => $value]);
$this->assertSame('', $html, "if 값 '{$value}' 는 거짓이어야 합니다.");
}
}
/**
* if 결과가 참이면 정상 렌더링됩니다 (거짓 목록 확장의 회귀 방지).
*/
public function test_if_truthy_values_still_render(): void
{
foreach ([true, 'yes', '1', 'nullable'] as $value) {
$html = $this->render([
['type' => 'basic', 'name' => 'Div', 'if' => '{{flag}}', 'text' => '표시'],
], ['flag' => $value]);
$this->assertStringContainsString('표시', $html);
}
}
// =========================================================================
// 헬퍼 메서드 — config 기반 설정 반환
// =========================================================================
@@ -4276,4 +4788,134 @@ class ComponentHtmlMapperTest extends TestCase
$this->assertStringContainsString('base-class default-class', $html);
}
/**
* Select 의 options 는 option 목록으로 렌더링됩니다.
*
* 일반 화면은 option 라벨을 그리는데 봇 화면에는 선택값(내부 식별자)이 글자로 나가고
* 있었다 (`<select>created_at_desc</select>`).
*/
public function test_select_renders_option_list_from_options_prop(): void
{
$components = [[
'type' => 'basic',
'name' => 'Select',
'props' => [
'className' => 'w-40',
'value' => 'created_at_desc',
'options' => '{{sortOptions ?? []}}',
],
]];
$context = ['sortOptions' => [
['value' => 'created_at_desc', 'label' => '최신순'],
['value' => 'rating_desc', 'label' => '평점 높은순'],
]];
$html = $this->mapper->render($components, $context, $this->evaluator);
$this->assertStringContainsString('<option value="created_at_desc">최신순</option>', $html);
$this->assertStringContainsString('<option value="rating_desc">평점 높은순</option>', $html);
$this->assertStringNotContainsString('>created_at_desc<', $html);
}
/**
* options 가 없는 Select 는 선택값을 글자로 노출하지 않습니다.
*/
public function test_select_without_options_does_not_leak_value_as_text(): void
{
$components = [[
'type' => 'basic',
'name' => 'Select',
'props' => ['value' => 'created_at_desc'],
]];
$html = $this->mapper->render($components, [], $this->evaluator);
$this->assertSame('<select></select>', $html);
}
/**
* children 으로 정의된 Option 은 option 태그로 보존됩니다.
*/
public function test_select_children_options_are_preserved(): void
{
$components = [[
'type' => 'basic',
'name' => 'Select',
'props' => ['value' => 'a'],
'children' => [
['type' => 'basic', 'name' => 'Option', 'props' => ['value' => 'a'], 'text' => '가나다'],
['type' => 'basic', 'name' => 'Option', 'props' => ['value' => 'b'], 'text' => '라마바'],
],
]];
$html = $this->mapper->render($components, [], $this->evaluator);
$this->assertStringContainsString('<option>가나다</option>', $html);
$this->assertStringContainsString('<option>라마바</option>', $html);
}
/**
* Input/Textarea 도 선택값을 글자로 노출하지 않습니다.
*/
public function test_form_controls_do_not_render_value_prop_as_text(): void
{
$components = [
['type' => 'basic', 'name' => 'Input', 'props' => ['value' => 'internal_code']],
['type' => 'basic', 'name' => 'Textarea', 'props' => ['value' => 'internal_code']],
];
$html = $this->mapper->render($components, [], $this->evaluator);
$this->assertStringNotContainsString('internal_code', $html);
}
/**
* 폼 제어가 아닌 태그는 종전대로 value prop 을 텍스트로 승계합니다 (기존 동작 보존).
*/
public function test_non_form_tag_still_uses_value_prop_as_text(): void
{
$components = [['type' => 'basic', 'name' => 'Span', 'props' => ['value' => '표시할 값']]];
$html = $this->mapper->render($components, [], $this->evaluator);
$this->assertStringContainsString('>표시할 값<', $html);
}
/**
* iterate 모드에서 item_attrs 와 item_content 는 함께 적용됩니다.
*/
public function test_iterate_mode_applies_attrs_and_content_together(): void
{
$components = [[
'type' => 'basic',
'name' => 'Select',
'props' => ['options' => '{{items ?? []}}'],
]];
$context = ['items' => [['value' => 'v1', 'label' => '라벨1']]];
$html = $this->mapper->render($components, $context, $this->evaluator);
$this->assertStringContainsString('<option value="v1">라벨1</option>', $html);
}
/**
* 속성만 선언된 iterate 모드(image_gallery)는 종전 동작을 유지합니다.
*/
public function test_iterate_mode_attrs_only_keeps_previous_behaviour(): void
{
$components = [[
'type' => 'composite',
'name' => 'ProductImageViewer',
'props' => ['images' => '{{images ?? []}}'],
]];
$context = ['images' => [['url' => 'https://cdn.test/a.png', 'alt' => '이미지']]];
$html = $this->mapper->render($components, $context, $this->evaluator);
$this->assertStringContainsString('<img src="https://cdn.test/a.png" alt="이미지">', $html);
}
}
+116
View File
@@ -462,4 +462,120 @@ class DataSourceResolverTest extends TestCase
&& str_contains($request->url(), 'page=3');
});
}
// =========================================================================
// 엔드포인트 표현식 — route 외 컨텍스트 (query / _global)
// =========================================================================
/**
* 엔드포인트 안의 {{query.xxx}} 표현식이 쿼리 파라미터로 치환됩니다.
*/
public function test_endpoint_resolves_query_expression(): void
{
Http::fake(['*' => Http::response(['data' => []], 200)]);
$dataSources = [
['id' => 'searchResults', 'endpoint' => '/api/search?q={{query?.q}}', 'method' => 'GET'],
];
$this->resolver->resolve($dataSources, ['searchResults'], [], 'ko', ['q' => '검색어']);
Http::assertSent(function ($request) {
return str_contains(urldecode($request->url()), 'q=검색어');
});
}
/**
* 엔드포인트 안의 {{_global.xxx}} 표현식이 전달된 컨텍스트로 치환됩니다.
*/
public function test_endpoint_resolves_global_expression(): void
{
Http::fake(['*' => Http::response(['data' => []], 200)]);
$dataSources = [
[
'id' => 'searchResults',
'endpoint' => "/api/search?type={{_global.searchActiveTab ?? 'all'}}&page={{_global.searchPage ?? 1}}",
'method' => 'GET',
],
];
$this->resolver->resolve(
$dataSources,
['searchResults'],
[],
'ko',
[],
['_global' => ['searchActiveTab' => 'products', 'searchPage' => 2]]
);
Http::assertSent(function ($request) {
return str_contains($request->url(), 'type=products')
&& str_contains($request->url(), 'page=2');
});
}
/**
* 전역 컨텍스트가 없으면 엔드포인트의 ?? 기본값이 적용됩니다.
*/
public function test_endpoint_global_expression_falls_back_to_default(): void
{
Http::fake(['*' => Http::response(['data' => []], 200)]);
$dataSources = [
[
'id' => 'searchResults',
'endpoint' => "/api/search?type={{_global.searchActiveTab ?? 'all'}}",
'method' => 'GET',
],
];
$this->resolver->resolve($dataSources, ['searchResults'], [], 'ko');
Http::assertSent(fn ($request) => str_contains($request->url(), 'type=all'));
}
/**
* 엔드포인트에 표현식이 없으면 원본 그대로 사용됩니다 (회귀 방지).
*/
public function test_endpoint_without_expression_is_unchanged(): void
{
Http::fake(['*' => Http::response(['data' => []], 200)]);
$dataSources = [
['id' => 'categories', 'endpoint' => '/api/categories', 'method' => 'GET'],
];
$this->resolver->resolve($dataSources, ['categories'], [], 'ko');
Http::assertSent(fn ($request) => str_ends_with($request->url(), '/api/categories'));
}
/**
* params 표현식에서도 _global 컨텍스트를 참조할 수 있습니다.
*/
public function test_params_resolve_global_expression(): void
{
Http::fake(['*' => Http::response(['data' => []], 200)]);
$dataSources = [
[
'id' => 'products',
'endpoint' => '/api/products',
'method' => 'GET',
'params' => ['currency' => "{{_global.preferredCurrency ?? 'KRW'}}"],
],
];
$this->resolver->resolve(
$dataSources,
['products'],
[],
'ko',
[],
['_global' => ['preferredCurrency' => 'USD']]
);
Http::assertSent(fn ($request) => str_contains($request->url(), 'currency=USD'));
}
}
+155 -13
View File
@@ -350,11 +350,11 @@ class ExpressionEvaluatorTest extends TestCase
$result = $this->evaluator->evaluate('{{a || b}}', $context);
$this->assertSame('true', $result);
$this->assertSame('1', $result);
}
/**
* || 연산자: 우측이 truthy이면 true를 반환합니다.
* || 연산자: 좌측이 falsy이면 우측 피연산자 값을 반환합니다.
*/
public function test_logical_or_right_truthy(): void
{
@@ -362,11 +362,11 @@ class ExpressionEvaluatorTest extends TestCase
$result = $this->evaluator->evaluate('{{a || b}}', $context);
$this->assertSame('true', $result);
$this->assertSame('1', $result);
}
/**
* || 연산자: 양쪽 모두 falsy이면 false를 반환합니다.
* || 연산자: 양쪽 모두 falsy이면 우측 피연산자 값을 반환합니다.
*/
public function test_logical_or_both_falsy(): void
{
@@ -374,11 +374,23 @@ class ExpressionEvaluatorTest extends TestCase
$result = $this->evaluator->evaluate('{{a || b}}', $context);
$this->assertSame('false', $result);
$this->assertSame('0', $result);
}
/**
* && 연산자: 양쪽 모두 truthy이면 true를 반환합니다.
* || 연산자: 값 폴백 관용구가 값을 그대로 반환합니다.
*
* 레이아웃이 `{{query?.q || ''}}` 를 값 폴백으로 사용하므로 boolean이 아니라
* 피연산자 값이어야 합니다 (프론트엔드 JS 평가와 동일).
*/
public function test_logical_or_value_fallback_idiom(): void
{
$this->assertSame('test', $this->evaluator->evaluate("{{query?.q || ''}}", ['query' => ['q' => 'test']]));
$this->assertSame('기본값', $this->evaluator->evaluate("{{query?.q || '기본값'}}", ['query' => []]));
}
/**
* && 연산자: 좌측이 truthy이면 우측 피연산자 값을 반환합니다.
*/
public function test_logical_and_both_truthy(): void
{
@@ -386,11 +398,11 @@ class ExpressionEvaluatorTest extends TestCase
$result = $this->evaluator->evaluate('{{a && b}}', $context);
$this->assertSame('true', $result);
$this->assertSame('1', $result);
}
/**
* && 연산자: 한쪽이 falsy이면 false를 반환합니다.
* && 연산자: 한쪽이 falsy이면 falsy 피연산자 값을 반환합니다.
*/
public function test_logical_and_one_falsy(): void
{
@@ -398,7 +410,7 @@ class ExpressionEvaluatorTest extends TestCase
$result = $this->evaluator->evaluate('{{a && b}}', $context);
$this->assertSame('false', $result);
$this->assertSame('0', $result);
}
/**
@@ -568,6 +580,52 @@ class ExpressionEvaluatorTest extends TestCase
$this->assertSame('(0 )', $result);
}
/**
* $t:defer: 접두사는 $t: 와 동일한 키로 해석됩니다.
*
* 프론트엔드의 지연 해석 표기를 SEO에서도 인식해야 합니다 — 미인식 시 키가
* `defer:xxx` 가 되어 사전 조회에 실패하고 빈 문자열이 출력됩니다.
*/
public function test_defer_translation_prefix_resolves_same_key(): void
{
$this->evaluator->setTranslations([
'shop' => ['product' => ['reviews_count' => '개 리뷰']],
]);
$result = $this->evaluator->evaluate('$t:defer:shop.product.reviews_count', []);
$this->assertSame('개 리뷰', $result);
}
/**
* $t:defer: 접두사가 파라미터와 함께 사용되어도 해석됩니다.
*/
public function test_defer_translation_prefix_with_params(): void
{
$this->evaluator->setTranslations([
'shop' => ['order' => ['total' => '총 {{amount}}원']],
]);
$result = $this->evaluator->evaluate('$t:defer:shop.order.total|amount=1000', []);
$this->assertSame('총 1000원', $result);
}
/**
* 텍스트 중간의 $t:defer: 토큰도 해석되며 raw 문자열이 남지 않습니다.
*/
public function test_inline_defer_translation_token(): void
{
$this->evaluator->setTranslations([
'shop' => ['product' => ['reviews_count' => '개 리뷰']],
]);
$result = $this->evaluator->evaluate('(0 $t:defer:shop.product.reviews_count)', []);
$this->assertSame('(0 개 리뷰)', $result);
$this->assertStringNotContainsString('defer', $result);
}
// ==========================================
// 후처리: 고립 연산자 정리
// ==========================================
@@ -2047,6 +2105,31 @@ class ExpressionEvaluatorTest extends TestCase
$this->assertSame('prefix hello suffix', $result);
}
/**
* evaluateRaw: 바인딩이 둘 이상이면 보간된 문자열을 반환합니다.
*
* 회귀: 양끝 앵커 정규식이 `{{a}} - {{b}}` 도 단일 바인딩으로 오인해
* 내부를 `a}} - {{b` 로 해석하던 문제.
*/
public function test_evaluate_raw_multiple_bindings_returns_interpolated_string(): void
{
$context = ['a' => '제목', 'b' => '작성자'];
$result = $this->evaluator->evaluateRaw('{{a}} - {{b}}', $context);
$this->assertSame('제목 - 작성자', $result);
}
/**
* evaluateRaw: 중첩 객체 리터럴은 내부에 `}}` 가 있어도 단일 바인딩으로 유지됩니다.
*/
public function test_evaluate_raw_nested_object_literal_stays_single_binding(): void
{
$result = $this->evaluator->evaluateRaw("{{ {outer: {inner: 'value'}} }}", []);
$this->assertSame(['outer' => ['inner' => 'value']], $result);
}
/**
* evaluateRaw: 미존재 경로는 null을 반환합니다.
*/
@@ -2135,7 +2218,7 @@ class ExpressionEvaluatorTest extends TestCase
{
$context = ['val' => 'test'];
$result = $this->evaluator->evaluate("{{[].includes(val)}}", $context);
$result = $this->evaluator->evaluate('{{[].includes(val)}}', $context);
$this->assertSame('false', $result);
}
@@ -2146,7 +2229,7 @@ class ExpressionEvaluatorTest extends TestCase
{
$context = ['status' => ['code' => 200]];
$result = $this->evaluator->evaluate("{{[200, 201, 204].includes(status?.code)}}", $context);
$result = $this->evaluator->evaluate('{{[200, 201, 204].includes(status?.code)}}', $context);
$this->assertSame('true', $result);
}
@@ -2823,7 +2906,7 @@ class ExpressionEvaluatorTest extends TestCase
public function test_object_literal_with_boolean_null(): void
{
$context = [];
$result = $this->evaluator->evaluateRaw("{{ {enabled: true, disabled: false, empty: null} }}", $context);
$result = $this->evaluator->evaluateRaw('{{ {enabled: true, disabled: false, empty: null} }}', $context);
$this->assertIsArray($result);
$this->assertTrue($result['enabled']);
@@ -3252,7 +3335,7 @@ class ExpressionEvaluatorTest extends TestCase
$result = $this->evaluator->evaluate('{{a || b}}', $context);
$this->assertSame('true', $result);
$this->assertSame('hello', $result);
}
/**
@@ -3339,4 +3422,63 @@ class ExpressionEvaluatorTest extends TestCase
$this->assertSame('가격: 15,000원', $result);
}
/**
* 비교식의 원본 값은 참/거짓입니다.
*
* 종전에는 null 을 돌려줘서, 확장 주입 props 의 `?? 기본값` 이 항상 적용되며
* 레이아웃이 선언한 판정(예: `isHtml`)이 통째로 뒤집혔습니다.
*/
public function test_raw_comparison_returns_boolean(): void
{
$context = ['post' => ['content_mode' => 'text']];
$this->assertFalse($this->evaluator->evaluateRaw("{{(post.content_mode ?? 'text') === 'html'}}", $context));
$this->assertTrue($this->evaluator->evaluateRaw("{{(post.content_mode ?? 'text') === 'text'}}", $context));
}
/**
* 부정·논리곱 표현식의 원본 값도 참/거짓입니다.
*/
public function test_raw_logical_expression_returns_boolean(): void
{
$context = ['reply' => ['is_secret' => false, 'status' => 'published']];
$this->assertTrue($this->evaluator->evaluateRaw('{{!reply.is_secret}}', $context));
$this->assertTrue($this->evaluator->evaluateRaw("{{reply.status === 'published' && !reply.is_secret}}", $context));
$this->assertFalse($this->evaluator->evaluateRaw("{{reply.status === 'blinded' && !reply.is_secret}}", $context));
}
/**
* `||` 값 폴백 관용구는 참/거짓이 아니라 피연산자 값을 유지합니다.
*/
public function test_raw_or_expression_keeps_operand_value(): void
{
$context = ['query' => ['q' => '']];
$this->assertSame('셔츠', $this->evaluator->evaluateRaw("{{query.q || '셔츠'}}", $context));
}
/**
* 삼항·화살표 함수는 비교식으로 오인되지 않습니다 (기존 동작 보존).
*/
public function test_raw_ternary_and_arrow_function_are_not_treated_as_boolean(): void
{
$context = ['_local' => ['expanded' => true], 'items' => [1, 2, 3]];
$this->assertSame('open', $this->evaluator->evaluateRaw("{{_local.expanded ? 'open' : 'closed'}}", $context));
$this->assertNotSame(true, $this->evaluator->evaluateRaw('{{items.filter(i => i > 1)}}', $context));
}
/**
* 경로·배열 해석은 종전대로 원본 값을 반환합니다 (회귀 방지).
*/
public function test_raw_path_resolution_is_unchanged(): void
{
$context = ['product' => ['tags' => ['a', 'b'], 'stock' => 0, 'name' => '상품']];
$this->assertSame(['a', 'b'], $this->evaluator->evaluateRaw('{{product.tags}}', $context));
$this->assertSame(0, $this->evaluator->evaluateRaw('{{product.stock}}', $context));
$this->assertSame('상품', $this->evaluator->evaluateRaw('{{product.name}}', $context));
}
}
File diff suppressed because it is too large Load Diff
+265
View File
@@ -0,0 +1,265 @@
<?php
namespace Tests\Unit\Seo;
use App\Seo\HtmlSanitizer;
use Tests\TestCase;
/**
* 봇 화면 HTML 정화기 검증.
*
* 배경: 봇 화면은 사용자 작성 본문(게시글·답변글·페이지·상품 설명)을 그대로 삽입하고
* 있었고, 주소에 봇 파라미터를 붙이면 저장된 `<script>` 가 실제로 실행됐다. 일반 화면은
* DOMPurify 로 정화한 결과를 그리므로 봇 화면도 같은 강도로 정화해야 한다.
*
* 차단 목록의 근거는 `HtmlContent.tsx` 의 DOMPurify 설정이다.
*
* @see HtmlSanitizer
*/
class HtmlSanitizerTest extends TestCase
{
private HtmlSanitizer $sanitizer;
/**
* 테스트 초기화 — 정화기를 준비합니다.
*/
protected function setUp(): void
{
parent::setUp();
$this->sanitizer = new HtmlSanitizer;
}
/**
* script 태그는 내용까지 제거된다.
*
* @effects active_content_is_removed, html_mode_content_sanitized
*/
public function test_script_tag_is_removed_with_its_content(): void
{
$html = $this->sanitizer->sanitize('<p>본문</p><script>window.__x=1</script>');
$this->assertStringContainsString('<p>본문</p>', $html);
$this->assertStringNotContainsString('<script', $html);
$this->assertStringNotContainsString('window.__x', $html);
}
/**
* 이벤트 핸들러 속성은 태그를 남기고 속성만 제거된다.
*
* @effects event_handler_attributes_are_stripped
*/
public function test_event_handler_attributes_are_stripped(): void
{
$html = $this->sanitizer->sanitize('<img src="a.png" onerror="window.__x=1" alt="설명">');
$this->assertStringContainsString('src="a.png"', $html);
$this->assertStringContainsString('alt="설명"', $html);
$this->assertStringNotContainsString('onerror', $html);
}
/**
* 목록에 없는 이벤트 핸들러(on* 전량)도 제거된다.
*
* @effects event_handler_attributes_are_stripped
*/
public function test_unlisted_event_handlers_are_stripped(): void
{
$html = $this->sanitizer->sanitize('<div onanimationstart="window.__x=1" onpointerover="y()">내용</div>');
$this->assertStringContainsString('내용', $html);
$this->assertStringNotContainsString('onanimationstart', $html);
$this->assertStringNotContainsString('onpointerover', $html);
}
/**
* 외부 콘텐츠 삽입 태그는 제거된다.
*
* @effects active_content_is_removed
*/
public function test_embedding_tags_are_removed(): void
{
$html = $this->sanitizer->sanitize(
'<iframe src="https://e.test"></iframe><object data="x"></object><embed src="y">본문'
);
$this->assertStringNotContainsString('<iframe', $html);
$this->assertStringNotContainsString('<object', $html);
$this->assertStringNotContainsString('<embed', $html);
$this->assertStringContainsString('본문', $html);
}
/**
* 폼 요소는 벗기되 내부 텍스트는 남긴다 (피싱 방지 + 본문 보존).
*
* @effects active_content_is_removed, safe_markup_and_text_are_preserved
*/
public function test_form_elements_are_unwrapped_but_text_is_kept(): void
{
$html = $this->sanitizer->sanitize('<form action="https://e.test"><p>안내 문구</p></form>');
$this->assertStringNotContainsString('<form', $html);
$this->assertStringContainsString('<p>안내 문구</p>', $html);
}
/**
* javascript: 스킴 링크는 href 만 제거되고 글자는 남는다.
*
* @effects dangerous_url_schemes_are_blocked
*/
public function test_javascript_scheme_href_is_removed(): void
{
$html = $this->sanitizer->sanitize('<a href="javascript:alert(1)">링크</a>');
$this->assertStringNotContainsString('javascript:', $html);
$this->assertStringContainsString('링크', $html);
}
/**
* 제어문자를 끼워 넣은 스킴 우회도 차단된다.
*
* @effects dangerous_url_schemes_are_blocked
*/
public function test_obfuscated_javascript_scheme_is_removed(): void
{
$html = $this->sanitizer->sanitize("<a href=\"java\tscript:alert(1)\">링크</a>");
$this->assertStringNotContainsString('script:', $html);
$this->assertStringContainsString('링크', $html);
}
/**
* 상대 경로·앵커·정상 스킴 링크는 보존된다.
*
* @effects safe_markup_and_text_are_preserved
*/
public function test_normal_urls_are_preserved(): void
{
$html = $this->sanitizer->sanitize(
'<a href="/board/free/1">내부</a><a href="#anchor">앵커</a><a href="mailto:a@b.test">메일</a>'
);
$this->assertStringContainsString('href="/board/free/1"', $html);
$this->assertStringContainsString('href="#anchor"', $html);
$this->assertStringContainsString('href="mailto:a@b.test"', $html);
}
/**
* 외부 링크에는 rel 이 보강된다 (일반 화면 후처리와 동일).
*
* @effects safe_markup_and_text_are_preserved
*/
public function test_external_link_gets_rel_attribute(): void
{
$html = $this->sanitizer->sanitize('<a href="https://external.test">외부</a>');
$this->assertStringContainsString('rel="noopener noreferrer"', $html);
}
/**
* 이미지 data URL 은 허용하고 그 외 data URL 은 차단한다.
*
* @effects dangerous_url_schemes_are_blocked, safe_markup_and_text_are_preserved
*/
public function test_data_url_is_allowed_for_images_only(): void
{
$allowed = $this->sanitizer->sanitize('<img src="data:image/png;base64,AAAA" alt="a">');
$blocked = $this->sanitizer->sanitize('<img src="data:text/html;base64,AAAA" alt="b">');
$this->assertStringContainsString('data:image/png', $allowed);
$this->assertStringNotContainsString('data:text/html', $blocked);
}
/**
* 일반 서식 태그와 한글 텍스트는 그대로 보존된다.
*
* @effects ordinary_formatting_survives_sanitization, safe_markup_and_text_are_preserved
*/
public function test_formatting_markup_and_multibyte_text_survive(): void
{
$source = '<h3>제목</h3><p><strong>굵게</strong> 그리고 <em>기울임</em></p>'
.'<ul><li>첫째</li><li>둘째</li></ul><blockquote>인용문</blockquote>';
$html = $this->sanitizer->sanitize($source);
foreach (['<h3>제목</h3>', '<strong>굵게</strong>', '<em>기울임</em>', '<li>첫째</li>', '<blockquote>인용문</blockquote>'] as $fragment) {
$this->assertStringContainsString($fragment, $html);
}
}
/**
* 표 마크업(게시글에서 흔함)은 보존된다.
*
* @effects ordinary_formatting_survives_sanitization
*/
public function test_table_markup_is_preserved(): void
{
$html = $this->sanitizer->sanitize('<table><tbody><tr><td>값</td></tr></tbody></table>');
$this->assertStringContainsString('<table>', $html);
$this->assertStringContainsString('<td>값</td>', $html);
}
/**
* 빈 문자열/공백은 빈 문자열을 반환한다.
*
* @effects safe_markup_and_text_are_preserved
*/
public function test_blank_input_returns_empty_string(): void
{
$this->assertSame('', $this->sanitizer->sanitize(''));
$this->assertSame('', $this->sanitizer->sanitize(" \n "));
}
/**
* 태그가 없는 평문은 그대로 남는다.
*
* @effects safe_markup_and_text_are_preserved
*/
public function test_plain_text_passes_through(): void
{
$html = $this->sanitizer->sanitize('태그 없는 본문입니다.');
$this->assertStringContainsString('태그 없는 본문입니다.', $html);
}
/**
* SVG 는 스크립트 실행 벡터라 통째로 제거된다.
*
* @effects active_content_is_removed
*/
public function test_svg_is_removed(): void
{
$html = $this->sanitizer->sanitize('<svg><script>window.__x=1</script></svg><p>본문</p>');
$this->assertStringNotContainsString('<svg', $html);
$this->assertStringNotContainsString('window.__x', $html);
$this->assertStringContainsString('<p>본문</p>', $html);
}
/**
* 주석은 제거된다.
*
* @effects active_content_is_removed
*/
public function test_comments_are_removed(): void
{
$html = $this->sanitizer->sanitize('<!-- 숨은 주석 --><p>본문</p>');
$this->assertStringNotContainsString('숨은 주석', $html);
$this->assertStringContainsString('<p>본문</p>', $html);
}
/**
* 중첩 구조 안쪽의 스크립트도 제거된다.
*
* @effects active_content_is_removed
*/
public function test_nested_script_is_removed(): void
{
$html = $this->sanitizer->sanitize('<div><section><p>본문</p><script>window.__x=1</script></section></div>');
$this->assertStringNotContainsString('<script', $html);
$this->assertStringContainsString('<p>본문</p>', $html);
}
}
+52 -2
View File
@@ -4,6 +4,7 @@ namespace Tests\Unit\Seo;
use App\Seo\ComponentHtmlMapper;
use App\Seo\ExpressionEvaluator;
use App\Seo\SeoRenderer;
use Tests\TestCase;
/**
@@ -71,6 +72,55 @@ class RealWorldRenderingTest extends TestCase
return $this->mapper->render($components, $context, $this->evaluator);
}
// ========================================================
// 0. $all_props 해석 회귀 — 단일/다중 바인딩 판정 변경의 영향
// ========================================================
/**
* $all_props 모드에서 다중 바인딩 prop 이 보간된 문자열로 해석됩니다.
*
* 회귀: 단일 바인딩 판정이 `{{a}} - {{b}}` 를 단일로 오인하면 내부가
* `a}} - {{b` 로 해석되어 값이 통째로 비워졌습니다.
*/
public function test_all_props_mode_interpolates_multi_binding_prop(): void
{
$html = $this->render([
[
'type' => 'layout',
'name' => 'Header',
'props' => [
'siteName' => '{{site.name}} - {{site.tagline}}',
],
],
], ['site' => ['name' => '그누보드7', 'tagline' => '오픈소스 CMS']]);
$this->assertStringContainsString('그누보드7 - 오픈소스 CMS', $html);
}
/**
* $all_props 모드에서 단일 바인딩 prop 은 원본 타입(배열)을 유지합니다.
*/
public function test_all_props_mode_preserves_single_binding_array(): void
{
$html = $this->render([
[
'type' => 'layout',
'name' => 'Header',
'props' => [
'siteName' => '{{site.name}}',
'menuItems' => '{{menu}}',
],
],
], [
'site' => ['name' => '그누보드7'],
'menu' => [
['label' => '공지', 'url' => '/board/notice'],
],
]);
$this->assertStringContainsString('그누보드7', $html);
}
// ========================================================
// 1. 태그 매핑 검증 — Icon, Img 등이 올바른 HTML 태그를 사용하는지
// ========================================================
@@ -751,7 +801,7 @@ class RealWorldRenderingTest extends TestCase
$this->assertTrue($hasFontAwesome, 'Font Awesome CSS가 seo-config.json에 포함되어야 합니다');
// SeoRenderer.getTemplateCssUrls()가 템플릿 CSS를 자동 해석하는지 확인
$renderer = app(\App\Seo\SeoRenderer::class);
$renderer = app(SeoRenderer::class);
$method = new \ReflectionMethod($renderer, 'getTemplateCssUrls');
$method->setAccessible(true);
@@ -764,7 +814,7 @@ class RealWorldRenderingTest extends TestCase
// dist/css/components.css → /api/templates/assets/{id}/css/components.css 변환 검증
foreach ($cssPaths as $cssPath) {
$servePath = preg_replace('#^dist/#', '', $cssPath);
$expectedUrl = '/api/templates/assets/sirsoft-basic/' . $servePath;
$expectedUrl = '/api/templates/assets/sirsoft-basic/'.$servePath;
$this->assertStringContainsString('components.css', $expectedUrl,
'템플릿 CSS URL이 올바르게 변환되어야 합니다');
}
@@ -0,0 +1,196 @@
<?php
namespace Tests\Unit\Seo;
use Tests\TestCase;
/**
* 봇 대상 레이아웃이 화면에 쓰는 데이터소스를 빠짐없이 선언했는지 감시합니다.
*
* 배경:
* 봇 렌더링은 `meta.seo.data_sources` 에 적힌 id 만 미리 조회한다. 화면이 쓰는
* 데이터소스가 목록에서 빠지면 그 값은 늘 비어 있고, 그 값을 조건으로 삼는 블록이
* 통째로 사라진다. 실제로 `users/posts` 레이아웃이 목록 전체를 `userPosts` 로
* 감싸 두고 그 id 를 선언하지 않아, 봇에게는 머리말과 꼬리말만 있는 빈 화면이
* 나갔다.
*
* 봇에 노출할 필요가 없어 일부러 뺀 데이터소스는 아래 EXEMPT 목록에 사유와 함께
* 등록한다. 목록에 없는 미선언 사용처가 나타나면 실패한다.
*/
class SeoLayoutDataSourceDeclarationTest extends TestCase
{
/**
* 검사 대상 템플릿의 레이아웃 디렉토리 (프로젝트 루트 기준 상대 경로).
*/
private const LAYOUT_ROOT = 'templates/_bundled/sirsoft-basic/layouts';
/**
* 의도적으로 봇 조회에서 제외한 데이터소스 — `레이아웃::데이터소스 id => 사유`.
*/
private const EXEMPT = [
'shop/index::recentProducts' => '브라우저에 저장된 열람 이력이 필요해 봇에게는 늘 빈 값',
'shop/show::productDownloadableCoupons' => '로그인 사용자 전용 쿠폰이라 봇에게 의미 없음',
];
/**
* 봇 대상 레이아웃은 화면이 참조하는 데이터소스를 모두 선언해야 합니다.
*
* @effects every_used_data_source_is_declared
*/
public function test_seo_layouts_declare_every_data_source_they_use(): void
{
$root = base_path(self::LAYOUT_ROOT);
$this->assertDirectoryExists($root);
$violations = [];
$checked = 0;
foreach ($this->layoutFiles($root) as $file) {
$layout = json_decode((string) file_get_contents($file), true);
if (! is_array($layout)) {
continue;
}
$seo = $layout['meta']['seo'] ?? null;
if (! is_array($seo) || ($seo['enabled'] ?? false) !== true) {
continue;
}
$checked++;
$layoutName = $this->layoutName($root, $file);
$declared = $seo['data_sources'] ?? [];
$body = $this->layoutBodyText($root, $file);
foreach ($layout['data_sources'] ?? [] as $dataSource) {
$id = $dataSource['id'] ?? null;
if (! is_string($id) || $id === '' || in_array($id, $declared, true)) {
continue;
}
if (isset(self::EXEMPT[$layoutName.'::'.$id])) {
continue;
}
if ($this->isReferenced($id, $body)) {
$violations[] = "{$layoutName}: '{$id}' 를 화면에서 참조하지만 meta.seo.data_sources 에 없음";
}
}
}
$this->assertGreaterThan(0, $checked, '봇 대상 레이아웃을 하나도 찾지 못했습니다 — 스캔 경로를 확인하세요.');
$this->assertSame([], $violations, implode(PHP_EOL, $violations));
}
/**
* 면제 목록에 죽은 항목이 남아 있지 않은지 확인합니다.
*
* @effects intentional_exclusions_are_documented
*/
public function test_exempt_entries_still_exist(): void
{
$root = base_path(self::LAYOUT_ROOT);
foreach (array_keys(self::EXEMPT) as $entry) {
[$layoutName, $id] = explode('::', $entry, 2);
$path = $root.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $layoutName).'.json';
$this->assertFileExists($path, "면제 항목의 레이아웃이 없습니다: {$entry}");
$layout = json_decode((string) file_get_contents($path), true);
$ids = array_column($layout['data_sources'] ?? [], 'id');
$this->assertContains($id, $ids, "면제 항목의 데이터소스가 없습니다: {$entry}");
}
}
/**
* 레이아웃 JSON 파일 목록을 반환합니다 (admin 하위 제외 — 봇 대상 아님).
*
* @param string $root 레이아웃 루트 절대 경로
* @return array<int, string> 파일 절대 경로 목록
*/
private function layoutFiles(string $root): array
{
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)
);
$files = [];
foreach ($iterator as $file) {
if (! $file->isFile() || $file->getExtension() !== 'json') {
continue;
}
$path = $file->getPathname();
if (str_contains(str_replace('\\', '/', $path), '/admin/')) {
continue;
}
$files[] = $path;
}
sort($files);
return $files;
}
/**
* 레이아웃 파일 경로를 레이아웃명으로 변환합니다.
*
* @param string $root 레이아웃 루트 절대 경로
* @param string $file 레이아웃 파일 절대 경로
* @return string 레이아웃명 (예: users/posts)
*/
private function layoutName(string $root, string $file): string
{
$relative = ltrim(str_replace('\\', '/', substr($file, strlen($root))), '/');
return preg_replace('/\.json$/', '', $relative) ?? $relative;
}
/**
* 레이아웃 본문 + 참조 partial 본문을 하나의 문자열로 모읍니다.
*
* 화면 구성이 partial 로 쪼개져 있어 자기 파일만 봐서는 참조 여부를 알 수 없습니다.
*
* @param string $root 레이아웃 루트 절대 경로
* @param string $file 레이아웃 파일 절대 경로
* @param array<string, bool> $visited 순환 방지용 방문 기록
* @return string 합쳐진 JSON 문자열
*/
private function layoutBodyText(string $root, string $file, array &$visited = []): string
{
$normalized = str_replace('\\', '/', $file);
if (isset($visited[$normalized]) || ! is_file($file)) {
return '';
}
$visited[$normalized] = true;
$raw = (string) file_get_contents($file);
$text = $raw;
preg_match_all('/"partial"\s*:\s*"([^"]+)"/', $raw, $matches);
foreach ($matches[1] ?? [] as $partial) {
$text .= $this->layoutBodyText($root, $root.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $partial), $visited);
}
return $text;
}
/**
* 데이터소스 id 가 표현식/조건에서 참조되는지 판정합니다.
*
* `data_sources` 선언부의 `"id": "..."` 는 참조가 아니므로 제외하고, 표현식 안에서
* 식별자로 등장하는 경우만 참조로 봅니다.
*
* @param string $id 데이터소스 id
* @param string $body 레이아웃 + partial 본문
* @return bool 참조하면 true
*/
private function isReferenced(string $id, string $body): bool
{
$withoutDeclarations = preg_replace('/"id"\s*:\s*"'.preg_quote($id, '/').'"/', '', $body) ?? $body;
return preg_match('/[{(\s|!?,]'.preg_quote($id, '/').'[?.\s)]/', $withoutDeclarations) === 1;
}
}
+382
View File
@@ -0,0 +1,382 @@
<?php
namespace Tests\Unit\Seo;
use App\Seo\ComponentHtmlMapper;
use Tests\TestCase;
/**
* SEO(봇) 렌더러가 해석하지 못하는 노드 키가 봇 대상 화면에 유입되는지 감시합니다.
*
* 배경 (이슈 #86):
* 같은 레이아웃 JSON 을 React 렌더러와 SEO PHP 렌더러가 각각 그린다. React 에만 새 노드
* 키 처리를 추가하면 봇 화면에서는 그 부분이 조용히 사라지는데, 실제로 확장 주입 props
* (`extensionPointProps`) 가 그렇게 누락되어 게시글·댓글·페이지·상품 설명 본문이
* 통째로 빈 요소로 나갔다.
*
* 이 테스트는 봇 대상 자산에서 사용되는 노드 최상위 키를 모두 모아, 아래 두 목록의
* 합집합에 속하는지 확인한다. 새 키가 등장하면 실패하므로 "SEO 렌더러가 이 키를
* 알아야 하는가" 를 반드시 판단하게 된다.
*
* 목록을 갱신할 때는 docs/backend/seo-system.md 의 지원 노드 키 표도 함께 갱신한다.
*
* @see ComponentHtmlMapper
*/
class SeoNodeKeyParityTest extends TestCase
{
/**
* SEO 렌더러가 해석하는 노드 키.
*/
private const SUPPORTED_KEYS = [
'name',
'type',
'props',
'children',
'text',
'if',
'condition',
'conditions',
'iteration',
'classMap',
'responsive',
'actions',
'extensionPointProps',
// type: "iterator" 형태의 반복 정의
'data',
'itemName',
'indexName',
];
/**
* SEO 렌더러가 의도적으로 무시하는 노드 키.
*
* 인터랙션·편집기·빌드 메타 전용이라 봇 화면 콘텐츠에 영향을 주지 않는다.
*/
private const INTENTIONALLY_IGNORED_KEYS = [
'id',
'key',
'comment',
'_comment',
// 설치 시점에 인라인 확장되므로 SEO 렌더 시에는 남지 않는다
'partial',
// 레이아웃 병합 단계에서 처리된다
'slot',
'slotOrder',
// extension_point 호스트의 기본 children — LayoutExtensionService 가 렌더러 진입 전에
// 주입 컴포넌트로 교체하거나(mode=replace) 그대로 펼쳐 두므로 SEO 렌더러에는 남지 않는다
'default',
// 인터랙션 전용
'lifecycle',
'onComponentEvent',
'dataKey',
'trackChanges',
'debounce',
'blur_until_loaded',
'sortable',
'itemTemplate',
'expandChildren',
'component_layout',
'isolatedState',
'isolatedScopeId',
'extensionPointCallbacks',
// extension_point 호스트가 선언하는 콜백 원본 — LayoutExtensionService 가 이를 읽어
// 주입 컴포넌트에 extensionPointCallbacks 로 부착한다. SEO 에는 액션 디스패처가 없다
'callbacks',
// 노드 최상위에 잘못 놓인 컴포넌트 prop (props 안에 있어야 정상).
// React 도 props 객체만 읽으므로(DynamicRenderer.tsx:2235) 양쪽 모두 무시 — 패리티 유지.
// 렌더링에 관여하지 않는 잔여 키이며, props 로 옮기면 아이콘 크기가 실제로 바뀌므로
// SEO 패리티와 분리해 판단할 사항이다
'size',
// 표현 전용 / 편집기 메타
'style',
'__source',
'_fromBase',
];
/**
* 봇 대상 자산에서 사용되는 노드 키가 모두 판정된 목록 안에 있습니다.
*
* @effects unclassified_node_key_is_detected
*/
public function test_all_node_keys_in_bot_facing_assets_are_classified(): void
{
$known = array_merge(self::SUPPORTED_KEYS, self::INTENTIONALLY_IGNORED_KEYS);
$unknown = [];
foreach ($this->botFacingLayoutFiles() as $file) {
$decoded = json_decode((string) file_get_contents($file), true);
if (! is_array($decoded)) {
continue;
}
foreach ($this->collectNodeKeys($decoded) as $key) {
if (! in_array($key, $known, true)) {
$unknown[$key][] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $file);
}
}
}
$this->assertSame([], $unknown, sprintf(
"SEO 렌더러 판정 목록에 없는 노드 키가 봇 대상 화면에 있습니다: %s\n".
'→ SEO 렌더러가 이 키를 해석해야 하는지 판단한 뒤, SeoNodeKeyParityTest 의 목록과 '.
'docs/backend/seo-system.md 의 지원 노드 키 표를 함께 갱신하세요.',
json_encode(array_map(fn ($files) => array_slice(array_unique($files), 0, 3), $unknown), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
));
}
/**
* 문서 루트가 곧 노드인 partial 파일도 실제로 스캔됩니다.
*
* 수집 진입점이 `components`/`slots` 뿐이면 봇 대상 자산의 대부분(partial)이 통째로
* 스킵되어 위 분류 단언이 항상 통과한다 — 이슈 #86 이 터진 `_reply_section.json` 이
* 바로 그 형태이므로, 감시망이 비어 있는 채로 초록이 되는 상황을 여기서 차단한다.
*
* @effects node_rooted_partials_are_scanned
*/
public function test_scan_covers_node_rooted_partial_documents(): void
{
$file = base_path('templates/_bundled/sirsoft-basic/layouts/partials/board/show/_reply_section.json');
$this->assertFileExists($file);
$document = json_decode((string) file_get_contents($file), true);
$this->assertIsArray($document);
$this->assertArrayNotHasKey('components', $document, '전제 변경: 이 파일이 더 이상 노드 루트 형태가 아닙니다.');
$keys = $this->collectNodeKeys($document);
$this->assertNotEmpty($keys, '노드 루트 partial 에서 키가 수집되지 않았습니다 — 수집 진입점 누락.');
$this->assertContains('children', $keys);
$this->assertNotContains('meta', $keys, '문서 메타 키가 노드 키로 잘못 수집되었습니다.');
// 자손까지 내려갔는지 (루트 키만 모으고 끝나면 안 된다)
$this->assertContains('iteration', $keys, '자손 노드까지 재귀하지 않았습니다.');
}
/**
* 스캔 대상 파일 중 노드 루트 partial 이 실제로 다수를 차지하며 모두 수집됩니다.
*
* @effects node_rooted_partials_are_scanned
*/
public function test_node_rooted_partials_are_not_silently_skipped(): void
{
$nodeRooted = 0;
$collected = 0;
foreach ($this->botFacingLayoutFiles() as $file) {
$document = json_decode((string) file_get_contents($file), true);
if (! is_array($document) || isset($document['components']) || isset($document['slots'])) {
continue;
}
if (! isset($document['type']) && ! isset($document['name'])) {
continue;
}
$nodeRooted++;
if ($this->collectNodeKeys($document) !== []) {
$collected++;
}
}
$this->assertGreaterThan(0, $nodeRooted, '노드 루트 partial 탐색 자체가 실패했습니다.');
$this->assertSame($nodeRooted, $collected, sprintf(
'노드 루트 partial %d 개 중 %d 개에서만 키가 수집되었습니다.',
$nodeRooted,
$collected
));
}
/**
* 지원/무시 목록이 서로 겹치지 않습니다 (분류 모호성 차단).
*
* @effects key_classification_is_unambiguous
*/
public function test_supported_and_ignored_key_lists_are_disjoint(): void
{
$this->assertSame(
[],
array_values(array_intersect(self::SUPPORTED_KEYS, self::INTENTIONALLY_IGNORED_KEYS)),
'같은 키가 지원 목록과 무시 목록에 동시에 있습니다.'
);
}
/**
* 봇 렌더링 대상이 되는 레이아웃/확장 자산 파일 목록을 반환합니다.
*
* 관리자 템플릿과 admin 레이아웃은 인증이 필요해 봇이 접근할 수 없으므로 제외합니다.
*
* @return array<int, string> 파일 절대 경로 목록
*/
private function botFacingLayoutFiles(): array
{
$roots = [
base_path('templates/_bundled/sirsoft-basic/layouts'),
base_path('templates/_bundled/sirsoft-basic/extensions'),
base_path('plugins/_bundled/sirsoft-ckeditor5/resources/extensions'),
base_path('plugins/_bundled/sirsoft-daum_postcode/resources/extensions'),
base_path('plugins/_bundled/sirsoft-gdpr/resources/extensions'),
];
$files = [];
foreach ($roots as $root) {
if (! is_dir($root)) {
continue;
}
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root));
foreach ($iterator as $entry) {
if (! $entry->isFile() || $entry->getExtension() !== 'json') {
continue;
}
$path = $entry->getPathname();
if (str_contains(str_replace('\\', '/', $path), '/admin/')) {
continue;
}
$files[] = $path;
}
}
return $files;
}
/**
* 문서(레이아웃/확장 자산) 최상위에만 나타나는 키.
*
* partial 파일은 문서 루트 자체가 컴포넌트 노드라(§collectNodeKeys) 루트 키를 노드 키로
* 수집하는데, 그중 이 목록의 키는 노드가 아니라 문서 메타이므로 제외한다.
*/
private const DOCUMENT_ONLY_KEYS = [
'meta',
'version',
'extends',
'layout_name',
'data_sources',
'computed',
'permissions',
'init_actions',
'modals',
'states',
'defines',
'error_handling',
'globalHeaders',
'scripts',
'priority',
'extension_point',
'target_layout',
'target_id',
'position',
'mode',
'injections',
];
/**
* 레이아웃/확장 자산에서 컴포넌트 노드의 최상위 키를 수집합니다.
*
* 진입점은 셋이다:
* 1. `components` / `slots.*` — 일반 레이아웃 문서, 확장 자산(`extension_point` 형)
* 2. `injections[].components` — 주입 목록 형 확장 자산
* 3. 위 어느 것도 없고 `type`/`name` 이 있으면 **문서 루트 자체가 컴포넌트 노드** (partial)
*
* 3번을 빠뜨리면 봇 대상 자산의 대부분(partial)이 통째로 감시망 밖에 남는다 — 이슈 #86 이
* 실제로 터진 `partials/board/show/_reply_section.json` 이 정확히 이 형태다.
*
* 각 노드에서 `children` / `default` / `responsive.*.children` / `itemTemplate` 로 재귀한다.
*
* @param array $document 디코드된 레이아웃/확장 자산
* @return array<int, string> 사용된 노드 키 목록 (중복 제거)
*/
private function collectNodeKeys(array $document): array
{
$keys = [];
$hasComponentEntryPoint = false;
foreach ($document['components'] ?? [] as $node) {
$hasComponentEntryPoint = true;
$this->collectFromNode($node, $keys);
}
$slots = $document['slots'] ?? [];
if (is_array($slots)) {
foreach ($slots as $slotNodes) {
if (! is_array($slotNodes)) {
continue;
}
$hasComponentEntryPoint = true;
foreach ($slotNodes as $node) {
$this->collectFromNode($node, $keys);
}
}
}
$injections = $document['injections'] ?? [];
if (is_array($injections)) {
foreach ($injections as $injection) {
foreach ($injection['components'] ?? [] as $node) {
$hasComponentEntryPoint = true;
$this->collectFromNode($node, $keys);
}
}
}
// partial: 문서 루트가 곧 컴포넌트 노드
if (! $hasComponentEntryPoint && (isset($document['type']) || isset($document['name']))) {
// 루트에서만 문서 메타 키를 제외하고, 자손은 일반 노드와 동일하게 수집한다
foreach (array_keys($document) as $key) {
if (! in_array((string) $key, self::DOCUMENT_ONLY_KEYS, true)) {
$keys[] = (string) $key;
}
}
$this->collectDescendantKeys($document, $keys);
}
return array_values(array_unique($keys));
}
/**
* 단일 노드와 그 자손의 최상위 키를 수집합니다.
*
* @param mixed $node 노드
* @param array $keys 수집 결과 (참조)
*/
private function collectFromNode(mixed $node, array &$keys): void
{
if (! is_array($node) || array_is_list($node)) {
return;
}
foreach (array_keys($node) as $key) {
$keys[] = (string) $key;
}
$this->collectDescendantKeys($node, $keys);
}
/**
* 노드의 자손(children / default / responsive.*.children / itemTemplate) 키를 수집합니다.
*
* @param array $node 노드
* @param array $keys 수집 결과 (참조)
*/
private function collectDescendantKeys(array $node, array &$keys): void
{
foreach (['children', 'default'] as $arrayKey) {
foreach ($node[$arrayKey] ?? [] as $child) {
$this->collectFromNode($child, $keys);
}
}
if (isset($node['responsive']) && is_array($node['responsive'])) {
foreach ($node['responsive'] as $override) {
foreach ($override['children'] ?? [] as $child) {
$this->collectFromNode($child, $keys);
}
}
}
if (isset($node['itemTemplate'])) {
$this->collectFromNode($node['itemTemplate'], $keys);
}
}
}
+192 -10
View File
@@ -4,10 +4,12 @@ namespace Tests\Unit\Seo;
use App\Contracts\Extension\ModuleManagerInterface;
use App\Contracts\Extension\PluginManagerInterface;
use App\Extension\AbstractModule;
use App\Extension\HookManager;
use App\Seo\ComponentHtmlMapper;
use App\Seo\DataSourceResolver;
use App\Seo\ExpressionEvaluator;
use App\Seo\PipeRegistry;
use App\Seo\SeoConfigMerger;
use App\Seo\SeoMetaResolver;
use App\Seo\SeoRenderer;
@@ -16,6 +18,7 @@ use App\Services\LayoutService;
use App\Services\PluginSettingsService;
use App\Services\SettingsService;
use App\Services\TemplateService;
use App\Support\AssetUrl;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Mockery;
@@ -86,8 +89,10 @@ class SeoRendererTest extends TestCase
}
if (is_array($value)) {
$locale = app()->getLocale();
return (string) ($value[$locale] ?? reset($value) ?? '');
}
return (string) ($value ?? '');
})
->byDefault();
@@ -105,7 +110,7 @@ class SeoRendererTest extends TestCase
// 기본 evaluator mock (setTranslations, getPipeRegistry 허용)
$this->evaluator->shouldReceive('setTranslations')
->byDefault();
$pipeRegistry = Mockery::mock(\App\Seo\PipeRegistry::class);
$pipeRegistry = Mockery::mock(PipeRegistry::class);
$pipeRegistry->shouldReceive('setLocale')->byDefault();
$this->evaluator->shouldReceive('getPipeRegistry')
->andReturn($pipeRegistry)
@@ -542,9 +547,17 @@ class SeoRendererTest extends TestCase
->andReturn($mergedLayout);
// seo.data_sources에 'product'만 지정 → resolve에 전달되는 seoDataSourceIds 확인
// 6번째 인자는 엔드포인트 표현식 평가용 사전 컨텍스트 (route/query/_global)
$this->dataSourceResolver
->shouldReceive('resolve')
->with($allDataSources, ['product'], ['id' => '1'], Mockery::any(), Mockery::any())
->with(
$allDataSources,
['product'],
['id' => '1'],
Mockery::any(),
Mockery::any(),
Mockery::on(fn ($preContext) => isset($preContext['_global']) && isset($preContext['route']))
)
->once()
->andReturn(['product' => ['data' => ['name' => '상품']]]);
@@ -753,6 +766,10 @@ class SeoRendererTest extends TestCase
],
], JSON_PRETTY_PRINT));
// 자산 URL 모드를 고정 — 운영 설정(general.asset_url_mode)에 따라
// 확장자 유지/제거 두 형태가 나오므로 검증 대상 형태를 명시한다.
AssetUrl::forceMode(AssetUrl::MODE_EXTENSION);
try {
$request = Request::create('/test-css');
@@ -800,6 +817,7 @@ class SeoRendererTest extends TestCase
$result = $this->renderer->render($request);
$this->assertNotNull($result);
} finally {
AssetUrl::forceMode(null);
@unlink("{$configDir}/template.json");
@rmdir($configDir);
}
@@ -909,6 +927,9 @@ class SeoRendererTest extends TestCase
?array $structuredData = null,
?array $computed = null,
?array $extensions = null,
?array $state = null,
?array $initLocal = null,
?array $initGlobal = null,
): array {
$seo = [
'enabled' => $seoEnabled,
@@ -948,6 +969,18 @@ class SeoRendererTest extends TestCase
$layout['computed'] = $computed;
}
if ($state !== null) {
$layout['state'] = $state;
}
if ($initLocal !== null) {
$layout['initLocal'] = $initLocal;
}
if ($initGlobal !== null) {
$layout['initGlobal'] = $initGlobal;
}
return $layout;
}
@@ -2332,7 +2365,9 @@ class SeoRendererTest extends TestCase
Mockery::on(function ($queryParams) {
return isset($queryParams['page']) && $queryParams['page'] === '2'
&& isset($queryParams['sort']) && $queryParams['sort'] === 'price_asc';
})
}),
// 엔드포인트 표현식 평가용 사전 컨텍스트에도 동일한 쿼리가 담긴다
Mockery::on(fn ($preContext) => ($preContext['query']['page'] ?? null) === '2')
)
->once()
->andReturn(['products' => ['data' => []]]);
@@ -2553,6 +2588,145 @@ class SeoRendererTest extends TestCase
$this->assertNotNull($result);
}
/**
* 레이아웃 최상위 state 블록이 _local 초기값으로 반영됩니다.
*/
public function test_render_applies_layout_state_block_to_local(): void
{
$this->runInitialStateTest(
layoutArgs: ['state' => ['q' => '', 'view' => 'list']],
assertContext: fn ($context) => ($context['_local']['q'] ?? null) === ''
&& ($context['_local']['view'] ?? null) === 'list',
);
}
/**
* initLocal 블록이 state 블록보다 우선합니다 (state 는 하위 호환 별칭).
*/
public function test_render_prefers_init_local_over_state_block(): void
{
$this->runInitialStateTest(
layoutArgs: [
'state' => ['view' => 'list'],
'initLocal' => ['view' => 'grid'],
],
assertContext: fn ($context) => ($context['_local']['view'] ?? null) === 'grid',
);
}
/**
* 레이아웃 최상위 initGlobal 블록이 _global 초기값으로 반영됩니다.
*/
public function test_render_applies_layout_init_global_block(): void
{
$this->runInitialStateTest(
layoutArgs: ['initGlobal' => ['searchActiveTab' => 'all', 'searchPage' => 1]],
assertContext: fn ($context) => ($context['_global']['searchActiveTab'] ?? null) === 'all'
&& ($context['_global']['searchPage'] ?? null) === 1,
);
}
/**
* init_actions의 setState(target: global)이 _global에 반영됩니다.
*/
public function test_render_applies_init_actions_global_set_state_to_global(): void
{
$this->runInitialStateTest(
layoutArgs: [
'initActions' => [
[
'handler' => 'setState',
'params' => ['target' => 'global', 'searchSortBy' => 'relevance'],
],
],
],
assertContext: fn ($context) => ($context['_global']['searchSortBy'] ?? null) === 'relevance',
);
}
/**
* init_actions의 setState(target: global)이 초기 initGlobal 값을 덮어씁니다.
*/
public function test_render_init_actions_global_overrides_init_global_block(): void
{
$this->runInitialStateTest(
layoutArgs: [
'initGlobal' => ['searchActiveTab' => 'all'],
'initActions' => [
[
'handler' => 'setState',
'params' => ['target' => 'global', 'searchActiveTab' => 'products'],
],
],
],
assertContext: fn ($context) => ($context['_global']['searchActiveTab'] ?? null) === 'products',
);
}
/**
* target: global 인 setState가 실은 _local 하위 객체는 _local로 병합됩니다.
*/
public function test_render_global_set_state_nested_local_is_merged_into_local(): void
{
$this->runInitialStateTest(
layoutArgs: [
'initActions' => [
[
'handler' => 'setState',
'params' => [
'target' => 'global',
'searchActiveTab' => 'all',
'_local' => ['q' => '검색어'],
],
],
],
],
assertContext: fn ($context) => ($context['_local']['q'] ?? null) === '검색어'
&& ! array_key_exists('_local', $context['_global']),
);
}
/**
* 레이아웃 초기 상태(state/initLocal/initGlobal/init_actions) 검증 공통 헬퍼입니다.
*
* @param array $layoutArgs buildMergedLayout 에 전달할 명명 인자
* @param \Closure $assertContext htmlMapper.render 에 전달된 컨텍스트 검증 클로저
*/
private function runInitialStateTest(array $layoutArgs, \Closure $assertContext): void
{
$request = Request::create('/search');
$this->setupRouteResolver('/search', [
'templateIdentifier' => 'sirsoft-basic',
'layoutName' => 'search/index',
'routeParams' => [],
'moduleIdentifier' => null,
'routeMeta' => [],
]);
$mergedLayout = $this->buildMergedLayout(...array_merge([
'seoEnabled' => true,
'components' => [['component' => 'Div', 'props' => []]],
], $layoutArgs));
$this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout);
$this->metaResolver->shouldReceive('resolve')
->once()
->andReturn($this->buildMetaResult(title: '검색'));
$this->htmlMapper->shouldReceive('render')
->with(Mockery::type('array'), Mockery::on($assertContext), Mockery::type(ExpressionEvaluator::class))
->once()
->andReturn('');
$viewMock = Mockery::mock(\Illuminate\View\View::class);
$viewMock->shouldReceive('render')->once()->andReturn('<html></html>');
View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock);
$this->assertNotNull($this->renderer->render($request));
}
/**
* init_actions에서 setState가 아닌 핸들러(loadFromLocalStorage, closeModal)는 무시됩니다.
*/
@@ -3330,7 +3504,7 @@ class SeoRendererTest extends TestCase
$this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout);
// 모듈 인스턴스 mock
$moduleMock = Mockery::mock(\App\Extension\AbstractModule::class)->makePartial();
$moduleMock = Mockery::mock(AbstractModule::class)->makePartial();
$moduleMock->shouldReceive('seoVariables')->andReturn([
'_common' => [
'commerce_name' => ['source' => 'setting', 'key' => 'basic_info.shop_name'],
@@ -3411,7 +3585,7 @@ class SeoRendererTest extends TestCase
);
$this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout);
$moduleMock = Mockery::mock(\App\Extension\AbstractModule::class)->makePartial();
$moduleMock = Mockery::mock(AbstractModule::class)->makePartial();
$moduleMock->shouldReceive('seoVariables')->andReturn([
'_common' => [
'commerce_name' => ['source' => 'setting', 'key' => 'basic_info.shop_name'],
@@ -3486,7 +3660,7 @@ class SeoRendererTest extends TestCase
);
$this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout);
$moduleMock = Mockery::mock(\App\Extension\AbstractModule::class)->makePartial();
$moduleMock = Mockery::mock(AbstractModule::class)->makePartial();
$moduleMock->shouldReceive('seoVariables')->andReturn([
'_common' => [
'commerce_name' => ['source' => 'setting', 'key' => 'basic_info.shop_name'],
@@ -3550,7 +3724,7 @@ class SeoRendererTest extends TestCase
);
$this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout);
$moduleMock = Mockery::mock(\App\Extension\AbstractModule::class)->makePartial();
$moduleMock = Mockery::mock(AbstractModule::class)->makePartial();
$moduleMock->shouldReceive('seoVariables')->andReturn([
'product' => [
'product_name' => ['source' => 'data', 'required' => true],
@@ -3685,6 +3859,7 @@ class SeoRendererTest extends TestCase
// 청취자: image_width 1200 으로 변경
HookManager::addFilter('core.seo.filter_og_data', function (array $og) {
$og['image_width'] = 1200;
return $og;
});
@@ -3705,7 +3880,10 @@ class SeoRendererTest extends TestCase
$result = $this->renderer->render($request);
$this->assertNotNull($result);
HookManager::resetAll();
// resetAll() 은 부팅 시 등록된 코어/확장 리스너까지 통째로 지운다 — HookManager 가
// static 이라 같은 프로세스의 이후 테스트가 그 영향을 받는다. 이 테스트가 등록한
// 필터만 해제한다 (파일 내 다른 hook 테스트와 동일 패턴).
HookManager::clearFilter('core.seo.filter_og_data');
}
/**
@@ -3738,8 +3916,11 @@ class SeoRendererTest extends TestCase
// 청취자: review 배열 주입
HookManager::addFilter('core.seo.filter_structured_data', function (?array $sd) {
if ($sd === null) return $sd;
if ($sd === null) {
return $sd;
}
$sd['review'] = [['@type' => 'Review', 'reviewBody' => 'Great!']];
return $sd;
});
@@ -3755,6 +3936,7 @@ class SeoRendererTest extends TestCase
$this->renderer->render($request);
HookManager::resetAll();
// resetAll() 대신 이 테스트가 등록한 필터만 해제 (위 og_data 테스트와 동일 사유)
HookManager::clearFilter('core.seo.filter_structured_data');
}
}
@@ -0,0 +1,159 @@
<?php
namespace Tests\Unit\Upgrades;
require_once __DIR__.'/../../../upgrades/data/7.0.6/migrations/02_ClearSeoPageCacheAfterRendererParityFix.php';
use App\Extension\Cache\CoreCacheDriver;
use App\Extension\UpgradeContext;
use App\Upgrades\Data\V7_0_6\Migrations\ClearSeoPageCacheAfterRendererParityFix;
use Tests\TestCase;
/**
* 7.0.6 업그레이드 스텝 — 봇 페이지 캐시 1회 무효화
*
* 7.0.6 이전 렌더러가 빈 본문으로 저장한 캐시가 남아 있으면, 렌더러를 고쳐도
* 캐시 수명 동안 검색 봇이 계속 빈 페이지를 받는다. 업그레이드 시 한 번 비운다.
*/
class ClearSeoPageCacheAfterRendererParityFixTest extends TestCase
{
private ClearSeoPageCacheAfterRendererParityFix $migration;
private CoreCacheDriver $cache;
/**
* 테스트 초기화 - 배열 캐시 스토어와 마이그레이션을 준비합니다.
*/
protected function setUp(): void
{
parent::setUp();
config()->set('cache.default', 'array');
$this->cache = new CoreCacheDriver('array');
$this->cache->flush();
$this->migration = new ClearSeoPageCacheAfterRendererParityFix;
}
/**
* 업그레이드 컨텍스트를 만듭니다.
*
* @return UpgradeContext 컨텍스트
*/
private function context(): UpgradeContext
{
return new UpgradeContext('7.0.5', '7.0.6', '7.0.6');
}
/**
* 인덱스에 등록된 캐시 항목과 인덱스 자체가 모두 삭제됩니다.
*
* @effects stale_page_cache_is_cleared_on_upgrade, cache_index_is_cleared_on_upgrade
*/
public function test_clears_indexed_page_cache_entries_and_index(): void
{
$this->cache->put('seo.page.abc', '<html>빈 본문</html>', 3600);
$this->cache->put('seo.page.def', '<html>빈 본문2</html>', 3600);
$this->cache->put('seo.cached_urls', [
['key' => 'seo.page.abc', 'url' => '/board/free/1'],
['key' => 'seo.page.def', 'url' => '/page/about'],
], 3600);
$this->migration->run($this->context());
$this->assertNull($this->cache->get('seo.page.abc'));
$this->assertNull($this->cache->get('seo.page.def'));
$this->assertNull($this->cache->get('seo.cached_urls'));
}
/**
* 인덱스가 없으면 아무 것도 하지 않습니다 (no-op).
*
* @effects invalidation_is_idempotent, unrelated_cache_namespaces_are_preserved
*/
public function test_no_index_is_noop(): void
{
$this->cache->put('unrelated.key', 'keep', 3600);
$this->migration->run($this->context());
$this->assertSame('keep', $this->cache->get('unrelated.key'));
}
/**
* 재실행해도 부작용이 없습니다 (멱등).
*
* @effects invalidation_is_idempotent
*/
public function test_is_idempotent(): void
{
$this->cache->put('seo.page.abc', '<html></html>', 3600);
$this->cache->put('seo.cached_urls', [['key' => 'seo.page.abc', 'url' => '/']], 3600);
$this->migration->run($this->context());
$this->migration->run($this->context());
$this->assertNull($this->cache->get('seo.page.abc'));
$this->assertNull($this->cache->get('seo.cached_urls'));
}
/**
* 인덱스 항목이 손상되어 있어도 나머지를 삭제하고 인덱스를 비웁니다.
*
* @effects stale_page_cache_is_cleared_on_upgrade, cache_index_is_cleared_on_upgrade
*/
public function test_malformed_index_entries_are_skipped(): void
{
$this->cache->put('seo.page.ok', '<html></html>', 3600);
$this->cache->put('seo.cached_urls', [
'not-an-array',
['url' => '/키없음'],
['key' => '', 'url' => '/빈키'],
['key' => 'seo.page.ok', 'url' => '/정상'],
], 3600);
$this->migration->run($this->context());
$this->assertNull($this->cache->get('seo.page.ok'));
$this->assertNull($this->cache->get('seo.cached_urls'));
}
/**
* 봇 페이지 캐시 외의 캐시는 건드리지 않습니다.
*
* @effects unrelated_cache_namespaces_are_preserved
*/
public function test_unrelated_cache_keys_are_preserved(): void
{
$this->cache->put('extension.middleware_index', ['a'], 3600);
$this->cache->put('seo.page.abc', '<html></html>', 3600);
$this->cache->put('seo.cached_urls', [['key' => 'seo.page.abc', 'url' => '/']], 3600);
$this->migration->run($this->context());
$this->assertSame(['a'], $this->cache->get('extension.middleware_index'));
}
/**
* 인덱스가 손상되어 다른 네임스페이스 키를 가리켜도 그 키는 지우지 않습니다.
*
* 삭제 대상을 봇 페이지 캐시 프리픽스(`seo.page.`)로 한정해, 오염된 인덱스가
* 무관한 캐시를 함께 날리는 사고를 막습니다.
*
* @effects unrelated_cache_namespaces_are_preserved, stale_page_cache_is_cleared_on_upgrade
*/
public function test_index_entries_outside_page_cache_namespace_are_not_forgotten(): void
{
$this->cache->put('extension.middleware_index', ['a'], 3600);
$this->cache->put('seo.page.abc', '<html></html>', 3600);
$this->cache->put('seo.cached_urls', [
['key' => 'seo.page.abc', 'url' => '/'],
// 오염된 엔트리 — 봇 페이지 캐시가 아닌 키를 가리킨다
['key' => 'extension.middleware_index', 'url' => '/'],
], 3600);
$this->migration->run($this->context());
$this->assertSame(['a'], $this->cache->get('extension.middleware_index'), '무관한 캐시가 삭제되었습니다.');
$this->assertNull($this->cache->get('seo.page.abc'), '봇 페이지 캐시는 삭제되어야 합니다.');
}
}
@@ -0,0 +1,143 @@
feature: 봇 화면의 확장 주입 본문 렌더링
description: |
레이아웃의 확장 포인트(extension_point)를 확장이 교체할 때, 호스트가 넘긴 props 가
봇용 서버 렌더링에서도 해석되는지의 시나리오 매트릭스.
기존 회귀 (#86):
위지윅 편집기 플러그인이 본문 영역을 교체하면 봇 화면의 본문이 통째로 빈 요소로
나갔다. 메타 태그에는 본문이 들어가지만 화면 본문 영역은 비어 있었다.
게시글 본문 / 답글의 원글 본문 / 댓글 본문 / 페이지 본문 / 상품 상세설명이 모두 해당.
해결 축:
- 주입 컴포넌트에 부착된 호스트 props 를 봇 렌더러의 평가 컨텍스트에 주입
- 주입된 값은 해당 노드의 서브트리 전체에 상속 (일반 화면 렌더러와 동일)
- 반복(댓글 목록) 안에서도 항목별 값이 각각 해석
- 호스트 조건이 거짓이면 서브트리 전체 미출력
- 확장이 꺼져 있을 때 쓰이는 기본 구성의 출력은 종전과 동일해야 함
axes:
ep_site: [board_post, board_parent, board_reply, page, product_detail, product_common_info]
injection: [extension_active, default_fallback]
nesting: [direct_child, grandchild, inside_iteration]
host_gate: [if_true, if_false]
content_kind: [plain_text_mode, html_mode, html_mode_with_dangerous_markup]
exclusions:
- { ep_site: board_parent, injection: default_fallback, reason: "원글 인용 영역의 기본 구성은 게시글 본문과 동일 노드 — board_post 기본 구성으로 대표 검증" }
- { ep_site: board_parent, nesting: grandchild, reason: "손자 상속은 대표 1개 화면(게시글)으로 검증" }
- { ep_site: board_parent, nesting: inside_iteration, reason: "원글 인용은 단건 — 반복 안에 놓이지 않음" }
- { ep_site: board_parent, host_gate: if_false, reason: "조건부 확장 포인트 실사용처는 상품 공통정보 하나 — 대표 1건으로 검증" }
- { ep_site: product_common_info, injection: default_fallback, reason: "조건이 거짓이면 기본 구성도 함께 미출력 — 게이트 축으로 검증" }
- { ep_site: product_common_info, nesting: grandchild, reason: "손자 상속은 대표 1개 화면(게시글)으로 검증" }
- { ep_site: product_common_info, nesting: inside_iteration, reason: "상품 공통정보는 단건 — 반복 안에 놓이지 않음" }
- { injection: default_fallback, nesting: grandchild, reason: "기본 구성은 주입 구조가 없어 중첩 상속 축이 성립하지 않음" }
- { injection: default_fallback, nesting: inside_iteration, reason: "동일 — 기본 구성은 주입 상속 대상 아님" }
- { injection: default_fallback, host_gate: if_false, reason: "기본 구성의 조건 게이트는 확장 주입과 무관한 일반 조건부 렌더링" }
- { host_gate: if_false, nesting: grandchild, reason: "게이트가 거짓이면 서브트리 전체가 미출력이라 하위 구조는 무의미" }
- { host_gate: if_false, nesting: inside_iteration, reason: "동일" }
- { host_gate: if_false, ep_site: board_post, reason: "조건부 확장 포인트 실사용처는 상품 공통정보 하나 — 대표 1건으로 검증" }
- { host_gate: if_false, ep_site: board_reply, reason: "동일" }
- { host_gate: if_false, ep_site: page, reason: "동일" }
- { host_gate: if_false, ep_site: product_detail, reason: "상품 상세설명 확장 포인트에는 조건이 없음 — 조건부는 공통정보 하나" }
- { nesting: grandchild, ep_site: board_reply, reason: "손자 상속은 대표 1개 화면(게시글)으로 검증" }
- { nesting: grandchild, ep_site: page, reason: "동일" }
- { nesting: grandchild, ep_site: product_detail, reason: "동일" }
- { nesting: inside_iteration, ep_site: board_post, reason: "반복 안의 확장 포인트 실사용처는 댓글 목록 하나" }
- { nesting: inside_iteration, ep_site: page, reason: "동일" }
- { nesting: inside_iteration, ep_site: product_detail, reason: "동일" }
- { content_kind: html_mode_with_dangerous_markup, injection: default_fallback, reason: "정화는 주입 여부와 무관한 출력 단계 동작 — 확장 활성 경로로 대표 검증" }
- { content_kind: html_mode_with_dangerous_markup, host_gate: if_false, reason: "게이트가 거짓이면 콘텐츠 자체가 출력되지 않음" }
- { content_kind: html_mode_with_dangerous_markup, nesting: grandchild, reason: "정화 동작은 중첩 깊이와 무관" }
- { content_kind: html_mode_with_dangerous_markup, nesting: inside_iteration, reason: "동일" }
- { content_kind: plain_text_mode, host_gate: if_false, reason: "게이트가 거짓이면 콘텐츠 자체가 출력되지 않음" }
- { content_kind: plain_text_mode, nesting: grandchild, reason: "이스케이프 동작은 중첩 깊이와 무관" }
- { content_kind: plain_text_mode, ep_site: product_common_info, reason: "공통정보 확장 포인트는 html 모드 조건에서만 노출 — 평문 모드가 성립하지 않음" }
- { content_kind: plain_text_mode, ep_site: board_parent, reason: "원글 인용은 게시글 본문과 같은 판정식을 쓰는 동일 노드 — board_post 로 대표 검증" }
- { content_kind: plain_text_mode, ep_site: page, reason: "이스케이프는 콘텐츠 출력 단계의 단일 경로라 화면별로 갈리지 않음 — 게시글·답글·상품 설명으로 대표 검증" }
- { content_kind: plain_text_mode, nesting: inside_iteration, reason: "반복 항목별로 이스케이프가 달라지지 않음 — 반복 축은 html 모드로 검증" }
- { content_kind: plain_text_mode, ep_site: board_reply, injection: default_fallback, reason: "기본 구성의 이스케이프는 게시글 기본 구성으로 대표 검증 — 같은 컴포넌트·같은 판정식" }
- { content_kind: plain_text_mode, ep_site: product_detail, injection: default_fallback, reason: "동일 — 기본 구성 경로는 화면과 무관한 단일 컴포넌트" }
- { content_kind: html_mode_with_dangerous_markup, ep_site: board_parent, reason: "정화는 콘텐츠 출력 단계의 단일 경로 — 게시글·상품 설명 두 화면으로 대표 검증" }
- { content_kind: html_mode_with_dangerous_markup, ep_site: board_reply, reason: "동일" }
- { content_kind: html_mode_with_dangerous_markup, ep_site: page, reason: "동일" }
- { content_kind: html_mode_with_dangerous_markup, ep_site: product_common_info, reason: "동일" }
effects:
- ep_props_resolved_into_context
- subtree_inherits_ep_props
- sibling_isolated_from_ep_props
- callbacks_not_injected
- default_fallback_unchanged
- nested_expression_resolved
- non_string_values_preserved
- iteration_scoped_ep_props
- host_gate_suppresses_subtree
- text_mode_content_escaped
- html_mode_content_sanitized
- ordinary_formatting_survives_sanitization
- form_control_value_not_exposed_as_text
sub_flows:
- id: bot_request_transport
description: |
봇 판정 경로별 실제 HTTP 응답. 봇용 렌더링은 `?_escaped_fragment_=` 쿼리와
봇 User-Agent 두 경로로 진입하는데, 한쪽만 검증하면 다른 경로에서 본문이 비어도
드러나지 않는다. 응답 캐시에 빈 본문이 굳는 경우까지 함께 확인한다.
effects:
- escaped_fragment_request_renders_content
- bot_user_agent_request_renders_content
- both_transports_render_same_content
- cached_response_keeps_content
- id: bot_screen_data_completeness
description: |
봇 화면이 조회할 데이터소스 선언 누락 감시. 화면이 쓰는 데이터소스가 선언에서
빠지면 그 값을 조건으로 삼은 블록이 통째로 사라져, 머리말과 꼬리말만 있는 빈
화면이 색인된다.
effects:
- every_used_data_source_is_declared
- intentional_exclusions_are_documented
- id: renderer_node_key_parity
description: |
일반 화면 렌더러에만 기능이 늘고 봇 렌더러가 방치되는 상황의 재발 감시.
레이아웃/확장 자산에 봇 렌더러가 모르는 노드 키가 등장하면 검출한다.
감시망 자체가 비어 있는 채로 초록이 되는 실패 모드(문서 루트가 곧 노드인
partial 을 스캔에서 빠뜨리는 경우)까지 함께 잠근다 — 이슈 #86 이 실제로 터진
`_reply_section.json` 이 정확히 그 형태다.
effects:
- unclassified_node_key_is_detected
- node_rooted_partials_are_scanned
- key_classification_is_unambiguous
- id: bot_html_sanitization
description: |
봇 화면에 사용자 작성 HTML 을 내보낼 때의 정화 계약. 일반 화면은 편집기
컴포넌트가 정화하지만 봇 화면은 서버가 직접 정화하므로, 한쪽만 막히면 봇 경로로만
저장형 스크립트가 실행된다. 위험 요소는 제거하되 정상 서식은 남겨야 한다.
effects:
- active_content_is_removed
- event_handler_attributes_are_stripped
- dangerous_url_schemes_are_blocked
- safe_markup_and_text_are_preserved
- id: stale_bot_cache_invalidation
description: |
렌더러를 고쳐도 이전 렌더러가 빈 본문으로 저장해 둔 봇 화면 캐시는 그대로 남는다.
캐시 수명이 긴 사이트는 검색 봇이 계속 빈 페이지를 받으므로 업그레이드 시 한 번
비운다. 삭제 범위가 넓어지면 무관한 캐시까지 날아가므로 네임스페이스 한정도 함께
잠근다.
effects:
- stale_page_cache_is_cleared_on_upgrade
- cache_index_is_cleared_on_upgrade
- invalidation_is_idempotent
- unrelated_cache_namespaces_are_preserved
test_files:
- tests/Unit/Seo/ExtensionPointPropsRenderingTest.php
- tests/Unit/Seo/SeoNodeKeyParityTest.php
- tests/Unit/Seo/HtmlSanitizerTest.php
- tests/Unit/Seo/SeoLayoutDataSourceDeclarationTest.php
- tests/Unit/Upgrades/ClearSeoPageCacheAfterRendererParityFixTest.php
- tests/Playwright/specs/seo-bot-rendering.spec.ts
+3
View File
@@ -13,6 +13,9 @@ use App\Extension\AbstractUpgradeStep;
* 01_NullifyUntouchedSeoCacheOverrides.php
* SEO 캐시 설정을 "미설정(null) = 고급 탭 값 사용" 의미로 이행 (결정 D19).
* 옛 기본값 그대로인 키는 null 로 비우고, 다른 값이면 오버라이드로 보존한다.
* 02_ClearSeoPageCacheAfterRendererParityFix.php
* 봇용 페이지 캐시를 1회 전량 무효화한다. 7.0.6 이전 렌더러가 빈 본문으로
* 저장한 결과물이 캐시 수명 동안 계속 서빙되는 것을 막는다.
*
* 본 클래스는 `AbstractUpgradeStep` 의 default `run()` 에 위임 — 별도 override 없음.
*
@@ -0,0 +1,129 @@
<?php
namespace App\Upgrades\Data\V7_0_6\Migrations;
use App\Extension\Cache\CoreCacheDriver;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\Log;
/**
* 봇용 페이지 캐시를 1회 전량 무효화합니다.
*
* 배경:
* 7.0.6 이전의 봇 렌더러는 레이아웃 확장이 교체한 본문 영역(게시글·댓글·페이지·상품
* 설명)을 빈 요소로 내보냈고, 그 결과물이 페이지 캐시에 그대로 저장되어 있다.
* 렌더러를 고쳐도 캐시가 유효한 동안에는 검색 봇이 계속 빈 본문을 받는다.
* 캐시 수명(TTL)을 길게 잡아 둔 사이트일수록 오래 남으므로 업그레이드 시 한 번 비운다.
*
* 멱등: 인덱스가 없으면 아무 것도 하지 않는다. 재실행해도 부작용이 없다.
*
* 실패 정책: 캐시 삭제 실패가 업그레이드 전체를 막지 않는다(경고 후 계속).
* 비우지 못해도 캐시 수명이 지나면 자연히 새 결과물로 교체된다.
*
* 격리 원칙 (docs/extension/upgrade-step-guide.md §13):
* `SeoCacheManager` 를 참조하지 않는다. 캐시 키 프리픽스와 인덱스 키를 본 클래스에
* 로컬 상수로 동결해 V-1 안전을 확보한다 — 미래 버전에서 그 클래스의 상수가 바뀌어도
* 본 스텝은 7.0.6 시점의 캐시를 대상으로 동작해야 한다.
*/
final class ClearSeoPageCacheAfterRendererParityFix implements DataMigration
{
/**
* 봇 페이지 캐시가 저장한 URL 인덱스 키 (7.0.6 시점 고정값).
*/
private const INDEX_KEY = 'seo.cached_urls';
/**
* 봇 페이지 캐시 키 프리픽스 (7.0.6 시점 고정값).
*
* 인덱스가 손상되어 다른 네임스페이스의 키가 섞여 있어도 봇 페이지 캐시 밖의
* 데이터를 지우지 않도록, 삭제 대상을 이 프리픽스로 한정합니다.
*/
private const CACHE_PREFIX = 'seo.page.';
/**
* 마이그레이션 식별자 (로그용).
*
* @return string 식별자
*/
public function name(): string
{
return 'ClearSeoPageCacheAfterRendererParityFix';
}
/**
* 저장된 봇 페이지 캐시를 전량 삭제합니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
*/
public function run(UpgradeContext $context): void
{
try {
$this->runInternal($context);
} catch (\Throwable $e) {
// 업그레이드 중단 금지 — 캐시 삭제 실패가 업그레이드 전체를 막지 않는다.
$context->logger->warning(sprintf(
'[7.0.6] 봇 페이지 캐시 무효화 실패 (캐시 수명 만료로 자연 해소, 계속 진행): %s',
$e->getMessage(),
));
Log::warning('7.0.6 ClearSeoPageCacheAfterRendererParityFix 실패', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
/**
* 실제 삭제 로직.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
*/
private function runInternal(UpgradeContext $context): void
{
$cache = $this->resolveCache();
if ($cache === null) {
$context->logger->info('[7.0.6] 봇 페이지 캐시 드라이버 미가용 — skip');
return;
}
$index = $cache->get(self::INDEX_KEY);
if (! is_array($index) || $index === []) {
$context->logger->info('[7.0.6] 봇 페이지 캐시 — 비울 대상 없음');
return;
}
$count = 0;
foreach ($index as $entry) {
$key = is_array($entry) ? ($entry['key'] ?? null) : null;
if (! is_string($key) || ! str_starts_with($key, self::CACHE_PREFIX)) {
continue;
}
$cache->forget($key);
$count++;
}
$cache->forget(self::INDEX_KEY);
$context->logger->info(sprintf('[7.0.6] 봇 페이지 캐시 %d건 무효화 완료', $count));
}
/**
* 코어 캐시 드라이버를 반환합니다.
*
* 컨테이너 바인딩에 의존하지 않고 직접 생성합니다 — 업그레이드 시점에는 바인딩이
* 없을 수 있습니다. 생성 실패 시 null 을 반환해 스텝을 건너뜁니다.
*
* @return CoreCacheDriver|null 캐시 드라이버 (미가용 시 null)
*/
private function resolveCache(): ?CoreCacheDriver
{
try {
return new CoreCacheDriver(config('cache.default', 'array'));
} catch (\Throwable) {
return null;
}
}
}