사유`. */ 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; } }