Files
Gnuboard7/tests/scenarios/seo-extension-point-rendering.yaml
HeuJung 0dfb45951b fix(core,basic): 검색 봇 화면 렌더러 패리티 결손 일괄 수정
일반 화면(React)과 봇 화면(PHP)은 같은 레이아웃을 각각 렌더하는데 봇
화면 쪽 지원 범위가 뒤처져, 확장이 교체한 본문이 통째로 빈 채 나갔다
(공개 이슈 ). 같은 결손 계열을 전수 조사해 함께 닫는다.

- 확장 주입 props 를 렌더 진입 시점에 해석해 자손 전체에 상속
 → 게시글·답글 원글·댓글·페이지·상품 설명 5개 지점 복구
- 사용자 작성 HTML 정화기 신설 — isHtml 판정에 따라 이스케이프/정화.
 미정화 상태에서 봇 경로로 저장형 스크립트가 실제 실행됐다
- 노드 키 패리티: conditions / type:iterator / responsive 매칭 규칙 /
 children 문자열 / text 우선순위 / $t:defer / 레이아웃 초기 상태
- 표현식: 단일 바인딩 판정 교체, ||·&& 를 값 반환(JS 시맨틱)으로,
 비교식이 raw 경로에서 null 이 되던 문제
- 데이터소스: 엔드포인트 표현식 전면 해석 + 레이아웃 8곳 선언 보강
- 봇 페이지 캐시 1회 전량 무효화 업그레이드 스텝

지원 범위 SSoT 표를 문서에 고정하고, 한쪽만 고치는 것을 막는 검사 룰과
레이아웃 전수 순회 테스트로 잠갔다.
2026-07-27 10:03:06 +09:00

144 lines
10 KiB
YAML

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