일반 화면(React)과 봇 화면(PHP)은 같은 레이아웃을 각각 렌더하는데 봇 화면 쪽 지원 범위가 뒤처져, 확장이 교체한 본문이 통째로 빈 채 나갔다 (공개 이슈 ). 같은 결손 계열을 전수 조사해 함께 닫는다. - 확장 주입 props 를 렌더 진입 시점에 해석해 자손 전체에 상속 → 게시글·답글 원글·댓글·페이지·상품 설명 5개 지점 복구 - 사용자 작성 HTML 정화기 신설 — isHtml 판정에 따라 이스케이프/정화. 미정화 상태에서 봇 경로로 저장형 스크립트가 실제 실행됐다 - 노드 키 패리티: conditions / type:iterator / responsive 매칭 규칙 / children 문자열 / text 우선순위 / $t:defer / 레이아웃 초기 상태 - 표현식: 단일 바인딩 판정 교체, ||·&& 를 값 반환(JS 시맨틱)으로, 비교식이 raw 경로에서 null 이 되던 문제 - 데이터소스: 엔드포인트 표현식 전면 해석 + 레이아웃 8곳 선언 보강 - 봇 페이지 캐시 1회 전량 무효화 업그레이드 스텝 지원 범위 SSoT 표를 문서에 고정하고, 한쪽만 고치는 것을 막는 검사 룰과 레이아웃 전수 순회 테스트로 잠갔다.
144 lines
10 KiB
YAML
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
|