session_id, $this->type, $this->sub_type, $old_template_id, $this->dir_path ); } /** * Check if content should be processed as AI content * * @param string $old_template_id The template ID to check * @return bool True if this is AI content, false otherwise */ public function isAiContent($old_template_id) { return AIUtils::should_process_as_ai_content( $this->session_id, $this->type, $this->sub_type, $old_template_id, $this->ai_page_ids, $this->dir_path ); } /** * Check if an AI file exists and is marked as skipped * * @param string $old_template_id The template ID to check * @return bool True if AI file exists and is skipped, false otherwise */ public function isAiFileSkipped($old_template_id) { // Generate file paths $paths = $this->generateAiFilePaths($old_template_id); return AIUtils::is_ai_file_skipped($paths['ai_file_path']); } /** * Normalize escape characters in AI content * * Removes backslashes before closing tags (converts `<\/` and `<\\/` to ` &$content_item) { if (isset($content_item['content']) && is_string($content_item['content'])) { // Normalize content: unescape closing tags (remove backslash before $value) { if (is_array($value)) { // If this is an element with 'widgetType' and 'contents', use its parent key as ID if (isset($value['widgetType']) && isset($value['contents'])) { $flat[$key] = $value; } // Recurse into children self::flattenById($value, $flat); } } return $flat; } /** * Set a value in a nested array using a dot notation path * * @param array $array Reference to the array to modify * @param array $path The path as an array of keys * @param mixed $value The value to set */ public static function setNestedValue(&$array, $path, $value) { $key = array_shift($path); if (empty($path)) { // We've reached the final key, set the value $array[$key] = $value; } else { // Initialize the nested array if it doesn't exist if (!isset($array[$key]) || !is_array($array[$key])) { $array[$key] = []; } // Continue recursively self::setNestedValue($array[$key], $path, $value); } } /** * Process AI content by merging it with the original template * * @param string $old_template_id The template ID to process * @return array Array containing the processed template and whether it's AI content */ public function processAiContent($old_template_id) { // Generate file paths $paths = $this->generateAiFilePaths($old_template_id); $original_file = $paths['original_file']; $ai_file = $paths['ai_file_path']; $file = $original_file; $isAi = false; // Check for AI file if ($this->hasAiFile($ai_file)) { $file = $ai_file; $isAi = true; } // Read the template JSON $template_json = Utils::read_json_file($file); if ($isAi) { // Read original template JSON content for merging $original_template_json = Utils::read_json_file($original_file); $ai_template_json = $template_json; if($this->platform === 'elementor'){ $template_json = self::mergeAiContentWithOriginal($ai_template_json, $original_template_json); } else if($this->platform === 'gutenberg'){ $template_json = self::mergeAiContentWithOriginalGutenberg($ai_template_json, $original_template_json); } // Write debug files after merging $this->writeDebugFile($original_file, $template_json, 'ao'); } return [ 'template_json' => $template_json, 'is_ai' => $isAi, 'file_used' => $file ]; } /** * Merge AI content with the original template * * @param array $ai_template_json The AI template JSON * @param array $original_template_json The original template JSON data * @return array The merged template JSON */ public static function mergeAiContentWithOriginal($ai_template_json, $original_template_json) { // 1. Flatten the AI template $flat = self::flattenById($ai_template_json); // 2. Normalize escape characters in AI content early in the pipeline self::normalizeAiContentEscapeCharacters($flat); $keys = array_keys($flat); // 3. Loop through original content only once and update elements directly self::updateElementorContentRecursively($flat, $keys, $original_template_json['content']); return $original_template_json; } /** * Update Elementor content recursively by looping through original content only once * * @param array $flat The flattened AI content array * @param array $keys Array of element IDs from the flat array * @param array $content Reference to the original content to update */ public static function updateElementorContentRecursively($flat, array $keys, &$content) { if (!is_array($content)) { return; } // Check if this element has an ID and needs updating if (isset($content['id']) && in_array($content['id'], $keys)) { $element_id = $content['id']; $element = $flat[$element_id]; if (isset($element['contents'])) { // Update settings based on contents foreach ($element['contents'] as $item) { if (isset($item['attribute'], $item['content'])) { $content_value = is_string($item['content']) ? str_replace(['<\/', '<\\/'], ' $blockData['contents']]); if(isset($block["innerBlocks"][0]["attrs"]["accordionLists"]) && count($block["innerBlocks"][0]["attrs"]["accordionLists"]) > 1){ $block["innerBlocks"] = self::replaceGutenbergContentRecursively($_generated, $block_inner_block_ids, $block['innerBlocks']); } else { // $block["attrs"]["accordionLists"][0]["id"] $attrAccordionLists = $block["attrs"]["accordionLists"]; foreach ($block["innerBlocks"] as $key => $accordion) { foreach($accordion["attrs"]["accordionLists"] as $accordionKey => $accordionList){ // $accordionList["id"] // search $block["attrs"]["accordionLists"] by $accordionList["id"] and replace $accordionList with searched one $ids = array_column($attrAccordionLists, 'id'); $foundIndex = array_search($accordionList["id"], $ids); if ($foundIndex !== false) { $block["innerBlocks"][$key]["attrs"]["accordionLists"][$accordionKey] = $attrAccordionLists[$foundIndex]; } } } } } } // Process nested blocks recursively if (!empty($block['innerBlocks'])) { $block['innerBlocks'] = self::replaceGutenbergContentRecursively($generated, $keys, $block['innerBlocks']); } } } return $blocks; } /** * Set nested attribute value using dot notation (ported from GutenbergContentReplacer) */ public static function setNestedGutenbergAttribute(&$attrs, $path, $value) { $keys = explode('.', $path); $current = &$attrs; for ($i = 0; $i < count($keys) - 1; $i++) { $key = $keys[$i]; if (!isset($current[$key])) { $current[$key] = []; } $current = &$current[$key]; } $finalKey = end($keys); $current[$finalKey] = $value; } /** * Get nested attribute value using dot notation (ported from GutenbergContentReplacer) */ public static function getNestedGutenbergAttribute($attrs, $path) { $keys = explode('.', $path); $current = $attrs; foreach ($keys as $key) { if (!isset($current[$key])) { return null; } $current = $current[$key]; } return $current; } /** * Replace content in innerHTML and innerContent while preserving HTML structure (ported from GutenbergContentReplacer) */ public static function replaceInGutenbergHtmlContent(&$block, $blockData, $oldContentMap) { if (empty($blockData['contents']) || empty($oldContentMap)) return; $replacements = []; foreach ($blockData['contents'] as $content) { $attribute = $content['attribute']; $newContent = $content['content']; if (isset($oldContentMap[$attribute])) { $oldAttributeContent = $oldContentMap[$attribute]; $decodedUnicodeContent = json_decode('"' . $oldAttributeContent . '"'); $normalizedAttributeContent = self::normalizeGutenbergUnicodeContent($oldAttributeContent); $normalizedNewContent = self::normalizeGutenbergUnicodeContent($newContent); if ($normalizedAttributeContent !== $normalizedNewContent) { $replacements[] = [ 'originalFormat' => $oldAttributeContent, 'decodedFormat' => $decodedUnicodeContent, 'normalizedFormat' => $normalizedAttributeContent, 'newContent' => $newContent, 'attribute' => $attribute ]; } } } // sort $replacements by length of 'originalFormat' in descending order usort($replacements, function($a, $b) { return strlen($b['originalFormat']) - strlen($a['originalFormat']); }); if (!empty($block['innerHTML']) && !empty($replacements)) { $block['innerHTML'] = self::replaceGutenbergContentInHtml($block['innerHTML'], $replacements); } if (!empty($block['innerContent']) && is_array($block['innerContent'])) { foreach ($block['innerContent'] as &$content) { if (is_string($content)) { $content = self::replaceGutenbergContentInHtml($content, $replacements); } } } } /** * Replace content in HTML while preserving structure and handling Unicode (ported from GutenbergContentReplacer) * * Uses targeted replacement that avoids replacing text inside HTML attributes (href, src, data-*, etc.) * to prevent breaking URLs and other attribute values. */ public static function replaceGutenbergContentInHtml($html, $replacements) { foreach ($replacements as $replacement) { $originalFormat = $replacement['originalFormat']; $decodedFormat = $replacement['decodedFormat']; $normalizedFormat = $replacement['normalizedFormat']; $newContent = $replacement['newContent']; if (empty($originalFormat)) continue; $htmlNewContent = $newContent; // Use targeted replacement that avoids HTML attributes $html = self::replaceTextOutsideAttributes($html, $originalFormat, $htmlNewContent); if ($decodedFormat !== null && $decodedFormat !== $originalFormat) { $html = self::replaceTextOutsideAttributes($html, $decodedFormat, $htmlNewContent); } if ($normalizedFormat !== $decodedFormat && $normalizedFormat !== $originalFormat) { $html = self::replaceTextOutsideAttributes($html, $normalizedFormat, $htmlNewContent); } } return $html; } /** * Replace text in HTML only outside of HTML tags and attributes * * This function replaces occurrences of $oldText with $newText, but only when the text * appears outside of HTML tags and attributes. This prevents unintended replacements * inside URLs, src attributes, href attributes, and other HTML attributes. * * @param string $html The HTML content to process * @param string $oldText The text to find and replace * @param string $newText The replacement text * @return string The HTML with replacements applied only outside of tags/attributes */ private static function replaceTextOutsideAttributes($html, $oldText, $newText) { if (empty($oldText) || $oldText === $newText) { return $html; } $result = ''; $lastPos = 0; // Find all occurrences of the text while (($pos = strpos($html, $oldText, $lastPos)) !== false) { // Check if this occurrence is inside an HTML tag or attribute if (!self::isPositionInsideTag($html, $pos)) { // Not inside a tag, safe to replace $result .= substr($html, $lastPos, $pos - $lastPos) . $newText; $lastPos = $pos + strlen($oldText); } else { // Inside a tag, skip this occurrence $result .= substr($html, $lastPos, $pos - $lastPos + strlen($oldText)); $lastPos = $pos + strlen($oldText); } } // Append remaining HTML $result .= substr($html, $lastPos); return $result; } /** * Check if a position in HTML is inside an HTML attribute value * * This checks if the position is between quotes within an HTML tag. * Returns true only if the position is inside an attribute value (between quotes), * not just anywhere inside a tag. * * @param string $html The HTML content * @param int $position The position to check * @return bool True if the position is inside an attribute value, false otherwise */ private static function isPositionInsideTag($html, $position) { // Get the text before the position $beforeText = substr($html, 0, $position); // Find the last < and > before the position $lastOpenTag = strrpos($beforeText, '<'); $lastCloseTag = strrpos($beforeText, '>'); // If there's no unclosed tag, we're not inside a tag if ($lastOpenTag === false || ($lastCloseTag !== false && $lastOpenTag < $lastCloseTag)) { return false; } // We're inside a tag. Now check if we're inside an attribute value (between quotes) // Get the tag content from the last < to the position $tagContent = substr($html, $lastOpenTag, $position - $lastOpenTag); // Track whether we're inside double or single quotes by iterating through the tag content $inDoubleQuotes = false; $inSingleQuotes = false; for ($i = 0; $i < strlen($tagContent); $i++) { $char = $tagContent[$i]; // Toggle quote state when we encounter a quote if ($char === '"' && !$inSingleQuotes) { $inDoubleQuotes = !$inDoubleQuotes; } elseif ($char === "'" && !$inDoubleQuotes) { $inSingleQuotes = !$inSingleQuotes; } } // We're inside an attribute value if we're inside either type of quotes return $inDoubleQuotes || $inSingleQuotes; } /** * Normalize Unicode content to handle different apostrophe types and other Unicode variations (ported from GutenbergContentReplacer) */ public static function normalizeGutenbergUnicodeContent($content) { $decoded = json_decode('"' . $content . '"'); if ($decoded !== null) { $content = $decoded; } $unicodeReplacements = [ '\u2019' => "'", '\u2018' => "'", '\u201C' => '"', '\u201D' => '"', '\u2013' => '-', '\u2014' => '-', '\u2026' => '...', "\u{2019}" => "'", "\u{2018}" => "'", "\u{201C}" => '"', "\u{201D}" => '"', "\u{2013}" => '-', "\u{2014}" => '-', "\u{2026}" => '...' ]; return str_replace(array_keys($unicodeReplacements), array_values($unicodeReplacements), $content); } /** * Convert content to HTML format (handle line breaks and inline tags) (ported from GutenbergContentReplacer) */ public static function convertGutenbergToHtmlFormat($content) { $content = str_replace("\n", '
', $content); $content = str_replace("\r\n", '
', $content); return $content; } /** * Recursively flatten a nested Gutenberg AI array by extracting blocks with 'contents' and using their blockId as key * * @param array $array The array to flatten * @param array $flat Reference to the flattened array * @return array The flattened array */ public static function flattenGutenbergById($array, &$flat = []) { foreach ($array as $key => $value) { if (is_array($value)) { // If this is a block with 'blockName' and 'contents', use its parent key as ID if (isset($value['blockName']) && isset($value['contents'])) { $flat[$key] = $value; } // Recurse into children self::flattenGutenbergById($value, $flat); } } return $flat; } /** * Write debug file only if TEMPLATELY_DEV_VIEWS is defined and true * Handles .ai.json to .ao.json or .og.json as appropriate */ protected function writeDebugFile($ai_file, $data, $type = 'ao') { if ((defined('TEMPLATELY_DEV') && TEMPLATELY_DEV) || (defined('IMPORT_DEBUG') && IMPORT_DEBUG)) { $replace = ".{$type}.json"; $debug_file = str_replace('.json', $replace, $ai_file); file_put_contents($debug_file, json_encode($data)); } } /** * Replace the inner content of tags with given class names in the HTML. * Supports indexed class names (e.g., "eb-feature-list-title.0", "eb-feature-list-title.1"). * Falls back to regex if DOMDocument does not find the class. * * @param string $html The HTML string. * @param array $contents Array of ['attribute' => className, 'content' => newContent] * @return string The updated HTML. */ public static function replaceContentByClassName($html, $contents) { $classExists = false; foreach ($contents as $item) { $className = $item['attribute']; // Extract base class name (remove index if present) $baseClassName = self::extractBaseClassName($className); if (preg_match('/class=["\'][^"\']*\b' . preg_quote($baseClassName, '/') . '\b[^"\']*["\']/', $html)) { $classExists = true; break; } } if (!$classExists) { return $html; // No relevant class found, skip both methods } if (class_exists('DOMDocument') && class_exists('DOMXPath')) { return self::replaceContentByClassNameDom($html, $contents); } else { return self::replaceContentByClassNameRegex($html, $contents); } } /** * Extract base class name from indexed class name. * * @param string $className The class name (e.g., "eb-feature-list-title.0") * @return string The base class name (e.g., "eb-feature-list-title") */ public static function extractBaseClassName($className) { // Check if class name has numeric index at the end if (preg_match('/^(.+)\.(\d+)$/', $className, $matches)) { return $matches[1]; // Return base class name } return $className; // Return original if no index found } /** * Extract index from indexed class name. * * @param string $className The class name (e.g., "eb-feature-list-title.0") * @return int|null The index (e.g., 0) or null if no index found */ public static function extractClassIndex($className) { // Check if class name has numeric index at the end if (preg_match('/^(.+)\.(\d+)$/', $className, $matches)) { return (int)$matches[2]; // Return index as integer } return null; // Return null if no index found } /** * Replace the inner content of tags with given class names in the HTML using DOMDocument. * Supports indexed class names (e.g., "eb-feature-list-title.0", "eb-feature-list-title.1"). * * Note: While CSS selectors would be more readable, PHP's DOMDocument doesn't natively support * CSS selectors. We use XPath which is the standard way to query DOM elements in PHP. * For CSS selector support, you would need a third-party library like symfony/css-selector * or QueryPath, but we keep this implementation dependency-free. * * @param string $html The HTML string. * @param array $contents Array of ['attribute' => className, 'content' => newContent] * @return string The updated HTML. */ public static function replaceContentByClassNameDom($html, $contents) { $dom = new \DOMDocument(); // Suppress errors due to HTML5 tags or fragments $html = self::escapeInvalidEntities($html); @$dom->loadHTML('' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); $xpath = new \DOMXPath($dom); foreach ($contents as $item) { $className = $item['attribute']; $newContent = self::escapeInvalidEntities($item['content']); // $newContent = $item['content']; // Extract base class name and index $baseClassName = self::extractBaseClassName($className); $targetIndex = self::extractClassIndex($className); // Find elements by base class name using XPath // XPath equivalent to CSS selector: .baseClassName $nodes = $xpath->query("//*[contains(concat(' ', normalize-space(@class), ' '), ' $baseClassName ')]"); if ($targetIndex !== null) { // If indexed, only replace the element at the specific index if (isset($nodes[$targetIndex])) { $nodes[$targetIndex]->nodeValue = $newContent; } } else { // If not indexed, replace all elements with the class foreach ($nodes as $node) { $node->nodeValue = $newContent; } } } // Remove the XML encoding declaration $result = $dom->saveHTML(); $result = preg_replace('/^<\?xml.*?\?>/', '', $result); return $result; } /** * Replace the inner content of tags with given class names in the HTML using regex. * Supports indexed class names (e.g., "eb-feature-list-title.0", "eb-feature-list-title.1"). * * @param string $html The HTML string. * @param array $contents Array of ['attribute' => className, 'content' => newContent] * @return string The updated HTML. */ public static function replaceContentByClassNameRegex($html, $contents) { foreach ($contents as $item) { $className = $item['attribute']; $newContent = $item['content']; // Extract base class name and index $baseClassName = self::extractBaseClassName($className); $targetIndex = self::extractClassIndex($className); if ($targetIndex !== null) { // Handle indexed replacement $html = self::replaceContentByClassNameRegexIndexed($html, $baseClassName, $newContent, $targetIndex); } else { // Handle non-indexed replacement (original behavior) $quotedClassName = preg_quote($className, '/'); $pattern = '/(<([a-z0-9]+)[^>]*class="[^"]*\b' . $quotedClassName . '\b[^"]*"[^>]*>)(.*?)(<\/\2>)/is'; $replacement = '$1' . $newContent . '$4'; $html = preg_replace($pattern, $replacement, $html); } } return $html; } /** * Replace content for a specific indexed occurrence of a class name using regex. * * @param string $html The HTML string. * @param string $baseClassName The base class name (without index). * @param string $newContent The new content to replace. * @param int $targetIndex The zero-based index of the element to replace. * @return string The updated HTML. */ public static function replaceContentByClassNameRegexIndexed($html, $baseClassName, $newContent, $targetIndex) { $quotedClassName = preg_quote($baseClassName, '/'); /* Regex explanation: - (<([a-z0-9]+)[^>]*class="[^"]*\b$baseClassName\b[^"]*"[^>]*>) - (<([a-z0-9]+)[^>]* ... >) : Captures the opening tag with any attributes - ([a-z0-9]+) : Captures the tag name (e.g., p, div, span) - class="[^"]*\b$baseClassName\b[^"]*" : Ensures the class attribute contains the exact base class name (word boundary) - (.*?) : Captures everything inside the tag (non-greedy) - (<\/\2>) : Matches the corresponding closing tag (\2 is the tag name from earlier) Flags: - i : case-insensitive (for tag names) - s : dot matches newlines */ $pattern = '/(<([a-z0-9]+)[^>]*class="[^"]*\b' . $quotedClassName . '\b[^"]*"[^>]*>)(.*?)(<\/\2>)/is'; $currentIndex = 0; $result = preg_replace_callback($pattern, function($matches) use ($newContent, $targetIndex, &$currentIndex) { if ($currentIndex == $targetIndex) { $currentIndex++; return $matches[1] . $newContent . $matches[4]; } $currentIndex++; return $matches[0]; // Return original match unchanged }, $html); return $result; } /** * Clean block name by removing namespace/plugin prefix * * @param string $block_name The full block name * * @return string Cleaned block name without prefix */ public static function cleanBlockName( $block_name ) { // Remove namespace/plugin prefix (everything before the last slash) $parts = explode( '/', $block_name ); return end( $parts ); } /** * Escape invalid entities in HTML to prevent DOMDocument warnings. * * @param string $html The HTML string to escape. * @return string The escaped HTML string. */ public static function escapeInvalidEntities($html) { // Replace & not followed by one of: #, a-z, A-Z, or 0-9, and then a semicolon return preg_replace('/&(?!(#[0-9]+|[a-zA-Z0-9]+);)/', '&', $html); } /** * Remove invalid blocks from array * * @param array $blocks Array of blocks to clean * @return array Cleaned array with only valid blocks */ public static function cleanInvalidBlocks(array $blocks) { $cleanedBlocks = []; foreach ($blocks as $block) { // Skip if not array if (!is_array($block)) { continue; } // Skip if blockName is null or empty if (empty($block['blockName'])) { continue; } // Skip if missing required properties if (!isset($block['attrs']) || !isset($block['innerBlocks']) || !isset($block['innerHTML']) || !isset($block['innerContent'])) { continue; } // Clean nested blocks recursively if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) { $block['innerBlocks'] = self::cleanInvalidBlocks($block['innerBlocks']); } $cleanedBlocks[] = $block; } return $cleanedBlocks; } }