input('content'); // content가 JSON 문자열인 경우 배열로 변환 if (is_string($content)) { $decoded = json_decode($content, true); if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { $this->merge(['content' => $decoded]); } // JSON 파싱 실패 시 원본 유지 (검증에서 'array' 규칙으로 실패 처리) } // 라우트 파라미터에서 templateName을 가져와 template_id로 변환 // ValidParentLayout 규칙에서 template_id를 사용하기 때문에 필요 $templateName = $this->route('templateName'); if ($templateName && ! $this->has('template_id')) { $template = \App\Models\Template::where('identifier', $templateName)->first(); if ($template) { $this->merge(['template_id' => $template->id]); } } } /** * 요청에 적용할 검증 규칙 * * extends 레이아웃과 standalone 레이아웃의 구조 차이를 고려합니다: * - standalone: endpoint, components 필수 * - extends: extends, slots 사용 (endpoint, components는 부모에서 상속) * * @return array|string> */ public function rules(): array { $content = $this->input('content'); // extends 레이아웃 여부 (extends 필드가 있고 null이 아닌 경우) $isExtending = is_array($content) && isset($content['extends']) && $content['extends'] !== null; // Base 레이아웃 여부 (slots를 정의하고 있는 경우 - 다른 레이아웃이 상속받는 용도) $isBaseLayout = is_array($content) && isset($content['slots']) && is_array($content['slots']); $rules = [ // content 전체 검증 (ValidLayoutStructure에서 extends/standalone 분기 처리) 'content' => [ 'required', 'array', new ValidLayoutStructure, ], // 버전 필드 'content.version' => ['required', 'string'], // 레이아웃명 'content.layout_name' => ['required', 'string', 'max:255'], // 상속 검증 (extends가 있는 경우) 'content.extends' => [ 'nullable', 'string', function (string $attribute, mixed $value, Closure $fail) { if ($value === null) { return; } $layoutId = request()->route('id'); $validator = new ValidParentLayout($layoutId); $validator->validate($attribute, $value, $fail); }, ], // 슬롯 검증 (extends 레이아웃에서 사용) 'content.slots' => [ 'nullable', 'array', new ValidSlotStructure, ], // 데이터소스 검증 'content.data_sources' => [ 'nullable', 'array', new ValidDataSourceMerge, ], // 메타데이터 (legacy - metadata 키 사용하는 경우) 'content.metadata' => ['nullable', 'array'], // 메타 정보 (title, description, auth_required 등) 'content.meta' => ['nullable', 'array'], 'content.meta.title' => ['nullable', 'string'], 'content.meta.description' => ['nullable', 'string'], 'content.meta.auth_required' => ['nullable', 'boolean'], 'content.meta.is_base' => ['nullable', 'boolean'], // SEO 메타데이터 'content.meta.seo' => ['nullable', 'array'], 'content.meta.seo.enabled' => ['nullable', 'boolean'], 'content.meta.seo.data_sources' => ['nullable', 'array'], 'content.meta.seo.data_sources.*' => ['string'], 'content.meta.seo.priority' => ['nullable', 'numeric', 'min:0', 'max:1'], 'content.meta.seo.changefreq' => ['nullable', 'string', 'in:always,hourly,daily,weekly,monthly,yearly,never'], 'content.meta.seo.og' => ['nullable', 'array'], 'content.meta.seo.structured_data' => ['nullable', 'array'], // 모달 컴포넌트 정의 'content.modals' => ['nullable', 'array'], // 상태 정의 (레이아웃 레벨 초기 상태) 'content.state' => ['nullable', 'array'], // 초기화 액션 (레이아웃 로드 시 실행) 'content.init_actions' => ['nullable', 'array'], // 정의 (재사용 가능한 컴포넌트 조각) 'content.defines' => ['nullable', 'array'], // 초기 상태 (init_state - state의 대체 키) 'content.init_state' => ['nullable', 'array'], // 라우트 정의 'content.routes' => ['nullable', 'array'], // 계산된 속성 'content.computed' => ['nullable', 'array'], // Named Actions (재사용 가능한 액션 정의) 'content.named_actions' => ['nullable', 'array'], 'content.named_actions.*' => ['array'], // 권한 (레이아웃 접근에 필요한 권한 식별자 배열 또는 OR/AND 구조) 'content.permissions' => ['nullable', new ValidPermissionStructure], // 전역 헤더 (API 호출 시 자동 적용되는 HTTP 헤더) 'content.globalHeaders' => ['nullable', 'array'], 'content.globalHeaders.*.pattern' => ['required', 'string'], 'content.globalHeaders.*.headers' => ['required', 'array'], 'content.globalHeaders.*.headers.*' => ['string'], // 전환 오버레이 설정 (페이지 전환 시 stale DOM 방지) 'content.transition_overlay' => ['nullable'], 'content.transition_overlay.enabled' => ['nullable', 'boolean'], 'content.transition_overlay.style' => ['nullable', 'string', Rule::in(['opaque', 'blur', 'fade', 'skeleton', 'spinner'])], 'content.transition_overlay.target' => ['nullable', 'string', 'max:100'], 'content.transition_overlay.fallback_target' => ['nullable', 'string', 'max:100'], 'content.transition_overlay.skeleton' => ['nullable', 'array'], 'content.transition_overlay.skeleton.component' => ['nullable', 'required_with:content.transition_overlay.skeleton', 'string', 'max:100'], 'content.transition_overlay.skeleton.animation' => ['nullable', 'string', Rule::in(['pulse', 'wave', 'none'])], 'content.transition_overlay.skeleton.iteration_count' => ['nullable', 'integer', 'min:1', 'max:50'], 'content.transition_overlay.spinner' => ['nullable', 'array'], 'content.transition_overlay.spinner.component' => ['nullable', 'string', 'max:100'], 'content.transition_overlay.spinner.text' => ['nullable', 'string', 'max:200'], ]; // extends 레이아웃 또는 Base 레이아웃이 아닌 경우에만 endpoint, components 필수 // - extends 레이아웃: 부모로부터 endpoint 상속 // - Base 레이아웃 (slots 정의): 자식 레이아웃이 endpoint 정의 if (! $isExtending && ! $isBaseLayout) { $rules['content.endpoint'] = [ 'required', 'string', new WhitelistedEndpoint, new NoExternalUrls, ]; $rules['content.components'] = ['required', 'array']; } else { // extends 또는 Base 레이아웃은 endpoint가 선택적 $rules['content.endpoint'] = [ 'nullable', 'string', new WhitelistedEndpoint, new NoExternalUrls, ]; // extends 레이아웃은 components 또는 slots 중 하나 사용 // ValidLayoutStructure에서 상세 검증 $rules['content.components'] = ['nullable', 'array']; } // 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공 return HookManager::applyFilters('core.layout.update_content_validation_rules', $rules, $this); } /** * 검증 오류 메시지 커스터마이징 * * @return array */ public function messages(): array { return [ 'content.required' => __('validation.layout.content.required'), 'content.array' => __('validation.layout.content.array'), 'content.version.required' => __('validation.layout.version.required'), 'content.version.string' => __('validation.layout.version.string'), 'content.layout_name.required' => __('validation.layout.layout_name.required'), 'content.layout_name.string' => __('validation.layout.layout_name.string'), 'content.layout_name.max' => __('validation.layout.layout_name.max'), 'content.endpoint.required' => __('validation.layout.endpoint.required'), 'content.endpoint.string' => __('validation.layout.endpoint.string'), 'content.extends.string' => __('validation.layout.extends.string'), 'content.slots.array' => __('validation.layout.slots.array'), 'content.data_sources.array' => __('validation.layout.data_sources.array'), 'content.components.required' => __('validation.layout.components.required'), 'content.components.array' => __('validation.layout.components.array'), 'content.metadata.array' => __('validation.layout.metadata.array'), 'content.meta.array' => __('validation.layout.meta.array'), 'content.meta.title.string' => __('validation.layout.meta.title.string'), 'content.meta.description.string' => __('validation.layout.meta.description.string'), 'content.meta.auth_required.boolean' => __('validation.layout.meta.auth_required.boolean'), 'content.meta.is_base.boolean' => __('validation.layout.meta.is_base.boolean'), 'content.meta.seo.array' => __('validation.layout.meta.seo.array'), 'content.meta.seo.enabled.boolean' => __('validation.layout.meta.seo.enabled.boolean'), 'content.meta.seo.data_sources.array' => __('validation.layout.meta.seo.data_sources.array'), 'content.meta.seo.data_sources.*.string' => __('validation.layout.meta.seo.data_sources.string'), 'content.meta.seo.priority.numeric' => __('validation.layout.meta.seo.priority.numeric'), 'content.meta.seo.priority.min' => __('validation.layout.meta.seo.priority.min'), 'content.meta.seo.priority.max' => __('validation.layout.meta.seo.priority.max'), 'content.meta.seo.changefreq.string' => __('validation.layout.meta.seo.changefreq.string'), 'content.meta.seo.changefreq.in' => __('validation.layout.meta.seo.changefreq.in'), 'content.meta.seo.og.array' => __('validation.layout.meta.seo.og.array'), 'content.meta.seo.structured_data.array' => __('validation.layout.meta.seo.structured_data.array'), 'content.modals.array' => __('validation.layout.modals.array'), 'content.state.array' => __('validation.layout.state.array'), 'content.init_actions.array' => __('validation.layout.init_actions.array'), 'content.defines.array' => __('validation.layout.defines.array'), 'content.init_state.array' => __('validation.layout.init_state.array'), 'content.routes.array' => __('validation.layout.routes.array'), 'content.computed.array' => __('validation.layout.computed.array'), 'content.permissions' => __('validation.layout.permissions.array'), 'content.globalHeaders.array' => __('validation.layout.globalHeaders.array'), 'content.globalHeaders.*.pattern.required' => __('validation.layout.globalHeaders.pattern.required'), 'content.globalHeaders.*.pattern.string' => __('validation.layout.globalHeaders.pattern.string'), 'content.globalHeaders.*.headers.required' => __('validation.layout.globalHeaders.headers.required'), 'content.globalHeaders.*.headers.array' => __('validation.layout.globalHeaders.headers.array'), 'content.globalHeaders.*.headers.*.string' => __('validation.layout.globalHeaders.headers.string'), // transition_overlay 'content.transition_overlay.enabled.boolean' => __('validation.layout.transition_overlay.enabled.boolean'), 'content.transition_overlay.style.string' => __('validation.layout.transition_overlay.style.string'), 'content.transition_overlay.style.in' => __('validation.layout.transition_overlay.style.in'), 'content.transition_overlay.target.string' => __('validation.layout.transition_overlay.target.string'), 'content.transition_overlay.target.max' => __('validation.layout.transition_overlay.target.max'), 'content.transition_overlay.fallback_target.string' => __('validation.layout.transition_overlay.fallback_target.string'), 'content.transition_overlay.fallback_target.max' => __('validation.layout.transition_overlay.fallback_target.max'), 'content.transition_overlay.skeleton.array' => __('validation.layout.transition_overlay.skeleton.array'), 'content.transition_overlay.skeleton.component.string' => __('validation.layout.transition_overlay.skeleton.component.string'), 'content.transition_overlay.skeleton.component.max' => __('validation.layout.transition_overlay.skeleton.component.max'), 'content.transition_overlay.skeleton.animation.string' => __('validation.layout.transition_overlay.skeleton.animation.string'), 'content.transition_overlay.skeleton.animation.in' => __('validation.layout.transition_overlay.skeleton.animation.in'), 'content.transition_overlay.skeleton.iteration_count.integer' => __('validation.layout.transition_overlay.skeleton.iteration_count.integer'), 'content.transition_overlay.skeleton.iteration_count.min' => __('validation.layout.transition_overlay.skeleton.iteration_count.min'), 'content.transition_overlay.skeleton.iteration_count.max' => __('validation.layout.transition_overlay.skeleton.iteration_count.max'), ]; } }