Files
Gnuboard7/app/Seo/SeoConfigMerger.php
2026-04-20 20:37:49 +09:00

255 lines
7.9 KiB
PHP

<?php
namespace App\Seo;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use Illuminate\Support\Facades\Log;
class SeoConfigMerger
{
/**
* 캐시 TTL (24시간)
*/
private const CACHE_TTL = 86400;
/**
* 캐시 키 접두사 (드라이버 접두사 `g7:core:` 다음에 붙음)
*/
private const CACHE_PREFIX = 'seo.config.';
public function __construct(
private readonly ModuleManager $moduleManager,
private readonly PluginManager $pluginManager,
private readonly TemplateRepositoryInterface $templateRepository,
private readonly CacheInterface $cache,
) {}
/**
* 병합된 SEO config를 반환합니다.
*
* 우선순위 (나중이 우선): 모듈 < 플러그인 < 템플릿
*
* @param string $templateIdentifier 템플릿 식별자
* @return array 병합된 SEO 설정 배열
*/
public function getMergedConfig(string $templateIdentifier): array
{
$cacheKey = self::CACHE_PREFIX.$templateIdentifier;
return $this->cache->remember($cacheKey, function () use ($templateIdentifier) {
return $this->buildMergedConfig($templateIdentifier);
}, self::CACHE_TTL, ['seo']);
}
/**
* SEO config 캐시를 클리어합니다.
*
* @param string|null $templateIdentifier 특정 템플릿만 클리어 (null이면 전체)
*/
public function clearCache(?string $templateIdentifier = null): void
{
if ($templateIdentifier !== null) {
$this->cache->forget(self::CACHE_PREFIX.$templateIdentifier);
return;
}
// 전체 클리어: 패턴 기반 삭제가 불가능하므로
// 태그 캐시를 사용하지 않는 한 개별 키 삭제 불가
// → 간단히 모든 알려진 템플릿의 캐시를 삭제
try {
$templates = $this->templateRepository->getActive();
foreach ($templates as $template) {
$this->cache->forget(self::CACHE_PREFIX.$template->identifier);
}
} catch (\Throwable $e) {
Log::warning('[SEO] Config cache clear failed, clearing by pattern', [
'error' => $e->getMessage(),
]);
}
}
/**
* 모든 소스에서 SEO config를 수집하고 병합합니다.
*
* @param string $templateIdentifier 템플릿 식별자
* @return array 병합된 SEO 설정 배열
*/
private function buildMergedConfig(string $templateIdentifier): array
{
$base = [];
// 1. 활성 모듈 config 수집 (알파벳순 정렬, 결정론적)
$moduleConfigs = $this->collectModuleConfigs();
foreach ($moduleConfigs as $config) {
$base = $this->mergeConfigs($base, $config);
}
// 2. 활성 플러그인 config 수집 (알파벳순 정렬)
$pluginConfigs = $this->collectPluginConfigs();
foreach ($pluginConfigs as $config) {
$base = $this->mergeConfigs($base, $config);
}
// 3. 템플릿 config (최종 우선)
$templateConfig = $this->loadTemplateConfig($templateIdentifier);
if (! empty($templateConfig)) {
$base = $this->mergeConfigs($base, $templateConfig);
}
return $base;
}
/**
* 활성 모듈의 seo-config.json을 수집합니다.
*
* @return array<string, array> 모듈 식별자 → config 배열 (알파벳순 정렬)
*/
private function collectModuleConfigs(): array
{
$configs = [];
try {
$activeModules = $this->moduleManager->getActiveModules();
foreach ($activeModules as $module) {
$config = $module->getSeoConfig();
if (! empty($config)) {
$configs[$module->getIdentifier()] = $config;
}
}
} catch (\Throwable $e) {
Log::warning('[SEO] Module config collection failed', [
'error' => $e->getMessage(),
]);
}
// 알파벳순 정렬 (결정론적 병합 순서)
ksort($configs);
return $configs;
}
/**
* 활성 플러그인의 seo-config.json을 수집합니다.
*
* @return array<string, array> 플러그인 식별자 → config 배열 (알파벳순 정렬)
*/
private function collectPluginConfigs(): array
{
$configs = [];
try {
$activePlugins = $this->pluginManager->getActivePlugins();
foreach ($activePlugins as $plugin) {
$config = $plugin->getSeoConfig();
if (! empty($config)) {
$configs[$plugin->getIdentifier()] = $config;
}
}
} catch (\Throwable $e) {
Log::warning('[SEO] Plugin config collection failed', [
'error' => $e->getMessage(),
]);
}
// 알파벳순 정렬 (결정론적 병합 순서)
ksort($configs);
return $configs;
}
/**
* 템플릿의 seo-config.json을 로드합니다.
*
* @param string $templateIdentifier 템플릿 식별자
* @return array SEO 설정 배열
*/
private function loadTemplateConfig(string $templateIdentifier): array
{
$path = base_path("templates/{$templateIdentifier}/seo-config.json");
if (! file_exists($path)) {
return [];
}
$config = json_decode(file_get_contents($path), true);
return is_array($config) ? $config : [];
}
/**
* 두 SEO config를 병합합니다.
*
* 병합 전략:
* - component_map: deep merge (키 단위, 후순위 우선)
* - render_modes: deep merge (키 단위, 후순위 우선)
* - attr_map: shallow merge (후순위 우선)
* - text_props: array union (중복 제거)
* - allowed_attrs: array union (중복 제거)
* - self_closing: array union (중복 제거)
* - stylesheets: array append (중복 제거)
*
* @param array $base 기존 config
* @param array $layer 병합할 config (후순위)
* @return array 병합된 config
*/
private function mergeConfigs(array $base, array $layer): array
{
$result = $base;
// deep merge 키 (키 단위, 후순위 우선)
foreach (['component_map', 'render_modes'] as $key) {
if (isset($layer[$key])) {
$result[$key] = array_replace_recursive(
$result[$key] ?? [],
$layer[$key]
);
}
}
// shallow merge (후순위 우선)
foreach (['attr_map'] as $key) {
if (isset($layer[$key])) {
$result[$key] = array_merge(
$result[$key] ?? [],
$layer[$key]
);
}
}
// array union (중복 제거)
foreach (['text_props', 'allowed_attrs', 'self_closing'] as $key) {
if (isset($layer[$key])) {
$result[$key] = array_values(array_unique(
array_merge($result[$key] ?? [], $layer[$key])
));
}
}
// array append (중복 제거, 순서 유지)
if (isset($layer['stylesheets'])) {
$existing = $result['stylesheets'] ?? [];
foreach ($layer['stylesheets'] as $stylesheet) {
if (! in_array($stylesheet, $existing, true)) {
$existing[] = $stylesheet;
}
}
$result['stylesheets'] = $existing;
}
// seo_overrides: shallow merge (후순위 우선)
if (isset($layer['seo_overrides'])) {
$result['seo_overrides'] = array_merge(
$result['seo_overrides'] ?? [],
$layer['seo_overrides']
);
}
return $result;
}
}