*/ private array $definitions; public function __construct(private readonly RichTextSanitizer $richText) { $this->definitions = collect($this->definitions()) ->keyBy(fn (BlockDefinition $definition) => $definition->type) ->all(); } /** @return list> */ public function metadata(): array { return array_values(array_map( fn (BlockDefinition $definition) => $definition->metadata(), $this->definitions, )); } public function definition(string $type): BlockDefinition { $definition = $this->definitions[$type] ?? null; if (! $definition) { throw ValidationException::withMessages(['type' => ['The selected block type is not registered.']]); } return $definition; } /** @param array $data @return array */ public function validate(string $type, int $schemaVersion, array $data): array { $definition = $this->definition($type); if ($schemaVersion !== $definition->schemaVersion) { throw ValidationException::withMessages([ 'schemaVersion' => ["Schema version {$schemaVersion} is not supported for {$type}."], ]); } $allowedFields = collect(array_keys($definition->rules)) ->map(fn (string $field): string => str($field)->before('.')->toString()) ->unique() ->all(); $unknownFields = array_diff(array_keys($data), $allowedFields); if ($unknownFields !== []) { throw ValidationException::withMessages(collect($unknownFields) ->mapWithKeys(fn (string $field): array => ["data.{$field}" => ['This field is not defined by the registered block schema.']]) ->all()); } $validated = Validator::make($data, $definition->rules)->validate(); if ($type === 'text') { $validated['html'] = $this->richText->sanitize($validated['html']); } return $validated; } /** @param array $data @return array{version: int, data: array} */ public function migrate(string $type, int $schemaVersion, array $data): array { return $this->definition($type)->migrate($schemaVersion, $data); } /** @return list */ private function definitions(): array { return [ new BlockDefinition('heading', 'basic', 'عنوان', 'heading', 1, ['text' => 'عنوان بخش', 'level' => 2], ['text' => ['required', 'string', 'max:240'], 'level' => ['required', 'integer', 'between:1,3']]), new BlockDefinition('text', 'basic', 'متن', 'text', 1, ['html' => '

متن خود را اینجا بنویسید.

'], ['html' => ['required', 'string', 'max:50000']], ['searchable']), new BlockDefinition('quote', 'basic', 'نقل‌قول', 'quote', 1, ['text' => 'متن نقل‌قول', 'cite' => ''], ['text' => ['required', 'string', 'max:3000'], 'cite' => ['nullable', 'string', 'max:240']]), new BlockDefinition('key_point', 'basic', 'نکته کلیدی', 'lightbulb', 1, ['title' => 'نکته کلیدی', 'body' => 'پیام مهم این بخش'], ['title' => ['required', 'string', 'max:160'], 'body' => ['required', 'string', 'max:2000']]), new BlockDefinition('divider', 'basic', 'جداکننده', 'minus', 1, ['label' => ''], ['label' => ['nullable', 'string', 'max:120']]), new BlockDefinition('button', 'basic', 'دکمه', 'mouse-pointer-click', 1, ['label' => 'ادامه', 'url' => null, 'openInNewTab' => false], ['label' => ['required', 'string', 'max:120'], 'url' => ['nullable', 'url', 'max:2048'], 'openInNewTab' => ['required', 'boolean']], ['click']), new BlockDefinition('image', 'media', 'تصویر', 'image', 1, ['assetId' => null, 'url' => null, 'alt' => '', 'caption' => '', 'decorative' => false], ['assetId' => ['nullable', 'string'], 'url' => ['nullable', 'url', 'max:2048'], 'alt' => ['nullable', 'string', 'max:300'], 'caption' => ['nullable', 'string', 'max:500'], 'decorative' => ['required', 'boolean']], ['downloadable']), new BlockDefinition('gallery', 'media', 'گالری', 'images', 1, ['items' => [], 'layout' => 'grid'], ['items' => ['present', 'array', 'max:12'], 'items.*.assetId' => ['required', 'string'], 'items.*.alt' => ['nullable', 'string', 'max:300'], 'items.*.caption' => ['nullable', 'string', 'max:500'], 'layout' => ['required', 'in:grid,carousel']], ['interaction']), new BlockDefinition('video', 'media', 'ویدئو', 'video', 1, ['assetId' => null, 'url' => null, 'title' => '', 'transcript' => '', 'captionsUrl' => null], ['assetId' => ['nullable', 'string'], 'url' => ['nullable', 'url', 'max:2048'], 'title' => ['nullable', 'string', 'max:240'], 'transcript' => ['nullable', 'string', 'max:50000'], 'captionsUrl' => ['nullable', 'url', 'max:2048']], ['completion_tracking']), new BlockDefinition('audio', 'media', 'صوت', 'audio-lines', 1, ['assetId' => null, 'url' => null, 'title' => '', 'transcript' => ''], ['assetId' => ['nullable', 'string'], 'url' => ['nullable', 'url', 'max:2048'], 'title' => ['nullable', 'string', 'max:240'], 'transcript' => ['nullable', 'string', 'max:50000']], ['completion_tracking']), new BlockDefinition('document', 'media', 'سند', 'file-text', 1, ['assetId' => null, 'title' => 'سند', 'description' => ''], ['assetId' => ['nullable', 'string'], 'title' => ['required', 'string', 'max:240'], 'description' => ['nullable', 'string', 'max:1000']], ['downloadable']), new BlockDefinition('embed', 'media', 'محتوای تعبیه‌شده', 'code-xml', 1, ['url' => null, 'title' => '', 'aspectRatio' => '16/9'], ['url' => ['nullable', 'url', 'max:2048'], 'title' => ['nullable', 'string', 'max:240'], 'aspectRatio' => ['required', 'in:16/9,4/3,1/1']], [], ['sandboxed_iframe'], ['pdf' => 'link', 'scorm' => 'native', 'mp4' => 'poster']), new BlockDefinition('accordion', 'learning', 'آکاردئون', 'list-collapse', 1, ['items' => [['title' => 'عنوان', 'body' => 'توضیحات']]], ['items' => ['required', 'array', 'min:1', 'max:20'], 'items.*.title' => ['required', 'string', 'max:240'], 'items.*.body' => ['required', 'string', 'max:5000']]), new BlockDefinition('flashcard', 'learning', 'فلش‌کارت', 'cards', 1, ['front' => 'پرسش', 'back' => 'پاسخ'], ['front' => ['required', 'string', 'max:2000'], 'back' => ['required', 'string', 'max:5000']], ['interaction']), new BlockDefinition('tabs', 'learning', 'زبانه‌ها', 'panel-top', 1, ['items' => [['title' => 'زبانه اول', 'body' => 'محتوا']]], ['items' => ['required', 'array', 'min:1', 'max:8'], 'items.*.title' => ['required', 'string', 'max:120'], 'items.*.body' => ['required', 'string', 'max:5000']], ['interaction']), new BlockDefinition('timeline', 'learning', 'خط زمانی', 'git-commit-horizontal', 1, ['items' => [['title' => 'مرحله اول', 'body' => 'توضیحات', 'date' => '']]], ['items' => ['required', 'array', 'min:1', 'max:20'], 'items.*.title' => ['required', 'string', 'max:160'], 'items.*.body' => ['nullable', 'string', 'max:3000'], 'items.*.date' => ['nullable', 'string', 'max:80']]), new BlockDefinition('steps', 'learning', 'مراحل', 'list-ordered', 1, ['items' => [['title' => 'مرحله اول', 'body' => 'توضیحات']]], ['items' => ['required', 'array', 'min:1', 'max:20'], 'items.*.title' => ['required', 'string', 'max:160'], 'items.*.body' => ['nullable', 'string', 'max:3000']], ['progress']), new BlockDefinition('process', 'learning', 'فرایند', 'workflow', 1, ['items' => [['title' => 'شروع', 'body' => 'توضیحات']]], ['items' => ['required', 'array', 'min:1', 'max:12'], 'items.*.title' => ['required', 'string', 'max:160'], 'items.*.body' => ['nullable', 'string', 'max:2000']]), new BlockDefinition('checklist', 'learning', 'چک‌لیست', 'list-checks', 1, ['items' => [['text' => 'مورد اول', 'required' => true]]], ['items' => ['required', 'array', 'min:1', 'max:30'], 'items.*.text' => ['required', 'string', 'max:500'], 'items.*.required' => ['required', 'boolean']], ['interaction', 'completion_tracking']), new BlockDefinition('single_choice', 'assessment', 'تک‌گزینه‌ای', 'circle-check', 1, ['prompt' => 'پرسش را وارد کنید', 'options' => ['گزینه اول', 'گزینه دوم'], 'answerIndex' => 0], ['prompt' => ['required', 'string', 'max:2000'], 'options' => ['required', 'array', 'min:2', 'max:10'], 'options.*' => ['required', 'string', 'max:500'], 'answerIndex' => ['required', 'integer', 'min:0']], ['assessment', 'evidence']), new BlockDefinition('true_false', 'assessment', 'درست / نادرست', 'toggle-left', 1, ['prompt' => 'عبارت را وارد کنید', 'answer' => true], ['prompt' => ['required', 'string', 'max:2000'], 'answer' => ['required', 'boolean']], ['assessment', 'evidence']), new BlockDefinition('multiple_choice', 'assessment', 'چندگزینه‌ای', 'list-checks', 1, ['prompt' => 'پرسش را وارد کنید', 'options' => ['گزینه اول', 'گزینه دوم'], 'answerIndexes' => [0]], ['prompt' => ['required', 'string', 'max:2000'], 'options' => ['required', 'array', 'min:2', 'max:12'], 'options.*' => ['required', 'string', 'max:500'], 'answerIndexes' => ['required', 'array', 'min:1'], 'answerIndexes.*' => ['integer', 'min:0']], ['assessment', 'evidence']), new BlockDefinition('matching', 'assessment', 'تطبیقی', 'rows-3', 1, ['prompt' => 'موارد مرتبط را به هم وصل کنید', 'pairs' => [['left' => 'عبارت اول', 'right' => 'پاسخ اول'], ['left' => 'عبارت دوم', 'right' => 'پاسخ دوم']]], ['prompt' => ['required', 'string', 'max:2000'], 'pairs' => ['required', 'array', 'min:2', 'max:12'], 'pairs.*.left' => ['required', 'string', 'max:500'], 'pairs.*.right' => ['required', 'string', 'max:500']], ['assessment', 'evidence', 'interaction']), new BlockDefinition('sorting', 'assessment', 'مرتب‌سازی', 'arrow-down-up', 1, ['prompt' => 'موارد را به ترتیب صحیح بچینید', 'items' => [['text' => 'مرحله اول'], ['text' => 'مرحله دوم']]], ['prompt' => ['required', 'string', 'max:2000'], 'items' => ['required', 'array', 'min:2', 'max:12'], 'items.*.text' => ['required', 'string', 'max:500']], ['assessment', 'evidence', 'interaction']), new BlockDefinition('drag_drop', 'assessment', 'کشیدن و رهاکردن', 'move', 1, ['prompt' => 'هر مورد را در مقصد صحیح قرار دهید', 'items' => [['text' => 'آیتم اول', 'target' => 'گروه اول'], ['text' => 'آیتم دوم', 'target' => 'گروه دوم']]], ['prompt' => ['required', 'string', 'max:2000'], 'items' => ['required', 'array', 'min:2', 'max:16'], 'items.*.text' => ['required', 'string', 'max:500'], 'items.*.target' => ['required', 'string', 'max:160']], ['assessment', 'evidence', 'interaction']), new BlockDefinition('hotspot', 'assessment', 'نقطه داغ', 'scan', 1, ['prompt' => 'نقطه صحیح را روی تصویر انتخاب کنید', 'assetId' => null, 'hotspots' => [['label' => 'نقطه صحیح', 'x' => 50, 'y' => 50, 'radius' => 10, 'correct' => true]]], ['prompt' => ['required', 'string', 'max:2000'], 'assetId' => ['nullable', 'string'], 'hotspots' => ['required', 'array', 'min:1', 'max:12'], 'hotspots.*.label' => ['required', 'string', 'max:160'], 'hotspots.*.x' => ['required', 'numeric', 'between:0,100'], 'hotspots.*.y' => ['required', 'numeric', 'between:0,100'], 'hotspots.*.radius' => ['required', 'numeric', 'between:1,40'], 'hotspots.*.correct' => ['required', 'boolean']], ['assessment', 'evidence', 'interaction']), new BlockDefinition('scenario', 'assessment', 'سناریو', 'messages-square', 1, ['prompt' => 'در این موقعیت چه می‌کنید؟', 'context' => 'موقعیت را شرح دهید.', 'choices' => [['text' => 'انتخاب اول', 'feedback' => 'بازخورد انتخاب اول', 'score' => 1], ['text' => 'انتخاب دوم', 'feedback' => 'بازخورد انتخاب دوم', 'score' => 0]]], ['prompt' => ['required', 'string', 'max:2000'], 'context' => ['required', 'string', 'max:5000'], 'choices' => ['required', 'array', 'min:2', 'max:8'], 'choices.*.text' => ['required', 'string', 'max:1000'], 'choices.*.feedback' => ['required', 'string', 'max:3000'], 'choices.*.score' => ['required', 'numeric', 'between:0,1']], ['assessment', 'evidence', 'interaction']), new BlockDefinition('branching_scenario', 'assessment', 'سناریوی شاخه‌ای', 'git-branch', 1, ['prompt' => 'سناریوی شاخه‌ای', 'startNodeId' => 'start', 'nodes' => [['id' => 'start', 'type' => 'scene', 'title' => 'شروع', 'body' => 'صحنه آغازین', 'targetNodeId' => 'result'], ['id' => 'result', 'type' => 'result', 'title' => 'نتیجه', 'body' => 'پایان سناریو', 'targetNodeId' => '']]], ['prompt' => ['required', 'string', 'max:2000'], 'startNodeId' => ['required', 'string', 'max:80'], 'nodes' => ['required', 'array', 'min:2', 'max:80'], 'nodes.*.id' => ['required', 'string', 'distinct', 'max:80'], 'nodes.*.type' => ['required', 'in:scene,question,result'], 'nodes.*.title' => ['required', 'string', 'max:240'], 'nodes.*.body' => ['nullable', 'string', 'max:5000'], 'nodes.*.targetNodeId' => ['nullable', 'string', 'max:80']], ['assessment', 'evidence', 'interaction']), new BlockDefinition('interactive_image', 'assessment', 'تصویر تعاملی', 'image-up', 1, ['assetId' => null, 'alt' => '', 'markers' => [['label' => 'نقطه ۱', 'body' => 'توضیحات', 'x' => 50, 'y' => 50]]], ['assetId' => ['nullable', 'string'], 'alt' => ['nullable', 'string', 'max:300'], 'markers' => ['required', 'array', 'min:1', 'max:20'], 'markers.*.label' => ['required', 'string', 'max:160'], 'markers.*.body' => ['nullable', 'string', 'max:2000'], 'markers.*.x' => ['required', 'numeric', 'between:0,100'], 'markers.*.y' => ['required', 'numeric', 'between:0,100']], ['interaction']), new BlockDefinition('before_after', 'assessment', 'قبل و بعد', 'columns-2', 1, ['beforeAssetId' => null, 'afterAssetId' => null, 'beforeLabel' => 'قبل', 'afterLabel' => 'بعد', 'alt' => ''], ['beforeAssetId' => ['nullable', 'string'], 'afterAssetId' => ['nullable', 'string'], 'beforeLabel' => ['required', 'string', 'max:80'], 'afterLabel' => ['required', 'string', 'max:80'], 'alt' => ['nullable', 'string', 'max:300']], ['interaction']), new BlockDefinition('columns', 'layout', 'ستون‌ها', 'columns-2', 1, ['preset' => '50/50', 'gap' => 'medium', 'mobileOrder' => 'logical', 'columns' => [['title' => 'ستون ۱', 'content' => ''], ['title' => 'ستون ۲', 'content' => '']]], ['preset' => ['required', 'in:50/50,33/67,67/33,three'], 'gap' => ['required', 'in:small,medium,large'], 'mobileOrder' => ['required', 'in:logical,reverse'], 'columns' => ['required', 'array', 'min:2', 'max:3'], 'columns.*.title' => ['nullable', 'string', 'max:120'], 'columns.*.content' => ['nullable', 'string', 'max:5000']]), new BlockDefinition('section', 'layout', 'بخش', 'panel-top', 1, ['title' => 'بخش جدید', 'description' => ''], ['title' => ['required', 'string', 'max:240'], 'description' => ['nullable', 'string', 'max:1000']]), new BlockDefinition('controlled_grid', 'layout', 'شبکه کنترل‌شده', 'layout-grid', 1, ['columns' => 3, 'items' => [['title' => 'کارت اول', 'body' => 'محتوا']]], ['columns' => ['required', 'integer', 'between:2,4'], 'items' => ['required', 'array', 'min:1', 'max:12'], 'items.*.title' => ['required', 'string', 'max:160'], 'items.*.body' => ['nullable', 'string', 'max:3000']]), ]; } }