spec = $spec; } public static function fromJson(string $json): self { $data = json_decode($json, true); if (!is_array($data)) { throw new \InvalidArgumentException("Invalid JSON in workflow element spec"); } return new self($data); } public static function fromFile(string $path): self { if (!file_exists($path)) { throw new \InvalidArgumentException("Workflow spec file not found: $path"); } return self::fromJson((string) file_get_contents($path)); } public function name(): string { return $this->spec['name']; } public function itemName(): string { return $this->spec['item']['name'] ?? ($this->name() . '_item'); } public function isContainer(): bool { return !empty($this->spec['container']); } /** * Compiles all required files for the custom element. * * @return array relative_file_path => content */ public function compile(): array { $files = []; $name = $this->name(); // 1. Parent element $files["elements/{$name}/element.json"] = json_encode($this->compileParentSchema(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); $files["elements/{$name}/element.php"] = $this->compileElementPhp(); $files["elements/{$name}/templates/template.php"] = $this->compileParentTemplate(); $files["elements/{$name}/templates/content.php"] = $this->compileParentContent(); $files["elements/{$name}/images/icon.svg"] = $this->defaultIconSvg(); $files["elements/{$name}/images/iconSmall.svg"] = $this->defaultIconSmallSvg(); // 2. Child item element (if container) if ($this->isContainer()) { $itemName = $this->itemName(); $files["elements/{$itemName}/element.json"] = json_encode($this->compileItemSchema(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); $files["elements/{$itemName}/element.php"] = $this->compileItemPhp(); $files["elements/{$itemName}/templates/template.php"] = $this->compileItemTemplate(); $files["elements/{$itemName}/templates/content.php"] = $this->compileItemContent(); $files["elements/{$itemName}/images/icon.svg"] = $this->defaultIconSvg(); $files["elements/{$itemName}/images/iconSmall.svg"] = $this->defaultIconSmallSvg(); } return $files; } /** * Installs the generated workflow element directly into the yootheme_patches child theme, * maintaining 100% architectural consistency with the XS Elements ecosystem. */ public function installToPatches(string $patchesDir): array { $patchesDir = rtrim($patchesDir, '/'); if (!is_dir($patchesDir) || !file_exists($patchesDir . '/config.php')) { throw new \InvalidArgumentException("Invalid yootheme_patches directory: $patchesDir"); } $rawName = $this->name(); $cleanName = preg_replace('/^xs_/', '', $rawName); $pascalName = str_replace(' ', '', ucwords(str_replace('_', ' ', $cleanName))); $camelName = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $rawName)))); $targetElementDir = $patchesDir . '/src/Xs/' . $pascalName . '/element'; if (!is_dir($targetElementDir)) { mkdir($targetElementDir, 0755, true); } // Write elements without the extra 'elements/' prefix $compiled = $this->compile(); $writtenFiles = []; foreach ($compiled as $relPath => $content) { // Strip 'elements/' prefix $strippedPath = preg_replace('/^elements\//', '', $relPath); $fullPath = $targetElementDir . '/' . $strippedPath; $dir = dirname($fullPath); if (!is_dir($dir)) { mkdir($dir, 0755, true); } file_put_contents($fullPath, $content); $writtenFiles[] = $fullPath; } // 1. Update patches.php $patchesFile = $patchesDir . '/patches.php'; if (file_exists($patchesFile)) { $patchesCode = (string) file_get_contents($patchesFile); if (!str_contains($patchesCode, "'{$rawName}'")) { $hook = "// XS {$this->spec['title']} builder element (child-theme integrated)\n '{$rawName}' => true,\n"; $pos = strrpos($patchesCode, '];'); if ($pos !== false) { $patchesCode = substr_replace($patchesCode, " " . $hook, $pos, 0); file_put_contents($patchesFile, $patchesCode); } } } // 2. Update config.php $configFile = $patchesDir . '/config.php'; if (file_exists($configFile)) { $configCode = (string) file_get_contents($configFile); if (!str_contains($configCode, "'{$camelName}'")) { $block = "\n if (\$isPatchEnabled(\$config, '{$camelName}', true)) {\n" . " \$builder->addTypePath(__DIR__ . '/src/Xs/{$pascalName}/element/*/element.json');\n" . " }\n"; $anchor = "if (\$isPatchEnabled(\$config, 'xsChangelog', true)) {"; $pos = strpos($configCode, $anchor); if ($pos !== false) { $configCode = substr_replace($configCode, $block, $pos, 0); file_put_contents($configFile, $configCode); } } } return [ 'name' => $rawName, 'pascalName' => $pascalName, 'camelName' => $camelName, 'targetDir' => $targetElementDir, 'files' => $writtenFiles, ]; } public function spec(): array { return $this->spec; } /** * Writes compiled elements to the destination directory. */ public function write(string $targetDir): void { $compiled = $this->compile(); foreach ($compiled as $relPath => $content) { $fullPath = rtrim($targetDir, '/') . '/' . $relPath; $dir = dirname($fullPath); if (!is_dir($dir)) { mkdir($dir, 0755, true); } file_put_contents($fullPath, $content); } } /** * Writes compiled elements and zips them into a single archive. */ public function zip(string $outDir, string $zipPath): void { $this->write($outDir); $zip = new \ZipArchive(); if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { throw new \RuntimeException("Cannot create zip archive at: $zipPath"); } $files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($outDir, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::LEAVES_ONLY ); foreach ($files as $file) { if (!$file->isDir()) { $filePath = $file->getRealPath(); $relativePath = substr($filePath, strlen(realpath($outDir)) + 1); $zip->addFile($filePath, $relativePath); } } $zip->close(); } /** * Generates the YOOtheme builder element.json for the parent element. */ public function compileParentSchema(): array { $fields = []; $defaults = []; $tabs = []; foreach ($this->spec['tabs'] ?? [] as $tab) { $tabTitle = $tab['title'] ?? 'General'; $tabFieldNames = []; foreach ($tab['fields'] ?? [] as $f) { $fName = $f['name']; $tabFieldNames[] = $fName; $fieldDef = [ 'label' => $f['label'] ?? ucfirst(str_replace('_', ' ', $fName)), 'type' => $this->mapFieldType($f['type'] ?? 'text'), ]; if (isset($f['description'])) { $fieldDef['description'] = $f['description']; } if (isset($f['options']) && is_array($f['options'])) { $fieldDef['options'] = $f['options']; } if (isset($f['show'])) { $fieldDef['show'] = $f['show']; } $fields[$fName] = $fieldDef; if (isset($f['default'])) { $defaults[$fName] = $f['default']; } } $tabs[] = [ 'title' => $tabTitle, 'fields' => $tabFieldNames, ]; } // Append builder.advanced tab for standard YOOtheme advanced controls $tabs[] = '${builder.advanced}'; $schema = [ '@import' => './element.php', 'name' => $this->name(), 'title' => $this->spec['title'], 'group' => $this->spec['group'] ?? 'Workflow Elements', 'icon' => '${url:images/icon.svg}', 'iconSmall' => '${url:images/iconSmall.svg}', 'element' => true, 'container' => $this->isContainer(), 'width' => 500, 'defaults' => $defaults, 'templates' => [ 'render' => './templates/template.php', ], 'fields' => $fields, 'fieldset' => [ 'default' => [ 'type' => 'tabs', 'fields' => $tabs, ], ], ]; return $schema; } /** * Generates the YOOtheme builder element.json for the child item element. */ public function compileItemSchema(): array { $itemSpec = $this->spec['item'] ?? []; $itemName = $this->itemName(); $itemTitle = $itemSpec['title'] ?? ($this->spec['title'] . ' Record'); $fields = []; $defaults = []; $tabs = []; foreach ($itemSpec['tabs'] ?? [] as $tab) { $tabTitle = $tab['title'] ?? 'Record'; $tabFieldNames = []; foreach ($tab['fields'] ?? [] as $f) { $fName = $f['name']; $tabFieldNames[] = $fName; $fieldDef = [ 'label' => $f['label'] ?? ucfirst(str_replace('_', ' ', $fName)), 'type' => $this->mapFieldType($f['type'] ?? 'text'), ]; if (isset($f['description'])) { $fieldDef['description'] = $f['description']; } if (isset($f['options']) && is_array($f['options'])) { $fieldDef['options'] = $f['options']; } if (isset($f['show'])) { $fieldDef['show'] = $f['show']; } $fields[$fName] = $fieldDef; if (isset($f['default'])) { $defaults[$fName] = $f['default']; } } $tabs[] = [ 'title' => $tabTitle, 'fields' => $tabFieldNames, ]; } $tabs[] = '${builder.advanced}'; return [ '@import' => './element.php', 'name' => $itemName, 'title' => $itemTitle, 'width' => 500, 'defaults' => $defaults, 'templates' => [ 'render' => './templates/template.php', ], 'fields' => $fields, 'fieldset' => [ 'default' => [ 'type' => 'tabs', 'fields' => $tabs, ], ], ]; } /** * Hydrates an intelligent domain pipeline payload into a complete YOOtheme builder layout tree. */ public function hydrateLayout(array $payload, array $overrides = []): array { $parentProps = array_merge( $this->extractParentProps($payload), $overrides['props'] ?? [] ); $children = []; if ($this->isContainer()) { $rawItems = $payload['items'] ?? $payload['records'] ?? $payload['agenda'] ?? []; foreach ($rawItems as $rawItem) { $children[] = [ 'type' => $this->itemName(), 'props' => $this->extractItemProps($rawItem), ]; } } return [ 'type' => 'layout', 'version' => '5.0.0', 'children' => [ [ 'type' => 'section', 'props' => [ 'style' => 'default', 'width' => 'default', 'padding' => 'default', ], 'children' => [ [ 'type' => 'row', 'children' => [ [ 'type' => 'column', 'props' => ['image_position' => 'center-center'], 'children' => [ [ 'type' => $this->name(), 'props' => $parentProps, 'children' => $children, ], ], ], ], ], ], ], ], ]; } private function extractParentProps(array $payload): array { $props = []; foreach ($this->spec['tabs'] ?? [] as $tab) { foreach ($tab['fields'] ?? [] as $f) { $k = $f['name']; if (array_key_exists($k, $payload)) { $props[$k] = $payload[$k]; } elseif (isset($f['default'])) { $props[$k] = $f['default']; } } } return $props; } private function extractItemProps(array $rawItem): array { $props = []; $itemTabs = $this->spec['item']['tabs'] ?? []; foreach ($itemTabs as $tab) { foreach ($tab['fields'] ?? [] as $f) { $k = $f['name']; if (array_key_exists($k, $rawItem)) { $props[$k] = $rawItem[$k]; } elseif (isset($f['default'])) { $props[$k] = $f['default']; } } } return $props; } private function compileElementPhp(): string { return <<<'PHP' [ 'render' => function ($node) { // Ensure child records are available $node->children = $node->children ?? []; return $node; }, ], ]; PHP; } private function compileItemPhp(): string { return <<<'PHP' [ 'render' => function ($node) { return $node; }, ], ]; PHP; } private function compileParentTemplate(): string { return <<<'PHP'
< class="uk-margin-remove-top uk-margin-small-bottom">>

  • render($child) ?>
  • render($child) ?>
PHP; } private function compileItemTemplate(): string { return <<<'PHP'

PHP; } private function compileParentContent(): string { return <<<'PHP'

  1. render($child, ['element' => $props]) ?>
PHP; } private function compileItemContent(): string { return <<<'PHP'

PHP; } private function mapFieldType(string $type): string { return match ($type) { 'text' => 'text', 'textarea' => 'textarea', 'editor' => 'editor', 'select' => 'select', 'checkbox' => 'checkbox', 'color' => 'color', 'image' => 'image', 'video' => 'video', 'number' => 'number', 'range' => 'range', default => 'text', }; } private function defaultIconSvg(): string { return ''; } private function defaultIconSmallSvg(): string { return ''; } }