일반 화면(React)과 봇 화면(PHP)은 같은 레이아웃을 각각 렌더하는데 봇 화면 쪽 지원 범위가 뒤처져, 확장이 교체한 본문이 통째로 빈 채 나갔다 (공개 이슈 ). 같은 결손 계열을 전수 조사해 함께 닫는다. - 확장 주입 props 를 렌더 진입 시점에 해석해 자손 전체에 상속 → 게시글·답글 원글·댓글·페이지·상품 설명 5개 지점 복구 - 사용자 작성 HTML 정화기 신설 — isHtml 판정에 따라 이스케이프/정화. 미정화 상태에서 봇 경로로 저장형 스크립트가 실제 실행됐다 - 노드 키 패리티: conditions / type:iterator / responsive 매칭 규칙 / children 문자열 / text 우선순위 / $t:defer / 레이아웃 초기 상태 - 표현식: 단일 바인딩 판정 교체, ||·&& 를 값 반환(JS 시맨틱)으로, 비교식이 raw 경로에서 null 이 되던 문제 - 데이터소스: 엔드포인트 표현식 전면 해석 + 레이아웃 8곳 선언 보강 - 봇 페이지 캐시 1회 전량 무효화 업그레이드 스텝 지원 범위 SSoT 표를 문서에 고정하고, 한쪽만 고치는 것을 막는 검사 룰과 레이아웃 전수 순회 테스트로 잠갔다.
197 lines
7.3 KiB
PHP
197 lines
7.3 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Seo;
|
|
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* 봇 대상 레이아웃이 화면에 쓰는 데이터소스를 빠짐없이 선언했는지 감시합니다.
|
|
*
|
|
* 배경:
|
|
* 봇 렌더링은 `meta.seo.data_sources` 에 적힌 id 만 미리 조회한다. 화면이 쓰는
|
|
* 데이터소스가 목록에서 빠지면 그 값은 늘 비어 있고, 그 값을 조건으로 삼는 블록이
|
|
* 통째로 사라진다. 실제로 `users/posts` 레이아웃이 목록 전체를 `userPosts` 로
|
|
* 감싸 두고 그 id 를 선언하지 않아, 봇에게는 머리말과 꼬리말만 있는 빈 화면이
|
|
* 나갔다.
|
|
*
|
|
* 봇에 노출할 필요가 없어 일부러 뺀 데이터소스는 아래 EXEMPT 목록에 사유와 함께
|
|
* 등록한다. 목록에 없는 미선언 사용처가 나타나면 실패한다.
|
|
*/
|
|
class SeoLayoutDataSourceDeclarationTest extends TestCase
|
|
{
|
|
/**
|
|
* 검사 대상 템플릿의 레이아웃 디렉토리 (프로젝트 루트 기준 상대 경로).
|
|
*/
|
|
private const LAYOUT_ROOT = 'templates/_bundled/sirsoft-basic/layouts';
|
|
|
|
/**
|
|
* 의도적으로 봇 조회에서 제외한 데이터소스 — `레이아웃::데이터소스 id => 사유`.
|
|
*/
|
|
private const EXEMPT = [
|
|
'shop/index::recentProducts' => '브라우저에 저장된 열람 이력이 필요해 봇에게는 늘 빈 값',
|
|
'shop/show::productDownloadableCoupons' => '로그인 사용자 전용 쿠폰이라 봇에게 의미 없음',
|
|
];
|
|
|
|
/**
|
|
* 봇 대상 레이아웃은 화면이 참조하는 데이터소스를 모두 선언해야 합니다.
|
|
*
|
|
* @effects every_used_data_source_is_declared
|
|
*/
|
|
public function test_seo_layouts_declare_every_data_source_they_use(): void
|
|
{
|
|
$root = base_path(self::LAYOUT_ROOT);
|
|
$this->assertDirectoryExists($root);
|
|
|
|
$violations = [];
|
|
$checked = 0;
|
|
|
|
foreach ($this->layoutFiles($root) as $file) {
|
|
$layout = json_decode((string) file_get_contents($file), true);
|
|
if (! is_array($layout)) {
|
|
continue;
|
|
}
|
|
|
|
$seo = $layout['meta']['seo'] ?? null;
|
|
if (! is_array($seo) || ($seo['enabled'] ?? false) !== true) {
|
|
continue;
|
|
}
|
|
|
|
$checked++;
|
|
$layoutName = $this->layoutName($root, $file);
|
|
$declared = $seo['data_sources'] ?? [];
|
|
$body = $this->layoutBodyText($root, $file);
|
|
|
|
foreach ($layout['data_sources'] ?? [] as $dataSource) {
|
|
$id = $dataSource['id'] ?? null;
|
|
if (! is_string($id) || $id === '' || in_array($id, $declared, true)) {
|
|
continue;
|
|
}
|
|
|
|
if (isset(self::EXEMPT[$layoutName.'::'.$id])) {
|
|
continue;
|
|
}
|
|
|
|
if ($this->isReferenced($id, $body)) {
|
|
$violations[] = "{$layoutName}: '{$id}' 를 화면에서 참조하지만 meta.seo.data_sources 에 없음";
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->assertGreaterThan(0, $checked, '봇 대상 레이아웃을 하나도 찾지 못했습니다 — 스캔 경로를 확인하세요.');
|
|
$this->assertSame([], $violations, implode(PHP_EOL, $violations));
|
|
}
|
|
|
|
/**
|
|
* 면제 목록에 죽은 항목이 남아 있지 않은지 확인합니다.
|
|
*
|
|
* @effects intentional_exclusions_are_documented
|
|
*/
|
|
public function test_exempt_entries_still_exist(): void
|
|
{
|
|
$root = base_path(self::LAYOUT_ROOT);
|
|
|
|
foreach (array_keys(self::EXEMPT) as $entry) {
|
|
[$layoutName, $id] = explode('::', $entry, 2);
|
|
$path = $root.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $layoutName).'.json';
|
|
|
|
$this->assertFileExists($path, "면제 항목의 레이아웃이 없습니다: {$entry}");
|
|
|
|
$layout = json_decode((string) file_get_contents($path), true);
|
|
$ids = array_column($layout['data_sources'] ?? [], 'id');
|
|
|
|
$this->assertContains($id, $ids, "면제 항목의 데이터소스가 없습니다: {$entry}");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 JSON 파일 목록을 반환합니다 (admin 하위 제외 — 봇 대상 아님).
|
|
*
|
|
* @param string $root 레이아웃 루트 절대 경로
|
|
* @return array<int, string> 파일 절대 경로 목록
|
|
*/
|
|
private function layoutFiles(string $root): array
|
|
{
|
|
$iterator = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)
|
|
);
|
|
|
|
$files = [];
|
|
foreach ($iterator as $file) {
|
|
if (! $file->isFile() || $file->getExtension() !== 'json') {
|
|
continue;
|
|
}
|
|
|
|
$path = $file->getPathname();
|
|
if (str_contains(str_replace('\\', '/', $path), '/admin/')) {
|
|
continue;
|
|
}
|
|
|
|
$files[] = $path;
|
|
}
|
|
|
|
sort($files);
|
|
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 파일 경로를 레이아웃명으로 변환합니다.
|
|
*
|
|
* @param string $root 레이아웃 루트 절대 경로
|
|
* @param string $file 레이아웃 파일 절대 경로
|
|
* @return string 레이아웃명 (예: users/posts)
|
|
*/
|
|
private function layoutName(string $root, string $file): string
|
|
{
|
|
$relative = ltrim(str_replace('\\', '/', substr($file, strlen($root))), '/');
|
|
|
|
return preg_replace('/\.json$/', '', $relative) ?? $relative;
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 본문 + 참조 partial 본문을 하나의 문자열로 모읍니다.
|
|
*
|
|
* 화면 구성이 partial 로 쪼개져 있어 자기 파일만 봐서는 참조 여부를 알 수 없습니다.
|
|
*
|
|
* @param string $root 레이아웃 루트 절대 경로
|
|
* @param string $file 레이아웃 파일 절대 경로
|
|
* @param array<string, bool> $visited 순환 방지용 방문 기록
|
|
* @return string 합쳐진 JSON 문자열
|
|
*/
|
|
private function layoutBodyText(string $root, string $file, array &$visited = []): string
|
|
{
|
|
$normalized = str_replace('\\', '/', $file);
|
|
if (isset($visited[$normalized]) || ! is_file($file)) {
|
|
return '';
|
|
}
|
|
$visited[$normalized] = true;
|
|
|
|
$raw = (string) file_get_contents($file);
|
|
$text = $raw;
|
|
|
|
preg_match_all('/"partial"\s*:\s*"([^"]+)"/', $raw, $matches);
|
|
foreach ($matches[1] ?? [] as $partial) {
|
|
$text .= $this->layoutBodyText($root, $root.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $partial), $visited);
|
|
}
|
|
|
|
return $text;
|
|
}
|
|
|
|
/**
|
|
* 데이터소스 id 가 표현식/조건에서 참조되는지 판정합니다.
|
|
*
|
|
* `data_sources` 선언부의 `"id": "..."` 는 참조가 아니므로 제외하고, 표현식 안에서
|
|
* 식별자로 등장하는 경우만 참조로 봅니다.
|
|
*
|
|
* @param string $id 데이터소스 id
|
|
* @param string $body 레이아웃 + partial 본문
|
|
* @return bool 참조하면 true
|
|
*/
|
|
private function isReferenced(string $id, string $body): bool
|
|
{
|
|
$withoutDeclarations = preg_replace('/"id"\s*:\s*"'.preg_quote($id, '/').'"/', '', $body) ?? $body;
|
|
|
|
return preg_match('/[{(\s|!?,]'.preg_quote($id, '/').'[?.\s)]/', $withoutDeclarations) === 1;
|
|
}
|
|
}
|