From 0dfb45951bf8adf86ef26bf6644485ff039f91da Mon Sep 17 00:00:00 2001 From: HeuJung Date: Mon, 27 Jul 2026 10:03:06 +0900 Subject: [PATCH] =?UTF-8?q?fix(core,basic):=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EB=B4=87=20=ED=99=94=EB=A9=B4=20=EB=A0=8C=EB=8D=94=EB=9F=AC=20?= =?UTF-8?q?=ED=8C=A8=EB=A6=AC=ED=8B=B0=20=EA=B2=B0=EC=86=90=20=EC=9D=BC?= =?UTF-8?q?=EA=B4=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일반 화면(React)과 봇 화면(PHP)은 같은 레이아웃을 각각 렌더하는데 봇 화면 쪽 지원 범위가 뒤처져, 확장이 교체한 본문이 통째로 빈 채 나갔다 (공개 이슈 ). 같은 결손 계열을 전수 조사해 함께 닫는다. - 확장 주입 props 를 렌더 진입 시점에 해석해 자손 전체에 상속 → 게시글·답글 원글·댓글·페이지·상품 설명 5개 지점 복구 - 사용자 작성 HTML 정화기 신설 — isHtml 판정에 따라 이스케이프/정화. 미정화 상태에서 봇 경로로 저장형 스크립트가 실제 실행됐다 - 노드 키 패리티: conditions / type:iterator / responsive 매칭 규칙 / children 문자열 / text 우선순위 / $t:defer / 레이아웃 초기 상태 - 표현식: 단일 바인딩 판정 교체, ||·&& 를 값 반환(JS 시맨틱)으로, 비교식이 raw 경로에서 null 이 되던 문제 - 데이터소스: 엔드포인트 표현식 전면 해석 + 레이아웃 8곳 선언 보강 - 봇 페이지 캐시 1회 전량 무효화 업그레이드 스텝 지원 범위 SSoT 표를 문서에 고정하고, 한쪽만 고치는 것을 막는 검사 룰과 레이아웃 전수 순회 테스트로 잠갔다. --- CHANGELOG.md | 28 + app/Seo/ComponentHtmlMapper.php | 635 +++++++++- app/Seo/DataSourceResolver.php | 38 +- app/Seo/ExpressionEvaluator.php | 148 ++- app/Seo/HtmlSanitizer.php | 300 +++++ app/Seo/SeoRenderer.php | 113 +- docs/backend/seo-system.md | 106 +- docs/extension/layout-extensions.md | 17 + docs/frontend/layout-json.md | 4 + templates/_bundled/sirsoft-basic/CHANGELOG.md | 14 + .../sirsoft-basic/layouts/board/popular.json | 1 + .../sirsoft-basic/layouts/board/show.json | 2 +- .../partials/search/products/_item_list.json | 10 +- .../sirsoft-basic/layouts/search/index.json | 2 +- .../sirsoft-basic/layouts/shop/index.json | 4 +- .../sirsoft-basic/layouts/shop/show.json | 2 +- .../sirsoft-basic/layouts/users/posts.json | 4 +- .../sirsoft-basic/layouts/users/show.json | 4 +- .../_bundled/sirsoft-basic/seo-config.json | 14 +- tests/Feature/Seo/SeoPageRenderingTest.php | 31 +- .../specs/seo-bot-rendering.spec.ts | 360 ++++++ tests/Unit/Seo/ComponentHtmlMapperTest.php | 644 +++++++++- tests/Unit/Seo/DataSourceResolverTest.php | 116 ++ tests/Unit/Seo/ExpressionEvaluatorTest.php | 168 ++- .../Seo/ExtensionPointPropsRenderingTest.php | 1118 +++++++++++++++++ tests/Unit/Seo/HtmlSanitizerTest.php | 265 ++++ tests/Unit/Seo/RealWorldRenderingTest.php | 54 +- .../SeoLayoutDataSourceDeclarationTest.php | 196 +++ tests/Unit/Seo/SeoNodeKeyParityTest.php | 382 ++++++ tests/Unit/Seo/SeoRendererTest.php | 202 ++- ...SeoPageCacheAfterRendererParityFixTest.php | 159 +++ .../seo-extension-point-rendering.yaml | 143 +++ upgrades/Upgrade_7_0_6.php | 3 + ...learSeoPageCacheAfterRendererParityFix.php | 129 ++ 34 files changed, 5244 insertions(+), 172 deletions(-) create mode 100644 app/Seo/HtmlSanitizer.php create mode 100644 tests/Playwright/specs/seo-bot-rendering.spec.ts create mode 100644 tests/Unit/Seo/ExtensionPointPropsRenderingTest.php create mode 100644 tests/Unit/Seo/HtmlSanitizerTest.php create mode 100644 tests/Unit/Seo/SeoLayoutDataSourceDeclarationTest.php create mode 100644 tests/Unit/Seo/SeoNodeKeyParityTest.php create mode 100644 tests/Unit/Upgrades/ClearSeoPageCacheAfterRendererParityFixTest.php create mode 100644 tests/scenarios/seo-extension-point-rendering.yaml create mode 100644 upgrades/data/7.0.6/migrations/02_ClearSeoPageCacheAfterRendererParityFix.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d8950ee..1e9fbe1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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가 나타났습니다. 이제 끝 슬래시가 있어도 정상적으로 같은 페이지로 연결됩니다. + +#### 메뉴·플러그인 설정 무결성 + - 메뉴를 수정할 때 자기 자신의 하위 메뉴 아래로 옮길 수 있던 문제를 수정했습니다. 이렇게 옮기면 메뉴 트리가 끊어져 관리자 화면에서 되돌릴 수 없었습니다. 메뉴 순서 변경 화면과 동일한 기준이 적용됩니다. - 플러그인 설정을 저장할 때, 그 플러그인이 제공하지 않는 항목까지 설정 파일에 함께 기록되던 문제를 수정했습니다. 이제 플러그인이 실제로 제공하는 설정 항목만 저장됩니다. diff --git a/app/Seo/ComponentHtmlMapper.php b/app/Seo/ComponentHtmlMapper.php index b336e875..c2420e64 100644 --- a/app/Seo/ComponentHtmlMapper.php +++ b/app/Seo/ComponentHtmlMapper.php @@ -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 는 `created_at_desc`) 두 화면이 어긋나고 + * 색인에도 내부 식별자가 섞입니다. + */ + 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}"; @@ -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 가 함께 선언되면 속성과 라벨을 모두 그린다 — `` + // 처럼 둘이 다 필요한 요소를 표현하기 위함이다. + $content = $this->resolveFieldPattern($itemContent, $item); + $evaluatedContent = $content === '' + ? '' + : e($evaluator->evaluate((string) $content, $context)); + + if ($evaluatedContent === '' && $attrStr === '') { + continue; + } + + $badge = ''; + if ($badgeField && isset($item[$badgeField])) { + $evaluatedBadge = $evaluator->evaluate((string) $item[$badgeField], $context); + if ($evaluatedBadge !== '' && $evaluatedBadge !== '0') { + $badge = ' ('.e($evaluatedBadge).')'; } } + $html .= "<{$itemTag}{$attrStr}>".$evaluatedContent.$badge.""; + } elseif ($itemAttrs) { + // 속성 기반 렌더링 (이미지 갤러리 등) if (in_array($itemTag, $this->selfClosing)) { $html .= "<{$itemTag}{$attrStr}>"; } else { $html .= "<{$itemTag}{$attrStr}>"; } - } 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 = ' ('.e($evaluatedBadge).')'; - } - } - $html .= "<{$itemTag}>".$evaluatedContent.$badge.""; - } } 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 로 출력 + * + * 정화·이스케이프를 생략하면 봇 화면에서만 사용자 작성 `'; + + $node = $this->applyExtensionPoint( + [ + 'content' => "{{post.data?.content ?? ''}}", + 'isHtml' => "{{(post.data?.content_mode ?? 'text') === 'html'}}", + ], + [[ + 'type' => 'composite', + 'name' => 'HtmlContent', + 'props' => [ + 'content' => "{{extensionPointProps.content ?? ''}}", + 'isHtml' => '{{extensionPointProps.isHtml ?? true}}', + ], + ]] + ); + + $html = $this->render([$node], ['post' => ['data' => [ + 'content' => $payload, + 'content_mode' => 'text', + ]]]); + + $this->assertStringNotContainsString('assertStringNotContainsString('', $html); + $this->assertStringContainsString('<b>굵게</b>', $html); + } + + /** + * html 모드 본문은 정화된 뒤 출력됩니다 (일반 화면의 DOMPurify 와 동일 강도). + * + * @scenario ep_site=board_post, injection=extension_active, nesting=direct_child, host_gate=if_true, content_kind=html_mode_with_dangerous_markup + * + * @effects html_mode_content_sanitized + */ + public function test_html_mode_content_is_sanitized_before_output(): void + { + $payload = '

본문

' + .''; + + $node = $this->applyExtensionPoint( + [ + 'content' => "{{post.data?.content ?? ''}}", + 'isHtml' => "{{(post.data?.content_mode ?? 'text') === 'html'}}", + ], + [[ + 'type' => 'composite', + 'name' => 'HtmlContent', + 'props' => [ + 'content' => "{{extensionPointProps.content ?? ''}}", + 'isHtml' => '{{extensionPointProps.isHtml ?? true}}', + ], + ]] + ); + + $html = $this->render([$node], ['post' => ['data' => [ + 'content' => $payload, + 'content_mode' => 'html', + ]]]); + + $this->assertStringContainsString('

본문

', $html); + $this->assertStringContainsString('src="a.png"', $html); + $this->assertStringNotContainsString('assertStringNotContainsString('onerror', $html); + $this->assertStringNotContainsString('applyExtensionPoint( + [ + 'content' => "{{reply?.content ?? ''}}", + 'isHtml' => "{{(reply?.content_mode ?? 'text') === 'html'}}", + ], + $this->ckeditorInjection() + ); + + $html = $this->render([$node], ['reply' => ['content' => '답글 본문']]); + + $this->assertStringContainsString('답글 본문', $html); + $this->assertStringNotContainsString('applyExtensionPoint( + [ + 'content' => "{{product.data?.description_localized ?? ''}}", + 'isHtml' => "{{(product.data?.description_mode ?? 'text') === 'html'}}", + ], + $this->ckeditorInjection() + ); + + $html = $this->render([$node], ['product' => ['data' => [ + 'description_localized' => '

상품 설명

', + 'description_mode' => 'text', + ]]]); + + $this->assertStringContainsString('<p>상품 설명</p>', $html); + } + + /** + * 상품 상세설명의 서식 본문도 정화 후 출력됩니다. + * + * @scenario ep_site=product_detail, injection=extension_active, nesting=direct_child, host_gate=if_true, content_kind=html_mode_with_dangerous_markup + * + * @effects html_mode_content_sanitized + */ + public function test_product_description_in_html_mode_is_sanitized(): void + { + $node = $this->applyExtensionPoint( + [ + 'content' => "{{product.data?.description_localized ?? ''}}", + 'isHtml' => "{{(product.data?.description_mode ?? 'text') === 'html'}}", + ], + $this->ckeditorInjection() + ); + + $html = $this->render([$node], ['product' => ['data' => [ + 'description_localized' => '

상품 설명

', + 'description_mode' => 'html', + ]]]); + + $this->assertStringContainsString('

상품 설명

', $html); + $this->assertStringNotContainsString('assertStringNotContainsString('render([[ + 'type' => 'composite', + 'name' => 'HtmlContent', + 'props' => [ + 'content' => "{{post?.data?.content ?? ''}}", + 'isHtml' => "{{(post?.data?.content_mode ?? 'text') === 'html'}}", + ], + ]], ['post' => ['data' => [ + 'content' => '기본 구성 본문', + 'content_mode' => 'text', + ]]]); + + $this->assertStringContainsString('기본 구성 본문', $html); + $this->assertStringNotContainsString('assertTrue(file_exists($layoutPath), 'basic/show.json 이 존재해야 합니다.'); + $this->assertTrue(file_exists($extensionPath), 'html-content.json 이 존재해야 합니다.'); + + $layout = json_decode(file_get_contents($layoutPath), true); + $extension = json_decode(file_get_contents($extensionPath), true); + + // 게시글 본문 EP 는 id 가 없으므로 content 표현식으로 찾는다. + $host = $this->findExtensionPointByContentExpression($layout, "{{post?.data?.content ?? ''}}"); + $this->assertIsArray($host, '게시글 본문 extension_point 노드를 찾지 못했습니다.'); + + $node = $this->applyExtensionPoint( + $host['props'] ?? [], + $extension['components'] ?? [], + [], + isset($host['if']) ? ['if' => $host['if']] : [] + ); + + $context = ['post' => ['data' => [ + 'content' => '평문 본문', + 'content_mode' => 'text', + 'status' => 'published', + 'is_secret' => false, + ]]]; + + $html = $this->render([$node], $context); + + $this->assertStringContainsString('평문 본문', $html); + $this->assertStringNotContainsString('applyExtensionPoint( + ['content' => "{{page.data?.content ?? ''}}", 'isHtml' => true], + [[ + 'type' => 'composite', + 'name' => 'HtmlContent', + 'props' => [ + 'content' => "{{extensionPointProps.content ?? ''}}", + 'isHtml' => '{{extensionPointProps.isHtml ?? true}}', + ], + ]] + ); + + $html = $this->render([$node], ['page' => ['data' => [ + 'content' => '

소제목

강조 문장

  • 항목
' + .'링크', + ]]]); + + foreach (['

소제목

', '강조', '
  • 항목
  • ', 'href="https://gnuboard.test"'] as $fragment) { + $this->assertStringContainsString($fragment, $html); + } + } + + /** + * 레이아웃 트리에서 content 표현식으로 extension_point 노드를 찾습니다. + * + * 본문 EP 중에는 id 가 없는 노드가 있어 id 기반 탐색이 통하지 않습니다. + * + * @param mixed $node 탐색 대상 (배열/스칼라) + * @param string $contentExpression props.content 표현식 + * @return array|null 발견한 노드 (없으면 null) + */ + private function findExtensionPointByContentExpression(mixed $node, string $contentExpression): ?array + { + if (! is_array($node)) { + return null; + } + + if (($node['type'] ?? null) === 'extension_point' + && ($node['props']['content'] ?? null) === $contentExpression) { + return $node; + } + + foreach ($node as $child) { + $found = $this->findExtensionPointByContentExpression($child, $contentExpression); + if ($found !== null) { + return $found; + } + } + + return null; + } + + /** + * 레이아웃 트리에서 id 로 노드를 찾습니다. + * + * @param mixed $node 탐색 대상 (배열/스칼라) + * @param string $id 찾을 노드 id + * @return array|null 발견한 노드 (없으면 null) + */ + private function findNodeById(mixed $node, string $id): ?array + { + if (! is_array($node)) { + return null; + } + + if (($node['id'] ?? null) === $id) { + return $node; + } + + foreach ($node as $child) { + $found = $this->findNodeById($child, $id); + if ($found !== null) { + return $found; + } + } + + return null; + } +} diff --git a/tests/Unit/Seo/HtmlSanitizerTest.php b/tests/Unit/Seo/HtmlSanitizerTest.php new file mode 100644 index 00000000..eb219b8f --- /dev/null +++ b/tests/Unit/Seo/HtmlSanitizerTest.php @@ -0,0 +1,265 @@ +` 가 실제로 실행됐다. 일반 화면은 + * 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('

    본문

    '); + + $this->assertStringContainsString('

    본문

    ', $html); + $this->assertStringNotContainsString('assertStringNotContainsString('window.__x', $html); + } + + /** + * 이벤트 핸들러 속성은 태그를 남기고 속성만 제거된다. + * + * @effects event_handler_attributes_are_stripped + */ + public function test_event_handler_attributes_are_stripped(): void + { + $html = $this->sanitizer->sanitize('설명'); + + $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('
    내용
    '); + + $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( + '본문' + ); + + $this->assertStringNotContainsString('assertStringNotContainsString('assertStringNotContainsString('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('

    안내 문구

    '); + + $this->assertStringNotContainsString('assertStringContainsString('

    안내 문구

    ', $html); + } + + /** + * javascript: 스킴 링크는 href 만 제거되고 글자는 남는다. + * + * @effects dangerous_url_schemes_are_blocked + */ + public function test_javascript_scheme_href_is_removed(): void + { + $html = $this->sanitizer->sanitize('링크'); + + $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("링크"); + + $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( + '내부앵커메일' + ); + + $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('외부'); + + $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('a'); + $blocked = $this->sanitizer->sanitize('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 = '

    제목

    굵게 그리고 기울임

    ' + .'
    • 첫째
    • 둘째
    인용문
    '; + + $html = $this->sanitizer->sanitize($source); + + foreach (['

    제목

    ', '굵게', '기울임', '
  • 첫째
  • ', '
    인용문
    '] as $fragment) { + $this->assertStringContainsString($fragment, $html); + } + } + + /** + * 표 마크업(게시글에서 흔함)은 보존된다. + * + * @effects ordinary_formatting_survives_sanitization + */ + public function test_table_markup_is_preserved(): void + { + $html = $this->sanitizer->sanitize('
    값
    '); + + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $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('

    본문

    '); + + $this->assertStringNotContainsString('assertStringNotContainsString('window.__x', $html); + $this->assertStringContainsString('

    본문

    ', $html); + } + + /** + * 주석은 제거된다. + * + * @effects active_content_is_removed + */ + public function test_comments_are_removed(): void + { + $html = $this->sanitizer->sanitize('

    본문

    '); + + $this->assertStringNotContainsString('숨은 주석', $html); + $this->assertStringContainsString('

    본문

    ', $html); + } + + /** + * 중첩 구조 안쪽의 스크립트도 제거된다. + * + * @effects active_content_is_removed + */ + public function test_nested_script_is_removed(): void + { + $html = $this->sanitizer->sanitize('

    본문

    '); + + $this->assertStringNotContainsString('assertStringContainsString('

    본문

    ', $html); + } +} diff --git a/tests/Unit/Seo/RealWorldRenderingTest.php b/tests/Unit/Seo/RealWorldRenderingTest.php index aef5dbf4..9df8a513 100644 --- a/tests/Unit/Seo/RealWorldRenderingTest.php +++ b/tests/Unit/Seo/RealWorldRenderingTest.php @@ -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이 올바르게 변환되어야 합니다'); } diff --git a/tests/Unit/Seo/SeoLayoutDataSourceDeclarationTest.php b/tests/Unit/Seo/SeoLayoutDataSourceDeclarationTest.php new file mode 100644 index 00000000..a00d6149 --- /dev/null +++ b/tests/Unit/Seo/SeoLayoutDataSourceDeclarationTest.php @@ -0,0 +1,196 @@ + 사유`. + */ + 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 파일 절대 경로 목록 + */ + 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 $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; + } +} diff --git a/tests/Unit/Seo/SeoNodeKeyParityTest.php b/tests/Unit/Seo/SeoNodeKeyParityTest.php new file mode 100644 index 00000000..870f1817 --- /dev/null +++ b/tests/Unit/Seo/SeoNodeKeyParityTest.php @@ -0,0 +1,382 @@ +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 파일 절대 경로 목록 + */ + 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 사용된 노드 키 목록 (중복 제거) + */ + 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); + } + } +} diff --git a/tests/Unit/Seo/SeoRendererTest.php b/tests/Unit/Seo/SeoRendererTest.php index 654fe8c8..c6354768 100644 --- a/tests/Unit/Seo/SeoRendererTest.php +++ b/tests/Unit/Seo/SeoRendererTest.php @@ -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(''); + 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'); } } diff --git a/tests/Unit/Upgrades/ClearSeoPageCacheAfterRendererParityFixTest.php b/tests/Unit/Upgrades/ClearSeoPageCacheAfterRendererParityFixTest.php new file mode 100644 index 00000000..0924f8ba --- /dev/null +++ b/tests/Unit/Upgrades/ClearSeoPageCacheAfterRendererParityFixTest.php @@ -0,0 +1,159 @@ +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', '빈 본문', 3600); + $this->cache->put('seo.page.def', '빈 본문2', 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', '', 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', '', 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', '', 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', '', 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'), '봇 페이지 캐시는 삭제되어야 합니다.'); + } +} diff --git a/tests/scenarios/seo-extension-point-rendering.yaml b/tests/scenarios/seo-extension-point-rendering.yaml new file mode 100644 index 00000000..ff826225 --- /dev/null +++ b/tests/scenarios/seo-extension-point-rendering.yaml @@ -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 diff --git a/upgrades/Upgrade_7_0_6.php b/upgrades/Upgrade_7_0_6.php index e5e9076f..d350fc7a 100644 --- a/upgrades/Upgrade_7_0_6.php +++ b/upgrades/Upgrade_7_0_6.php @@ -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 없음. * diff --git a/upgrades/data/7.0.6/migrations/02_ClearSeoPageCacheAfterRendererParityFix.php b/upgrades/data/7.0.6/migrations/02_ClearSeoPageCacheAfterRendererParityFix.php new file mode 100644 index 00000000..1d54e025 --- /dev/null +++ b/upgrades/data/7.0.6/migrations/02_ClearSeoPageCacheAfterRendererParityFix.php @@ -0,0 +1,129 @@ +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; + } + } +}
    값