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 */ 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) * @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 */ 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 */ 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 */ 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 */ 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 */ 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 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; } } }