contributors[$contributor->getIdentifier()] = $contributor; } /** * 전체 Sitemap URL을 수집하여 XML을 생성합니다. * * @return string sitemap XML 문자열 */ public function generate(): string { $urls = []; // 1. 정적 라우트 수집 (파라미터 없는 공개 라우트) $urls = array_merge($urls, $this->collectStaticRoutes()); // 2. 기여자들의 동적 URL 수집 foreach ($this->contributors as $contributor) { try { $contributorUrls = $contributor->getUrls(); // 기여자 URL의 'url' 키를 'loc'(절대 URL)으로 변환 foreach ($contributorUrls as &$entry) { if (isset($entry['url']) && ! isset($entry['loc'])) { $entry['loc'] = url($entry['url']); unset($entry['url']); } } unset($entry); $urls = array_merge($urls, $contributorUrls); } catch (\Throwable $e) { Log::warning('[SEO] Sitemap contributor failed', [ 'contributor' => $contributor->getIdentifier(), 'error' => $e->getMessage(), ]); } } return $this->buildXml($urls); } /** * 등록된 기여자 목록을 반환합니다. * * @return SitemapContributorInterface[] 기여자 배열 */ public function getContributors(): array { return $this->contributors; } /** * 정적 라우트를 수집합니다 (파라미터 없는 공개 라우트만). * * @return array 정적 URL 배열 */ private function collectStaticRoutes(): array { try { $activeTemplate = TemplateManager::getActiveTemplate('user'); if (! $activeTemplate) { return []; } $templateIdentifier = $activeTemplate['identifier'] ?? null; if (! $templateIdentifier) { return []; } $routesResult = $this->templateService->getRoutesDataWithModules($templateIdentifier); if (! ($routesResult['success'] ?? false) || empty($routesResult['data']['routes'])) { return []; } $urls = []; foreach ($routesResult['data']['routes'] as $route) { // auth_required 라우트 제외 if ($route['auth_required'] ?? false) { continue; } // guest_only 라우트 제외 if ($route['guest_only'] ?? false) { continue; } $routePath = $route['path'] ?? ''; // 동적 파라미터(:id, :slug) 포함 라우트 제외 if (str_contains($routePath, ':')) { continue; } // 템플릿 표현식({{...}}) 포함 라우트 제외 if (str_contains($routePath, '{{')) { continue; } // 와일드카드(*) 접두사 제거 $routePath = ltrim($routePath, '*/'); if (! str_starts_with($routePath, '/')) { $routePath = '/'.$routePath; } $urls[] = [ 'loc' => url($routePath), 'changefreq' => 'weekly', 'priority' => 0.5, ]; } return $urls; } catch (\Throwable $e) { Log::warning('[SEO] Static route collection failed', [ 'error' => $e->getMessage(), ]); return []; } } /** * URL 배열을 다국어 sitemap XML로 변환합니다. * * supported_locales가 2개 이상이면 각 기본 URL에 대해 로케일별 항목을 생성하고, * 각 항목에 xhtml:link hreflang alternate 태그를 포함합니다. * 로케일이 1개뿐이면 기존 단일 언어 형식을 유지합니다. * * @param array $urls URL 배열 (loc 키에 기본 로케일 절대 URL 포함) * @return string XML 문자열 */ private function buildXml(array $urls): string { $defaultLocale = config('app.locale'); $supportedLocales = config('app.supported_locales', [$defaultLocale]); $isMultilingual = count($supportedLocales) > 1; $xml = ''."\n"; if ($isMultilingual) { $xml .= ' 항목 생성 foreach ($supportedLocales as $locale) { $localeLoc = $locale === $defaultLocale ? $baseLoc : $baseLoc.'?locale='.$locale; $xml .= ' '."\n"; $xml .= ' '.htmlspecialchars($localeLoc, ENT_XML1, 'UTF-8').''."\n"; if (! empty($entry['lastmod'])) { $xml .= ' '.htmlspecialchars($entry['lastmod'], ENT_XML1, 'UTF-8').''."\n"; } if (! empty($entry['changefreq'])) { $xml .= ' '.htmlspecialchars($entry['changefreq'], ENT_XML1, 'UTF-8').''."\n"; } if (isset($entry['priority'])) { $xml .= ' '.number_format((float) $entry['priority'], 1).''."\n"; } // 모든 로케일의 hreflang alternate 링크 foreach ($supportedLocales as $altLocale) { $altHref = $altLocale === $defaultLocale ? $baseLoc : $baseLoc.'?locale='.$altLocale; $xml .= ' '."\n"; } // x-default = 기본 로케일 URL $xml .= ' '."\n"; $xml .= ' '."\n"; } } else { // 단일 언어: 기존 형식 유지 $xml .= ' '."\n"; $xml .= ' '.htmlspecialchars($baseLoc, ENT_XML1, 'UTF-8').''."\n"; if (! empty($entry['lastmod'])) { $xml .= ' '.htmlspecialchars($entry['lastmod'], ENT_XML1, 'UTF-8').''."\n"; } if (! empty($entry['changefreq'])) { $xml .= ' '.htmlspecialchars($entry['changefreq'], ENT_XML1, 'UTF-8').''."\n"; } if (isset($entry['priority'])) { $xml .= ' '.number_format((float) $entry['priority'], 1).''."\n"; } $xml .= ' '."\n"; } } $xml .= ''; return $xml; } }