*/ private static $parity_warnings = []; public $htmlSources = [ 'testimonial', 'feature-list', 'notice', 'pricing-table', 'typing-text', 'interactive-promo', 'call-to-action' ]; /** * Merge AI-generated content into the original template for a given platform. * * Pure, self-contained dispatcher (spec 022 FR-011): the caller supplies the already-read * AI payload and original template; this routes to the platform-specific merger. Session * resolution and file IO are the consumer's responsibility, not this class's. * * @param array $payload The decoded AI-generated template content. * @param mixed $template The decoded original template to merge into. * @param string $platform Either 'elementor' or 'gutenberg'. * @return mixed The merged template, or the untouched $template for an unknown platform. */ public static function merge( array $payload, $template, string $platform ) { if ( 'elementor' === $platform ) { return self::mergeAiContentWithOriginal( $payload, $template ); } if ( 'gutenberg' === $platform ) { return self::mergeAiContentWithOriginalGutenberg( $payload, $template ); } return $template; } /** * 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); } } /** * 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) { // Some callers pass the AI template as a JSON-encoded string; decode it first. if (is_string($ai_template_json)) { $ai_template_json = json_decode($ai_template_json, true); } // 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(['<\/', '<\\/'], ' source:"text" on // `.eb-notice-title`; feature-list -> source:"query" over the `li`s), so the // values are PARSED BACK OUT OF THE MARKUP and never stored in the block // comment. Rewriting the markup is therefore the complete fix for them. // // The attribute pass below is a safety net for the mixed case — a block that // ALSO keeps a comment-stored copy of the same string, which would then go // stale. Read the old text out of the markup FIRST: after the replacement it // is gone, and it is the only key that can identify such an attribute (there // is no class-name -> attribute map, and inventing one would rot on every EB // markup change). $oldByClass = self::extractContentByClassName( is_string($block['innerHTML'] ?? null) ? $block['innerHTML'] : '', $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']); } } unset($content); } self::syncAttrsFromClassReplacements($block, $blockData['contents'], $oldByClass); } } 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 { // The live path: EB syncs each accordion-item's `accordionLists` down from // the parent as a SINGLE-entry array (accordion-item/src/edit.js), so the // count > 1 branch above never fires for current EB markup. // // `accordion-item/src/save.js` renders `foundItem?.title` straight out of // this attribute, so writing the updated entry here WITHOUT rewriting the // item's own markup guarantees a save()/markup mismatch: the editor shows // "Attempt recovery" on every FAQ row and the front end keeps serving the // pre-AI titles. Move both sides together. $attrAccordionLists = $block["attrs"]["accordionLists"]; $ids = array_column($attrAccordionLists, 'id'); foreach ($block["innerBlocks"] as $key => $accordion) { $itemLists = $accordion["attrs"]["accordionLists"] ?? []; if (!is_array($itemLists)) { continue; } foreach($itemLists as $accordionKey => $accordionList){ if (!is_array($accordionList) || !isset($accordionList['id'])) { continue; } // search $block["attrs"]["accordionLists"] by $accordionList["id"] and replace $accordionList with searched one $foundIndex = array_search($accordionList["id"], $ids); if ($foundIndex === false) { continue; } $updatedEntry = $attrAccordionLists[$foundIndex]; $pairs = self::diffStringFields($accordionList, $updatedEntry); $block["innerBlocks"][$key]["attrs"]["accordionLists"][$accordionKey] = $updatedEntry; if (!empty($pairs)) { self::applyReplacementsToBlockMarkup( $block["innerBlocks"][$key], self::buildReplacementRecords($pairs) ); } } } } } } // Process nested blocks recursively if (!empty($block['innerBlocks'])) { $block['innerBlocks'] = self::replaceGutenbergContentRecursively($generated, $keys, $block['innerBlocks']); } } } return $blocks; } /** * Parity warnings from the most recent Gutenberg merge. * * Empty means every replacement landed on both the attributes and the markup. A non-empty * list names the blocks where one side could not be updated — those are exactly the blocks * that will open with a recovery banner, so consumers (the FSI Finalizer log, the * post-import rebuild queue) use it to decide what to report and what to revisit. * * @return array */ public static function collect_parity_warnings(): array { return self::$parity_warnings; } /** * Record one attribute/markup parity failure. Never throws — a merge that cannot keep the * two sides in step must still produce content. */ private static function addParityWarning($blockName, $blockId, $attribute, $reason) { self::$parity_warnings[] = [ 'blockName' => (string) $blockName, 'blockId' => (string) $blockId, 'attribute' => (string) $attribute, 'reason' => (string) $reason, ]; } /** * String fields that differ between two versions of the same structured entry. * * Deliberately NOT limited to `title`: an accordion entry also carries * `titlePrefixText`/`titleSuffixText`/`imageAlt`, and EB adds fields over time. Comparing * every string field keeps this correct as the shape grows instead of silently covering * one key. * * @param array $old Entry before the update. * @param array $new Entry after the update. * @return array */ private static function diffStringFields($old, $new) { $pairs = []; if (!is_array($old) || !is_array($new)) { return $pairs; } foreach ($new as $field => $value) { if (!is_string($value) || !isset($old[$field]) || !is_string($old[$field])) { continue; } if ($old[$field] === '' || $old[$field] === $value) { continue; } $pairs[] = [ 'attribute' => (string) $field, 'old' => $old[$field], 'new' => $value, ]; } return $pairs; } /** * Shape old/new pairs into the replacement records `replaceGutenbergContentInHtml()` expects. * * Longest-first, for the same reason `replaceInGutenbergHtmlContent()` sorts: a short string * that is a prefix of a longer one would otherwise consume it. * * @param array $pairs * @return array */ private static function buildReplacementRecords($pairs) { $replacements = []; foreach ($pairs as $pair) { $replacements[] = [ 'originalFormat' => $pair['old'], 'decodedFormat' => json_decode('"' . $pair['old'] . '"'), 'normalizedFormat' => self::normalizeGutenbergUnicodeContent($pair['old']), 'newContent' => $pair['new'], 'attribute' => $pair['attribute'], ]; } usort($replacements, function ($a, $b) { return strlen($b['originalFormat']) - strlen($a['originalFormat']); }); return $replacements; } /** * Apply replacement records to a block's own markup (`innerHTML` + `innerContent`). * * Records a parity warning for any replacement whose old text is still present afterwards — * that block's save() output will not match what is stored. * * @param array $block Block, by reference. * @param array $replacements Records from {@see self::buildReplacementRecords()}. */ private static function applyReplacementsToBlockMarkup(&$block, $replacements) { if (empty($replacements)) { return; } $before = self::ownMarkup($block); if (!empty($block['innerHTML']) && is_string($block['innerHTML'])) { $block['innerHTML'] = self::replaceGutenbergContentInHtml($block['innerHTML'], $replacements); } if (!empty($block['innerContent']) && is_array($block['innerContent'])) { foreach ($block['innerContent'] as $index => $chunk) { if (is_string($chunk)) { $block['innerContent'][$index] = self::replaceGutenbergContentInHtml($chunk, $replacements); } } } $after = self::ownMarkup($block); $hasChildren = !empty($block['innerBlocks']); foreach ($replacements as $replacement) { $old = $replacement['originalFormat']; $new = $replacement['newContent']; if ($old === '' || $new === '') { continue; } $wasHere = strpos($before, $old) !== false; if ($wasHere) { // The copy IS in this block's markup. If it survived the replacement the block is // now desynced from its own attributes — this is the "Attempt recovery" case. if (strpos($after, $old) !== false) { self::addParityWarning($block['blockName'] ?? '', $block['attrs']['blockId'] ?? '', $replacement['attribute'], 'markup_not_updated'); } continue; } // The copy was not in this block's own markup. `parse_blocks` gives each block only // its OWN chunks, so for a container (an accordion parent, a wrapper) the text // legitimately lives in a child block that is walked separately — silence there. // With no children there is nowhere else for it to be, so the replacement had no // target and the two sides cannot agree. if (!$hasChildren && strpos($after, $new) === false && trim($after) !== '') { self::addParityWarning($block['blockName'] ?? '', $block['attrs']['blockId'] ?? '', $replacement['attribute'], 'markup_not_updated'); } } } /** * A block's OWN markup — `innerHTML` plus its string `innerContent` chunks, never its * children's (`parse_blocks` keeps those in `innerBlocks`, walked separately). * * @param array $block * @return string */ private static function ownMarkup($block) { $markup = is_string($block['innerHTML'] ?? null) ? $block['innerHTML'] : ''; if (!empty($block['innerContent']) && is_array($block['innerContent'])) { foreach ($block['innerContent'] as $chunk) { if (is_string($chunk)) { $markup .= $chunk; } } } return $markup; } /** * Current text of each class-addressed target, keyed by the class name used to address it. * * Must be called BEFORE the class-based replacement runs — afterwards the old text is gone, * and it is the only thing that can identify the attribute holding the same copy. * * @param string $html Block markup. * @param array $contents `[['attribute' => className, 'content' => newContent], …]` * @return array className => old text */ public static function extractContentByClassName($html, $contents) { $found = []; if (!is_string($html) || $html === '' || empty($contents)) { return $found; } if (!class_exists('DOMDocument') || !class_exists('DOMXPath')) { return $found; } $dom = new \DOMDocument(); @$dom->loadHTML('' . self::escapeInvalidEntities($html), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); $xpath = new \DOMXPath($dom); foreach ($contents as $item) { if (!isset($item['attribute'])) { continue; } $className = $item['attribute']; $baseClassName = self::extractBaseClassName($className); $targetIndex = self::extractClassIndex($className); $nodes = $xpath->query("//*[contains(concat(' ', normalize-space(@class), ' '), ' $baseClassName ')]"); if (!$nodes || $nodes->length === 0) { continue; } $node = $targetIndex !== null ? ($nodes[$targetIndex] ?? null) : $nodes[0]; if ($node !== null) { $found[$className] = $node->nodeValue; } } return $found; } /** * Write class-replaced copy back into the block's attributes, matched BY VALUE. * * For the `$htmlSources` blocks the copy normally lives ONLY in the markup (their text * attributes are `source`d from selectors), so this usually finds nothing — that is the * expected, healthy case and is deliberately NOT warned about. It exists for the mixed * block that also keeps a comment-stored copy of the same string, which would otherwise go * stale and make save() disagree with the markup. * * Matching on the exact old string needs no class-name -> attribute table and cannot rot * when EB renames a class; a table would have to be maintained per block per release, and a * stale entry fails silently. * * Exact FULL-string equality only (never substring), and only on string scalars, so an * unrelated attribute that merely contains the text is left alone. * * @param array $block Block, by reference. * @param array $contents `[['attribute' => className, 'content' => newContent], …]` * @param array $oldByClass className => old text, from {@see self::extractContentByClassName()} */ private static function syncAttrsFromClassReplacements(&$block, $contents, $oldByClass) { if (empty($contents) || empty($oldByClass) || empty($block['attrs']) || !is_array($block['attrs'])) { return; } $map = []; foreach ($contents as $item) { if (!isset($item['attribute'], $item['content']) || !is_string($item['content'])) { continue; } $className = $item['attribute']; if (!isset($oldByClass[$className])) { continue; } $old = $oldByClass[$className]; if (!is_string($old)) { continue; } $old = trim($old); if ($old === '' || $old === $item['content']) { continue; } $map[$old] = ['new' => $item['content'], 'attribute' => $className]; } if (empty($map)) { return; } $written = []; self::replaceStringsInAttrs($block['attrs'], $map, $written); } /** * Recursively replace exact string values inside an attribute tree. * * @param mixed $attrs Attribute value/tree, by reference. * @param array $map oldString => ['new' => newString, 'attribute' => label] * @param array $written oldString => hit count, by reference. */ private static function replaceStringsInAttrs(&$attrs, $map, &$written) { if (is_string($attrs)) { $key = trim($attrs); if (isset($map[$key])) { $attrs = $map[$key]['new']; $written[$key] = ($written[$key] ?? 0) + 1; } return; } if (is_array($attrs)) { foreach ($attrs as $key => $value) { self::replaceStringsInAttrs($attrs[$key], $map, $written); } } } /** * 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($replacements)) { return; } // One call, so the leftover check below sees the SAME markup the block ends up with — // the attributes have already been rewritten by the caller, so anything this fails to // replace is a desync that will surface as "Attempt recovery" in the editor. self::applyReplacementsToBlockMarkup($block, $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; } /** * 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; } }