validateExtensionPoint($value, $fail); } else { $this->validateOverlay($value, $fail); } } /** * extension_point 타입 검증 * * @param array $data 확장 정의 * @param Closure $fail 실패 콜백 */ private function validateExtensionPoint(array $data, Closure $fail): void { if (! is_string($data['extension_point']) || $data['extension_point'] === '') { $fail(__('validation.layout_extension.extension_point_invalid')); return; } // components 검증 (선택 — 빈 확장점도 허용) if (isset($data['components'])) { if (! is_array($data['components'])) { $fail(__('validation.layout_extension.components_must_be_array')); return; } foreach ($data['components'] as $index => $component) { if (! $this->validateComponent($component, $index, $fail)) { return; } } } $this->validateOptionalSections($data, $fail); } /** * overlay 타입 검증 * * @param array $data 확장 정의 * @param Closure $fail 실패 콜백 */ private function validateOverlay(array $data, Closure $fail): void { if (! is_string($data['target_layout']) || $data['target_layout'] === '') { $fail(__('validation.layout_extension.target_layout_invalid')); return; } // injections 검증 if (! isset($data['injections'])) { $fail(__('validation.layout_extension.injections_required')); return; } if (! is_array($data['injections'])) { $fail(__('validation.layout_extension.injections_must_be_array')); return; } foreach ($data['injections'] as $index => $injection) { if (! $this->validateInjection($injection, $index, $fail)) { return; } } $this->validateOptionalSections($data, $fail); } /** * 단일 injection 항목 검증 * * @param mixed $injection injection 정의 * @param int|string $index 인덱스 * @param Closure $fail 실패 콜백 * @return bool 유효 여부 */ private function validateInjection(mixed $injection, int|string $index, Closure $fail): bool { if (! is_array($injection)) { $fail(__('validation.layout_extension.injection_must_be_array', ['index' => $index])); return false; } // target_id 필수 if (! isset($injection['target_id']) || ! is_string($injection['target_id'])) { $fail(__('validation.layout_extension.injection_target_id_required', ['index' => $index])); return false; } // position 검증 (선택 — 기본 append_child) $validPositions = ['prepend', 'append', 'prepend_child', 'append_child', 'replace', 'inject_props']; $position = $injection['position'] ?? 'append_child'; if (! in_array($position, $validPositions, true)) { $fail(__('validation.layout_extension.injection_position_invalid', ['index' => $index])); return false; } // inject_props 가 아니면 components 검증 if ($position !== 'inject_props' && isset($injection['components'])) { if (! is_array($injection['components'])) { $fail(__('validation.layout_extension.injection_components_must_be_array', ['index' => $index])); return false; } foreach ($injection['components'] as $compIndex => $component) { if (! $this->validateComponent($component, "{$index}.{$compIndex}", $fail)) { return false; } } } // inject_props 는 props 배열 검증 if ($position === 'inject_props' && isset($injection['props']) && ! is_array($injection['props'])) { $fail(__('validation.layout_extension.injection_props_must_be_array', ['index' => $index])); return false; } return true; } /** * 선택적 섹션(data_sources, modals, scripts, computed, state, init_actions) 타입 검증 * * @param array $data 확장 정의 * @param Closure $fail 실패 콜백 */ private function validateOptionalSections(array $data, Closure $fail): void { foreach (['data_sources', 'modals', 'scripts', 'computed', 'state', 'init_actions'] as $section) { if (isset($data[$section]) && ! is_array($data[$section])) { $fail(__('validation.layout_extension.section_must_be_array', ['section' => $section])); return; } } } /** * 개별 컴포넌트 노드 검증 (ValidLayoutStructure 재귀 로직 참고) * * @param mixed $component 컴포넌트 노드 * @param int|string $index 인덱스 * @param Closure $fail 실패 콜백 * @param int $depth 현재 깊이 * @return bool 유효 여부 */ private function validateComponent(mixed $component, int|string $index, Closure $fail, int $depth = 0): bool { if ($depth > self::MAX_DEPTH) { $fail(__('validation.layout_extension.max_depth_exceeded', ['max' => self::MAX_DEPTH])); return false; } if (! is_array($component)) { $fail(__('validation.layout_extension.component_must_be_array', ['index' => $index])); return false; } // 슬롯/Partial 참조는 type/name 검증 건너뜀 if ((isset($component['slot']) && is_string($component['slot'])) || (isset($component['partial']) && is_string($component['partial']))) { return true; } // extension_point 노드는 type 만 있으면 됨 (중첩 확장점 허용) if (($component['type'] ?? '') === 'extension_point') { return true; } foreach (['type', 'name'] as $field) { if (! isset($component[$field])) { $fail(__('validation.layout_extension.component_required_field_missing', ['index' => $index, 'field' => $field])); return false; } } if (! is_string($component['name'])) { $fail(__('validation.layout_extension.component_name_must_be_string', ['index' => $index])); return false; } if (! in_array($component['type'], ['basic', 'composite', 'layout'], true)) { $fail(__('validation.layout_extension.component_type_invalid', ['index' => $index])); return false; } if (isset($component['props']) && ! is_array($component['props']) && ! is_object($component['props'])) { $fail(__('validation.layout_extension.props_must_be_object', ['index' => $index])); return false; } // children 재귀 검증 if (isset($component['children'])) { if (! is_array($component['children'])) { $fail(__('validation.layout_extension.children_must_be_array', ['index' => $index])); return false; } foreach ($component['children'] as $childIndex => $child) { if (! $this->validateComponent($child, "{$index}.children[{$childIndex}]", $fail, $depth + 1)) { return false; } } } return true; } }