Files
Gnuboard7/app/Seo/Editor/SeoCandidateService.php
2026-07-01 10:30:32 +09:00

351 lines
14 KiB
PHP

<?php
namespace App\Seo\Editor;
use App\Contracts\Extension\ModuleManagerInterface;
use App\Contracts\Extension\PluginManagerInterface;
/**
* SEO 후보 수집 서비스 — 레이아웃 편집기 [검색엔진] 탭 후보 공급.
*
* page_type 은 코어 고정 enum 이 아니라 확장이 `seoVariables()` 키로 자유 정의하는 열린
* 문자열이므로, 편집기 드롭다운 후보를 **활성 모듈/플러그인에서 런타임 수집**한다.
* 세 가지 후보를 한 번에 공급:
*
* 1. page_type 후보 — 활성 확장 `seoVariables()` 키(`_common` 제외) + 각 page_type 별
* `seoStructuredData($pageType)` 의 `@type`(구조화 "모듈 제공" 표시 근거).
* 2. toggle_setting 후보 — 활성 확장 `config/settings/defaults.json` `defaults.seo.*`
* boolean 키 + 친화 라벨(lang).
* 3. 유효 vars 후보 — 선언 확장들의 `seoVariables()[_common] + [page_type]` 키 합집합.
*
* Controller 가 검증/가드(`core.templates.layouts.edit`)를 담당하고, 본 서비스는 순수
* 수집만 한다(Service-Repository 정합 — 도메인 수집 로직 분리). 확장 declaration throw 는
* 격리(한 확장 오류가 전체 후보 수집을 망치지 않음).
*
* @since 7.0.0-beta.?
*/
class SeoCandidateService
{
public function __construct(
private readonly ModuleManagerInterface $moduleManager,
private readonly PluginManagerInterface $pluginManager,
) {}
/**
* 편집기 SEO 탭 후보를 수집합니다.
*
* @param array $declaredExtensions 현재 레이아웃 meta.seo.extensions (`[{type,id}]`)
* @param string|null $pageType 현재 선택된 page_type (유효 vars 게이팅용)
* @param string $locale 라벨 해석 로케일
* @return array{page_types: array, toggle_settings: array, vars: array}
*/
public function collect(array $declaredExtensions, ?string $pageType, string $locale): array
{
$activeExtensions = $this->resolveActiveExtensions();
return [
'page_types' => $this->collectPageTypes($activeExtensions, $declaredExtensions),
'toggle_settings' => $this->collectToggleSettings($activeExtensions, $locale),
'vars' => $this->collectVars($activeExtensions, $declaredExtensions, $pageType),
'extensions' => $this->collectExtensions($activeExtensions, $locale),
];
}
/**
* 확장 후보 — 확장 SEO 연동 칩(`g7le-seo-extensions`)의 활성 모듈/플러그인 목록.
*
* 레이아웃 `meta.seo.extensions[]` 에 `{type,id}` 로 저장될 후보를 공급한다. 각 항목은
* 친화 라벨(`getName()` 로케일 해석값, 폴백 = id)을 달아 칩 선택지 표시에 쓴다. SEO 별도
* 엔드포인트 신설 없이 본 후보 응답에 합류(세션 D 결선 — "활성 모듈/플러그인 후보").
*
* @param array $activeExtensions resolveActiveExtensions() 결과
* @param string $locale 라벨 해석 로케일
* @return array<int, array{type: string, id: string, label: string}>
*/
private function collectExtensions(array $activeExtensions, string $locale): array
{
$out = [];
foreach ($activeExtensions as $ext) {
$name = $this->safeCall($ext['instance'], 'getName', $ext['id']);
$out[] = [
'type' => $ext['type'],
'id' => $ext['id'],
'label' => $this->localizeName($name, $locale, $ext['id']),
];
}
return $out;
}
/**
* 확장 `getName()` 반환(string|array 다국어)을 로케일 라벨로 해석합니다.
*
* 배열이면 locale → app.locale → 첫 값 순으로 폴백, 끝내 비면 식별자 폴백.
*
* @param mixed $name getName() 반환 (string|array<string,string>)
* @param string $locale 목표 로케일
* @param string $fallbackId 최종 폴백 식별자
* @return string 친화 라벨
*/
private function localizeName(mixed $name, string $locale, string $fallbackId): string
{
if (is_string($name) && $name !== '') {
return $name;
}
if (is_array($name)) {
foreach ([$locale, config('app.locale')] as $loc) {
if (is_string($loc) && isset($name[$loc]) && is_string($name[$loc]) && $name[$loc] !== '') {
return $name[$loc];
}
}
foreach ($name as $value) {
if (is_string($value) && $value !== '') {
return $value;
}
}
}
return $fallbackId;
}
/**
* 활성 모듈/플러그인 인스턴스를 `{type, id, instance}` 목록으로 반환합니다.
*
* @return array<int, array{type: string, id: string, instance: object}>
*/
private function resolveActiveExtensions(): array
{
$out = [];
foreach ($this->moduleManager->getActiveModules() as $module) {
$out[] = ['type' => 'module', 'id' => $module->getIdentifier(), 'instance' => $module];
}
foreach ($this->pluginManager->getActivePlugins() as $plugin) {
$out[] = ['type' => 'plugin', 'id' => $plugin->getIdentifier(), 'instance' => $plugin];
}
return $out;
}
/**
* page_type 후보 — 활성 확장 seoVariables() 키(_common 제외) + owned @type.
*
* 선언 확장(declaredExtensions)을 우선 정렬하되, 전체 활성 확장을 fallback 으로 포함한다.
* 같은 page_type 을 여러 확장이 선언하면 첫 소유 확장 기준으로 1건만 노출(중복 제거).
*
* @param array $activeExtensions resolveActiveExtensions() 결과
* @param array $declaredExtensions 레이아웃 선언 확장
* @return array<int, array{value: string, owner: array, og_type?: string}>
*/
private function collectPageTypes(array $activeExtensions, array $declaredExtensions): array
{
$declaredIds = $this->declaredIdSet($declaredExtensions);
// 선언 확장 우선 정렬.
usort($activeExtensions, function ($a, $b) use ($declaredIds) {
$aw = isset($declaredIds[$a['type'].':'.$a['id']]) ? 0 : 1;
$bw = isset($declaredIds[$b['type'].':'.$b['id']]) ? 0 : 1;
return $aw <=> $bw;
});
$seen = [];
$out = [];
foreach ($activeExtensions as $ext) {
$vars = $this->safeCall($ext['instance'], 'seoVariables', []);
if (! is_array($vars)) {
continue;
}
foreach (array_keys($vars) as $key) {
if ($key === '_common' || ! is_string($key) || $key === '') {
continue;
}
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$entry = [
'value' => $key,
'owner' => ['type' => $ext['type'], 'id' => $ext['id']],
];
// owned @type — page_type 별 seoStructuredData 의 @type (구조화 "모듈 제공" 표시).
$structured = $this->safeCall($ext['instance'], 'seoStructuredData', [], [$key, [], []]);
if (is_array($structured) && isset($structured['@type']) && is_string($structured['@type'])) {
$entry['og_type'] = $structured['@type'];
}
$out[] = $entry;
}
}
return $out;
}
/**
* toggle_setting 후보 — 활성 확장 defaults.seo.* boolean 키 + 친화 라벨.
*
* @param array $activeExtensions resolveActiveExtensions() 결과
* @param string $locale 라벨 로케일
* @return array<int, array{ref: string, key: string, label: string, owner: array}>
*/
private function collectToggleSettings(array $activeExtensions, string $locale): array
{
$out = [];
foreach ($activeExtensions as $ext) {
$defaults = $this->readSeoDefaults($ext['type'], $ext['id']);
foreach ($defaults as $key => $value) {
if (! is_bool($value)) {
continue;
}
$out[] = [
'ref' => '$'.$ext['type'].'_settings:'.$ext['id'].':seo.'.$key,
'key' => $key,
'label' => $this->resolveToggleLabel($ext['type'], $ext['id'], $key, $locale),
'owner' => ['type' => $ext['type'], 'id' => $ext['id']],
];
}
}
return $out;
}
/**
* 유효 vars 후보 — 선언 확장들의 seoVariables()[_common]+[pageType] 키 합집합.
*
* page_type 미선택 시 _common 만. extensions 변경 시 갱신(편집기 게이팅 ④).
*
* @param array $activeExtensions resolveActiveExtensions() 결과
* @param array $declaredExtensions 레이아웃 선언 확장(게이팅 — 미선언 확장 vars 무효)
* @param string|null $pageType 선택된 page_type
* @return array<int, array{name: string, source: string, owner: array, required?: bool}>
*/
private function collectVars(array $activeExtensions, array $declaredExtensions, ?string $pageType): array
{
$declaredIds = $this->declaredIdSet($declaredExtensions);
$seen = [];
$out = [];
foreach ($activeExtensions as $ext) {
// 게이팅 — 선언 확장만 vars 유효(SeoRenderer.php:941-943 정합).
if (! isset($declaredIds[$ext['type'].':'.$ext['id']])) {
continue;
}
$vars = $this->safeCall($ext['instance'], 'seoVariables', []);
if (! is_array($vars)) {
continue;
}
$groups = [];
if (isset($vars['_common']) && is_array($vars['_common'])) {
$groups[] = $vars['_common'];
}
if ($pageType !== null && isset($vars[$pageType]) && is_array($vars[$pageType])) {
$groups[] = $vars[$pageType];
}
foreach ($groups as $group) {
foreach ($group as $name => $meta) {
if (! is_string($name) || isset($seen[$name])) {
continue;
}
$seen[$name] = true;
$entry = [
'name' => $name,
'source' => is_array($meta) ? (string) ($meta['source'] ?? 'data') : 'data',
'owner' => ['type' => $ext['type'], 'id' => $ext['id']],
];
if (is_array($meta) && ! empty($meta['required'])) {
$entry['required'] = true;
}
$out[] = $entry;
}
}
}
return $out;
}
/**
* 선언 확장 식별자 집합(`'type:id' => true`)을 만듭니다.
*
* @param array $declaredExtensions `[{type,id}]`
* @return array<string, bool>
*/
private function declaredIdSet(array $declaredExtensions): array
{
$set = [];
foreach ($declaredExtensions as $ext) {
$type = is_array($ext) ? ($ext['type'] ?? null) : null;
$id = is_array($ext) ? ($ext['id'] ?? null) : null;
if (is_string($type) && is_string($id)) {
$set[$type.':'.$id] = true;
}
}
return $set;
}
/**
* 확장의 `config/settings/defaults.json` `defaults.seo` 배열을 읽습니다.
*
* @param string $type module|plugin
* @param string $id 확장 식별자
* @return array<string, mixed> seo 기본값 맵 (없으면 빈 배열)
*/
private function readSeoDefaults(string $type, string $id): array
{
$base = $type === 'module' ? 'modules' : 'plugins';
foreach ([base_path("{$base}/{$id}/config/settings/defaults.json"), base_path("{$base}/_bundled/{$id}/config/settings/defaults.json")] as $path) {
if (! is_file($path)) {
continue;
}
$decoded = json_decode((string) file_get_contents($path), true);
$seo = $decoded['defaults']['seo'] ?? null;
return is_array($seo) ? $seo : [];
}
return [];
}
/**
* toggle_setting 친화 라벨을 해석합니다 — lang 키 `{ext}::settings.seo.{key}` 우선, 폴백 = 키.
*
* @param string $type module|plugin
* @param string $id 확장 식별자
* @param string $key seo boolean 키
* @param string $locale 로케일
* @return string 친화 라벨
*/
private function resolveToggleLabel(string $type, string $id, string $key, string $locale): string
{
$candidates = [
"{$id}::settings.seo.{$key}",
"{$id}::seo.{$key}",
];
foreach ($candidates as $transKey) {
$translated = __($transKey, [], $locale);
if (is_string($translated) && $translated !== $transKey) {
return $translated;
}
}
return $key;
}
/**
* 확장 메서드를 격리 호출합니다 — throw 시 fallback 반환(한 확장 오류 격리).
*
* @param object $instance 확장 인스턴스
* @param string $method 메서드명
* @param mixed $fallback 실패 시 반환값
* @param array $args 인자
* @return mixed 결과 또는 fallback
*/
private function safeCall(object $instance, string $method, mixed $fallback, array $args = []): mixed
{
if (! method_exists($instance, $method)) {
return $fallback;
}
try {
return $instance->{$method}(...$args);
} catch (\Throwable) {
return $fallback;
}
}
}