<?php
namespace Templately\Core\Importer\Utils;
/**
* AI Content Helper Trait
*
* Handles AI content processing functionality including file validation,
* template flattening, element updating, and content merging.
*
* This trait can be used by classes that need AI content processing capabilities
* without having to pass many variables as parameters.
*/
trait AIContentHelper {
public $htmlSources = [
'testimonial',
'feature-list',
'notice',
'pricing-table',
'typing-text',
'interactive-promo',
'call-to-action'
];
/**
* Check if an AI file exists and is not skipped
*
* @param string $ai_file_path Path to the AI file
* @return bool True if AI file exists and is not skipped, false otherwise
*/
protected function hasAiFile($ai_file_path) {
return AIUtils::has_ai_file($ai_file_path) && !AIUtils::is_ai_file_skipped($ai_file_path);
}
/**
* Generate file paths for AI content processing
*
* @param string $old_template_id The template ID
* @return array Array containing paths for original, AI, and previous AI files
*/
public function generateAiFilePaths($old_template_id) {
return AIUtils::generate_ai_file_paths(
$this->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 `</`)
* This normalization is applied to all content values in the AI template's contents arrays.
*
* @param array $flat Reference to the flattened AI content array
* @return void Modifies the array in place
*/
public static function normalizeAiContentEscapeCharacters(&$flat) {
foreach ($flat as &$element) {
if (isset($element['contents']) && is_array($element['contents'])) {
foreach ($element['contents'] as $index => &$content_item) {
if (isset($content_item['content']) && is_string($content_item['content'])) {
// Normalize content: unescape closing tags (remove backslash before </)
$content_item['content'] = str_replace(['<\/', '<\\/'], '</', $content_item['content']);
}
else {
unset($element['contents'][$index]);
}
}
}
}
}
/**
* Recursively flatten a nested array by extracting elements with 'contents' and using their ID 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 flattenById($array, &$flat = []) {
foreach ($array as $key => $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(['<\/', '<\\/'], '</', $item['content'])
: $item['content'];
// Support for dot notation in attribute paths
if (strpos($item['attribute'], '.') !== false) {
$path = explode('.', $item['attribute']);
self::setNestedValue($content['settings'], $path, $content_value);
} else {
$content['settings'][$item['attribute']] = $content_value;
}
}
}
}
}
// Recurse through elements array
if (isset($content['elements']) && is_array($content['elements'])) {
foreach ($content['elements'] as &$element) {
self::updateElementorContentRecursively($flat, $keys, $element);
}
}
// Recurse through all other array elements
foreach ($content as &$value) {
if (is_array($value)) {
self::updateElementorContentRecursively($flat, $keys, $value);
}
}
}
/**
* Merge AI content with the original Gutenberg template using advanced content replacement
*
* @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 mergeAiContentWithOriginalGutenberg($ai_template_json, $original_template_json) {
if (empty($original_template_json['content'])) {
return $original_template_json;
}
// 1. Flatten the AI template by block ID (for blocks with 'contents')
$flat = [];
self::flattenGutenbergById($ai_template_json, $flat);
// 2. Normalize escape characters in AI content early in the pipeline
self::normalizeAiContentEscapeCharacters($flat);
$generated = $flat;
$keys = array_keys($generated);
// 3. Parse the original Gutenberg content
$blocks = parse_blocks($original_template_json['content']);
// 4. Clean invalid blocks from parsed content
$blocks = self::cleanInvalidBlocks($blocks);
// 5. Replace content recursively using advanced replacer logic
$blocks = self::replaceGutenbergContentRecursively($generated, $keys, $blocks);
// 6. Clean invalid blocks before serialization
$blocks = self::cleanInvalidBlocks($blocks);
// 7. Serialize the updated blocks back to content
$original_template_json['content'] = serialize_blocks($blocks);
return $original_template_json;
}
/**
* Replace content recursively in Gutenberg blocks (ported from GutenbergContentReplacer)
*/
public static function replaceGutenbergContentRecursively($generated, array $keys, &$blocks) {
$htmlSources = [
'testimonial',
'feature-list',
'notice',
'pricing-table',
'typing-text',
'interactive-promo',
'call-to-action'
];
foreach ($blocks as &$block) {
if (!empty($block['attrs']['blockId'])) {
$blockId = $block['attrs']['blockId'];
if (in_array($blockId, $keys)) {
$blockData = $generated[$blockId];
$block_name = self::cleanBlockName( $block['blockName'] );
// Store old content BEFORE updating attributes
$oldContentMap = [];
if (!empty($blockData['contents']) && !in_array($block_name, $htmlSources)) {
foreach ($blockData['contents'] as $content) {
$attribute = $content['attribute'];
$oldContent = self::getNestedGutenbergAttribute($block['attrs'], $attribute);
if ($oldContent !== null) {
$oldContentMap[$attribute] = $oldContent;
}
}
}
// Replace content in attributes
if (!empty($blockData['contents']) && !in_array($block_name, $htmlSources)) {
foreach ($blockData['contents'] as $content) {
$attribute = $content['attribute'];
$newContent = $content['content'];
self::setNestedGutenbergAttribute($block['attrs'], $attribute, $newContent);
}
}
// Replace content in innerHTML and innerContent using old content
if (!empty($block['innerHTML']) || !empty($block['innerContent'])) {
self::replaceInGutenbergHtmlContent($block, $blockData, $oldContentMap);
}
if(in_array($block_name, $htmlSources)){
if (!empty($blockData['contents'])) {
if (!empty($block['innerHTML'])) {
$block['innerHTML'] = self::replaceContentByClassName($block['innerHTML'], $blockData['contents']);
}
if (!empty($block['innerContent']) && is_array($block['innerContent'])) {
foreach ($block['innerContent'] as &$content) {
if (is_string($content)) {
$content = self::replaceContentByClassName($content, $blockData['contents']);
}
}
}
}
}
if($block["blockName"] === 'essential-blocks/accordion'){
$block_inner_block_ids = array_map(function($innerBlock) {
return $innerBlock["attrs"]["blockId"] ?? null;
}, $block["innerBlocks"]);
$_generated = array_fill_keys($block_inner_block_ids, ['contents' => $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", '<br>', $content);
$content = str_replace("\r\n", '<br>', $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('<?xml encoding="utf-8" ?>' . $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;
}
}