PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.5.1
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.5.1
3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 3.1.10 All 111 releases
templately / includes / Core / Importer / Utils / AIContentHelper.php

AIContentHelper.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! 3.5.1, at includes/Core/Importer/Utils/AIContentHelper.php

909 lines 30.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Templately\Core\Importer\Utils;
4
5 /**
6 * AI Content Helper Trait
7 *
8 * Handles AI content processing functionality including file validation,
9 * template flattening, element updating, and content merging.
10 *
11 * This trait can be used by classes that need AI content processing capabilities
12 * without having to pass many variables as parameters.
13 */
14 trait AIContentHelper {
15 public $htmlSources = [
16 'testimonial',
17 'feature-list',
18 'notice',
19 'pricing-table',
20 'typing-text',
21 'interactive-promo',
22 'call-to-action'
23 ];
24
25 /**
26 * Check if an AI file exists and is not skipped
27 *
28 * @param string $ai_file_path Path to the AI file
29 * @return bool True if AI file exists and is not skipped, false otherwise
30 */
31 protected function hasAiFile($ai_file_path) {
32 return AIUtils::has_ai_file($ai_file_path) && !AIUtils::is_ai_file_skipped($ai_file_path);
33 }
34
35 /**
36 * Generate file paths for AI content processing
37 *
38 * @param string $old_template_id The template ID
39 * @return array Array containing paths for original, AI, and previous AI files
40 */
41 public function generateAiFilePaths($old_template_id) {
42 return AIUtils::generate_ai_file_paths(
43 $this->session_id,
44 $this->type,
45 $this->sub_type,
46 $old_template_id,
47 $this->dir_path
48 );
49 }
50
51 /**
52 * Check if content should be processed as AI content
53 *
54 * @param string $old_template_id The template ID to check
55 * @return bool True if this is AI content, false otherwise
56 */
57 public function isAiContent($old_template_id) {
58 return AIUtils::should_process_as_ai_content(
59 $this->session_id,
60 $this->type,
61 $this->sub_type,
62 $old_template_id,
63 $this->ai_page_ids,
64 $this->dir_path
65 );
66 }
67
68 /**
69 * Check if an AI file exists and is marked as skipped
70 *
71 * @param string $old_template_id The template ID to check
72 * @return bool True if AI file exists and is skipped, false otherwise
73 */
74 public function isAiFileSkipped($old_template_id) {
75 // Generate file paths
76 $paths = $this->generateAiFilePaths($old_template_id);
77 return AIUtils::is_ai_file_skipped($paths['ai_file_path']);
78 }
79
80 /**
81 * Normalize escape characters in AI content
82 *
83 * Removes backslashes before closing tags (converts `<\/` and `<\\/` to `</`)
84 * This normalization is applied to all content values in the AI template's contents arrays.
85 *
86 * @param array $flat Reference to the flattened AI content array
87 * @return void Modifies the array in place
88 */
89 public static function normalizeAiContentEscapeCharacters(&$flat) {
90 foreach ($flat as &$element) {
91 if (isset($element['contents']) && is_array($element['contents'])) {
92 foreach ($element['contents'] as $index => &$content_item) {
93 if (isset($content_item['content']) && is_string($content_item['content'])) {
94 // Normalize content: unescape closing tags (remove backslash before </)
95 $content_item['content'] = str_replace(['<\/', '<\\/'], '</', $content_item['content']);
96 }
97 else {
98 unset($element['contents'][$index]);
99 }
100 }
101 }
102 }
103 }
104
105 /**
106 * Recursively flatten a nested array by extracting elements with 'contents' and using their ID as key
107 *
108 * @param array $array The array to flatten
109 * @param array $flat Reference to the flattened array
110 * @return array The flattened array
111 */
112 public static function flattenById($array, &$flat = []) {
113 foreach ($array as $key => $value) {
114 if (is_array($value)) {
115 // If this is an element with 'widgetType' and 'contents', use its parent key as ID
116 if (isset($value['widgetType']) && isset($value['contents'])) {
117 $flat[$key] = $value;
118 }
119 // Recurse into children
120 self::flattenById($value, $flat);
121 }
122 }
123 return $flat;
124 }
125
126
127
128 /**
129 * Set a value in a nested array using a dot notation path
130 *
131 * @param array $array Reference to the array to modify
132 * @param array $path The path as an array of keys
133 * @param mixed $value The value to set
134 */
135 public static function setNestedValue(&$array, $path, $value) {
136 $key = array_shift($path);
137
138 if (empty($path)) {
139 // We've reached the final key, set the value
140 $array[$key] = $value;
141 } else {
142 // Initialize the nested array if it doesn't exist
143 if (!isset($array[$key]) || !is_array($array[$key])) {
144 $array[$key] = [];
145 }
146
147 // Continue recursively
148 self::setNestedValue($array[$key], $path, $value);
149 }
150 }
151
152 /**
153 * Process AI content by merging it with the original template
154 *
155 * @param string $old_template_id The template ID to process
156 * @return array Array containing the processed template and whether it's AI content
157 */
158 public function processAiContent($old_template_id) {
159 // Generate file paths
160 $paths = $this->generateAiFilePaths($old_template_id);
161 $original_file = $paths['original_file'];
162 $ai_file = $paths['ai_file_path'];
163
164 $file = $original_file;
165 $isAi = false;
166
167 // Check for AI file
168 if ($this->hasAiFile($ai_file)) {
169 $file = $ai_file;
170 $isAi = true;
171 }
172
173 // Read the template JSON
174 $template_json = Utils::read_json_file($file);
175
176 if ($isAi) {
177 // Read original template JSON content for merging
178 $original_template_json = Utils::read_json_file($original_file);
179 $ai_template_json = $template_json;
180
181 if($this->platform === 'elementor'){
182 $template_json = self::mergeAiContentWithOriginal($ai_template_json, $original_template_json);
183 }
184 else if($this->platform === 'gutenberg'){
185 $template_json = self::mergeAiContentWithOriginalGutenberg($ai_template_json, $original_template_json);
186 }
187
188 // Write debug files after merging
189 $this->writeDebugFile($original_file, $template_json, 'ao');
190 }
191
192 return [
193 'template_json' => $template_json,
194 'is_ai' => $isAi,
195 'file_used' => $file
196 ];
197 }
198
199 /**
200 * Merge AI content with the original template
201 *
202 * @param array $ai_template_json The AI template JSON
203 * @param array $original_template_json The original template JSON data
204 * @return array The merged template JSON
205 */
206 public static function mergeAiContentWithOriginal($ai_template_json, $original_template_json) {
207 // 1. Flatten the AI template
208 $flat = self::flattenById($ai_template_json);
209
210 // 2. Normalize escape characters in AI content early in the pipeline
211 self::normalizeAiContentEscapeCharacters($flat);
212
213 $keys = array_keys($flat);
214
215 // 3. Loop through original content only once and update elements directly
216 self::updateElementorContentRecursively($flat, $keys, $original_template_json['content']);
217
218 return $original_template_json;
219 }
220
221 /**
222 * Update Elementor content recursively by looping through original content only once
223 *
224 * @param array $flat The flattened AI content array
225 * @param array $keys Array of element IDs from the flat array
226 * @param array $content Reference to the original content to update
227 */
228 public static function updateElementorContentRecursively($flat, array $keys, &$content) {
229 if (!is_array($content)) {
230 return;
231 }
232
233 // Check if this element has an ID and needs updating
234 if (isset($content['id']) && in_array($content['id'], $keys)) {
235 $element_id = $content['id'];
236 $element = $flat[$element_id];
237
238 if (isset($element['contents'])) {
239 // Update settings based on contents
240 foreach ($element['contents'] as $item) {
241 if (isset($item['attribute'], $item['content'])) {
242 $content_value = is_string($item['content'])
243 ? str_replace(['<\/', '<\\/'], '</', $item['content'])
244 : $item['content'];
245
246 // Support for dot notation in attribute paths
247 if (strpos($item['attribute'], '.') !== false) {
248 $path = explode('.', $item['attribute']);
249 self::setNestedValue($content['settings'], $path, $content_value);
250 } else {
251 $content['settings'][$item['attribute']] = $content_value;
252 }
253 }
254 }
255 }
256 }
257
258 // Recurse through elements array
259 if (isset($content['elements']) && is_array($content['elements'])) {
260 foreach ($content['elements'] as &$element) {
261 self::updateElementorContentRecursively($flat, $keys, $element);
262 }
263 }
264
265 // Recurse through all other array elements
266 foreach ($content as &$value) {
267 if (is_array($value)) {
268 self::updateElementorContentRecursively($flat, $keys, $value);
269 }
270 }
271 }
272
273 /**
274 * Merge AI content with the original Gutenberg template using advanced content replacement
275 *
276 * @param array $ai_template_json The AI template JSON
277 * @param array $original_template_json The original template JSON data
278 * @return array The merged template JSON
279 */
280 public static function mergeAiContentWithOriginalGutenberg($ai_template_json, $original_template_json) {
281 if (empty($original_template_json['content'])) {
282 return $original_template_json;
283 }
284
285 // 1. Flatten the AI template by block ID (for blocks with 'contents')
286 $flat = [];
287 self::flattenGutenbergById($ai_template_json, $flat);
288
289 // 2. Normalize escape characters in AI content early in the pipeline
290 self::normalizeAiContentEscapeCharacters($flat);
291
292 $generated = $flat;
293 $keys = array_keys($generated);
294
295 // 3. Parse the original Gutenberg content
296 $blocks = parse_blocks($original_template_json['content']);
297
298 // 4. Clean invalid blocks from parsed content
299 $blocks = self::cleanInvalidBlocks($blocks);
300
301 // 5. Replace content recursively using advanced replacer logic
302 $blocks = self::replaceGutenbergContentRecursively($generated, $keys, $blocks);
303
304 // 6. Clean invalid blocks before serialization
305 $blocks = self::cleanInvalidBlocks($blocks);
306
307 // 7. Serialize the updated blocks back to content
308 $original_template_json['content'] = serialize_blocks($blocks);
309
310 return $original_template_json;
311 }
312
313 /**
314 * Replace content recursively in Gutenberg blocks (ported from GutenbergContentReplacer)
315 */
316 public static function replaceGutenbergContentRecursively($generated, array $keys, &$blocks) {
317 $htmlSources = [
318 'testimonial',
319 'feature-list',
320 'notice',
321 'pricing-table',
322 'typing-text',
323 'interactive-promo',
324 'call-to-action'
325 ];
326
327 foreach ($blocks as &$block) {
328 if (!empty($block['attrs']['blockId'])) {
329 $blockId = $block['attrs']['blockId'];
330 if (in_array($blockId, $keys)) {
331 $blockData = $generated[$blockId];
332 $block_name = self::cleanBlockName( $block['blockName'] );
333
334 // Store old content BEFORE updating attributes
335 $oldContentMap = [];
336 if (!empty($blockData['contents']) && !in_array($block_name, $htmlSources)) {
337 foreach ($blockData['contents'] as $content) {
338 $attribute = $content['attribute'];
339 $oldContent = self::getNestedGutenbergAttribute($block['attrs'], $attribute);
340 if ($oldContent !== null) {
341 $oldContentMap[$attribute] = $oldContent;
342 }
343 }
344 }
345
346 // Replace content in attributes
347 if (!empty($blockData['contents']) && !in_array($block_name, $htmlSources)) {
348 foreach ($blockData['contents'] as $content) {
349 $attribute = $content['attribute'];
350 $newContent = $content['content'];
351 self::setNestedGutenbergAttribute($block['attrs'], $attribute, $newContent);
352 }
353 }
354
355 // Replace content in innerHTML and innerContent using old content
356 if (!empty($block['innerHTML']) || !empty($block['innerContent'])) {
357 self::replaceInGutenbergHtmlContent($block, $blockData, $oldContentMap);
358 }
359
360 if(in_array($block_name, $htmlSources)){
361 if (!empty($blockData['contents'])) {
362 if (!empty($block['innerHTML'])) {
363 $block['innerHTML'] = self::replaceContentByClassName($block['innerHTML'], $blockData['contents']);
364 }
365 if (!empty($block['innerContent']) && is_array($block['innerContent'])) {
366 foreach ($block['innerContent'] as &$content) {
367 if (is_string($content)) {
368 $content = self::replaceContentByClassName($content, $blockData['contents']);
369 }
370 }
371 }
372 }
373 }
374 if($block["blockName"] === 'essential-blocks/accordion'){
375 $block_inner_block_ids = array_map(function($innerBlock) {
376 return $innerBlock["attrs"]["blockId"] ?? null;
377 }, $block["innerBlocks"]);
378 $_generated = array_fill_keys($block_inner_block_ids, ['contents' => $blockData['contents']]);
379
380 if(isset($block["innerBlocks"][0]["attrs"]["accordionLists"]) && count($block["innerBlocks"][0]["attrs"]["accordionLists"]) > 1){
381 $block["innerBlocks"] = self::replaceGutenbergContentRecursively($_generated, $block_inner_block_ids, $block['innerBlocks']);
382 }
383 else {
384 // $block["attrs"]["accordionLists"][0]["id"]
385 $attrAccordionLists = $block["attrs"]["accordionLists"];
386 foreach ($block["innerBlocks"] as $key => $accordion) {
387 foreach($accordion["attrs"]["accordionLists"] as $accordionKey => $accordionList){
388 // $accordionList["id"]
389 // search $block["attrs"]["accordionLists"] by $accordionList["id"] and replace $accordionList with searched one
390 $ids = array_column($attrAccordionLists, 'id');
391 $foundIndex = array_search($accordionList["id"], $ids);
392 if ($foundIndex !== false) {
393 $block["innerBlocks"][$key]["attrs"]["accordionLists"][$accordionKey] = $attrAccordionLists[$foundIndex];
394 }
395 }
396 }
397 }
398 }
399 }
400
401 // Process nested blocks recursively
402 if (!empty($block['innerBlocks'])) {
403 $block['innerBlocks'] = self::replaceGutenbergContentRecursively($generated, $keys, $block['innerBlocks']);
404 }
405 }
406 }
407 return $blocks;
408 }
409
410 /**
411 * Set nested attribute value using dot notation (ported from GutenbergContentReplacer)
412 */
413 public static function setNestedGutenbergAttribute(&$attrs, $path, $value) {
414 $keys = explode('.', $path);
415 $current = &$attrs;
416 for ($i = 0; $i < count($keys) - 1; $i++) {
417 $key = $keys[$i];
418 if (!isset($current[$key])) {
419 $current[$key] = [];
420 }
421 $current = &$current[$key];
422 }
423 $finalKey = end($keys);
424 $current[$finalKey] = $value;
425 }
426
427 /**
428 * Get nested attribute value using dot notation (ported from GutenbergContentReplacer)
429 */
430 public static function getNestedGutenbergAttribute($attrs, $path) {
431 $keys = explode('.', $path);
432 $current = $attrs;
433 foreach ($keys as $key) {
434 if (!isset($current[$key])) {
435 return null;
436 }
437 $current = $current[$key];
438 }
439 return $current;
440 }
441
442 /**
443 * Replace content in innerHTML and innerContent while preserving HTML structure (ported from GutenbergContentReplacer)
444 */
445 public static function replaceInGutenbergHtmlContent(&$block, $blockData, $oldContentMap) {
446 if (empty($blockData['contents']) || empty($oldContentMap)) return;
447 $replacements = [];
448 foreach ($blockData['contents'] as $content) {
449 $attribute = $content['attribute'];
450 $newContent = $content['content'];
451 if (isset($oldContentMap[$attribute])) {
452 $oldAttributeContent = $oldContentMap[$attribute];
453 $decodedUnicodeContent = json_decode('"' . $oldAttributeContent . '"');
454 $normalizedAttributeContent = self::normalizeGutenbergUnicodeContent($oldAttributeContent);
455 $normalizedNewContent = self::normalizeGutenbergUnicodeContent($newContent);
456 if ($normalizedAttributeContent !== $normalizedNewContent) {
457 $replacements[] = [
458 'originalFormat' => $oldAttributeContent,
459 'decodedFormat' => $decodedUnicodeContent,
460 'normalizedFormat' => $normalizedAttributeContent,
461 'newContent' => $newContent,
462 'attribute' => $attribute
463 ];
464 }
465 }
466 }
467 // sort $replacements by length of 'originalFormat' in descending order
468 usort($replacements, function($a, $b) {
469 return strlen($b['originalFormat']) - strlen($a['originalFormat']);
470 });
471 if (!empty($block['innerHTML']) && !empty($replacements)) {
472 $block['innerHTML'] = self::replaceGutenbergContentInHtml($block['innerHTML'], $replacements);
473 }
474 if (!empty($block['innerContent']) && is_array($block['innerContent'])) {
475 foreach ($block['innerContent'] as &$content) {
476 if (is_string($content)) {
477 $content = self::replaceGutenbergContentInHtml($content, $replacements);
478 }
479 }
480 }
481 }
482
483 /**
484 * Replace content in HTML while preserving structure and handling Unicode (ported from GutenbergContentReplacer)
485 *
486 * Uses targeted replacement that avoids replacing text inside HTML attributes (href, src, data-*, etc.)
487 * to prevent breaking URLs and other attribute values.
488 */
489 public static function replaceGutenbergContentInHtml($html, $replacements) {
490 foreach ($replacements as $replacement) {
491 $originalFormat = $replacement['originalFormat'];
492 $decodedFormat = $replacement['decodedFormat'];
493 $normalizedFormat = $replacement['normalizedFormat'];
494 $newContent = $replacement['newContent'];
495 if (empty($originalFormat)) continue;
496 $htmlNewContent = $newContent;
497
498 // Use targeted replacement that avoids HTML attributes
499 $html = self::replaceTextOutsideAttributes($html, $originalFormat, $htmlNewContent);
500
501 if ($decodedFormat !== null && $decodedFormat !== $originalFormat) {
502 $html = self::replaceTextOutsideAttributes($html, $decodedFormat, $htmlNewContent);
503 }
504 if ($normalizedFormat !== $decodedFormat && $normalizedFormat !== $originalFormat) {
505 $html = self::replaceTextOutsideAttributes($html, $normalizedFormat, $htmlNewContent);
506 }
507 }
508 return $html;
509 }
510
511 /**
512 * Replace text in HTML only outside of HTML tags and attributes
513 *
514 * This function replaces occurrences of $oldText with $newText, but only when the text
515 * appears outside of HTML tags and attributes. This prevents unintended replacements
516 * inside URLs, src attributes, href attributes, and other HTML attributes.
517 *
518 * @param string $html The HTML content to process
519 * @param string $oldText The text to find and replace
520 * @param string $newText The replacement text
521 * @return string The HTML with replacements applied only outside of tags/attributes
522 */
523 private static function replaceTextOutsideAttributes($html, $oldText, $newText) {
524 if (empty($oldText) || $oldText === $newText) {
525 return $html;
526 }
527
528 $result = '';
529 $lastPos = 0;
530
531 // Find all occurrences of the text
532 while (($pos = strpos($html, $oldText, $lastPos)) !== false) {
533 // Check if this occurrence is inside an HTML tag or attribute
534 if (!self::isPositionInsideTag($html, $pos)) {
535 // Not inside a tag, safe to replace
536 $result .= substr($html, $lastPos, $pos - $lastPos) . $newText;
537 $lastPos = $pos + strlen($oldText);
538 } else {
539 // Inside a tag, skip this occurrence
540 $result .= substr($html, $lastPos, $pos - $lastPos + strlen($oldText));
541 $lastPos = $pos + strlen($oldText);
542 }
543 }
544
545 // Append remaining HTML
546 $result .= substr($html, $lastPos);
547 return $result;
548 }
549
550 /**
551 * Check if a position in HTML is inside an HTML attribute value
552 *
553 * This checks if the position is between quotes within an HTML tag.
554 * Returns true only if the position is inside an attribute value (between quotes),
555 * not just anywhere inside a tag.
556 *
557 * @param string $html The HTML content
558 * @param int $position The position to check
559 * @return bool True if the position is inside an attribute value, false otherwise
560 */
561 private static function isPositionInsideTag($html, $position) {
562 // Get the text before the position
563 $beforeText = substr($html, 0, $position);
564
565 // Find the last < and > before the position
566 $lastOpenTag = strrpos($beforeText, '<');
567 $lastCloseTag = strrpos($beforeText, '>');
568
569 // If there's no unclosed tag, we're not inside a tag
570 if ($lastOpenTag === false || ($lastCloseTag !== false && $lastOpenTag < $lastCloseTag)) {
571 return false;
572 }
573
574 // We're inside a tag. Now check if we're inside an attribute value (between quotes)
575 // Get the tag content from the last < to the position
576 $tagContent = substr($html, $lastOpenTag, $position - $lastOpenTag);
577
578 // Track whether we're inside double or single quotes by iterating through the tag content
579 $inDoubleQuotes = false;
580 $inSingleQuotes = false;
581
582 for ($i = 0; $i < strlen($tagContent); $i++) {
583 $char = $tagContent[$i];
584
585 // Toggle quote state when we encounter a quote
586 if ($char === '"' && !$inSingleQuotes) {
587 $inDoubleQuotes = !$inDoubleQuotes;
588 } elseif ($char === "'" && !$inDoubleQuotes) {
589 $inSingleQuotes = !$inSingleQuotes;
590 }
591 }
592
593 // We're inside an attribute value if we're inside either type of quotes
594 return $inDoubleQuotes || $inSingleQuotes;
595 }
596
597 /**
598 * Normalize Unicode content to handle different apostrophe types and other Unicode variations (ported from GutenbergContentReplacer)
599 */
600 public static function normalizeGutenbergUnicodeContent($content) {
601 $decoded = json_decode('"' . $content . '"');
602 if ($decoded !== null) {
603 $content = $decoded;
604 }
605 $unicodeReplacements = [
606 '\u2019' => "'",
607 '\u2018' => "'",
608 '\u201C' => '"',
609 '\u201D' => '"',
610 '\u2013' => '-',
611 '\u2014' => '-',
612 '\u2026' => '...',
613 "\u{2019}" => "'",
614 "\u{2018}" => "'",
615 "\u{201C}" => '"',
616 "\u{201D}" => '"',
617 "\u{2013}" => '-',
618 "\u{2014}" => '-',
619 "\u{2026}" => '...'
620 ];
621 return str_replace(array_keys($unicodeReplacements), array_values($unicodeReplacements), $content);
622 }
623
624 /**
625 * Convert content to HTML format (handle line breaks and inline tags) (ported from GutenbergContentReplacer)
626 */
627 public static function convertGutenbergToHtmlFormat($content) {
628 $content = str_replace("\n", '<br>', $content);
629 $content = str_replace("\r\n", '<br>', $content);
630 return $content;
631 }
632
633 /**
634 * Recursively flatten a nested Gutenberg AI array by extracting blocks with 'contents' and using their blockId as key
635 *
636 * @param array $array The array to flatten
637 * @param array $flat Reference to the flattened array
638 * @return array The flattened array
639 */
640 public static function flattenGutenbergById($array, &$flat = []) {
641 foreach ($array as $key => $value) {
642 if (is_array($value)) {
643 // If this is a block with 'blockName' and 'contents', use its parent key as ID
644 if (isset($value['blockName']) && isset($value['contents'])) {
645 $flat[$key] = $value;
646 }
647 // Recurse into children
648 self::flattenGutenbergById($value, $flat);
649 }
650 }
651 return $flat;
652 }
653
654 /**
655 * Write debug file only if TEMPLATELY_DEV_VIEWS is defined and true
656 * Handles .ai.json to .ao.json or .og.json as appropriate
657 */
658 protected function writeDebugFile($ai_file, $data, $type = 'ao') {
659 if ((defined('TEMPLATELY_DEV') && TEMPLATELY_DEV) || (defined('IMPORT_DEBUG') && IMPORT_DEBUG)) {
660 $replace = ".{$type}.json";
661 $debug_file = str_replace('.json', $replace, $ai_file);
662 file_put_contents($debug_file, json_encode($data));
663 }
664 }
665
666 /**
667 * Replace the inner content of tags with given class names in the HTML.
668 * Supports indexed class names (e.g., "eb-feature-list-title.0", "eb-feature-list-title.1").
669 * Falls back to regex if DOMDocument does not find the class.
670 *
671 * @param string $html The HTML string.
672 * @param array $contents Array of ['attribute' => className, 'content' => newContent]
673 * @return string The updated HTML.
674 */
675 public static function replaceContentByClassName($html, $contents) {
676 $classExists = false;
677 foreach ($contents as $item) {
678 $className = $item['attribute'];
679 // Extract base class name (remove index if present)
680 $baseClassName = self::extractBaseClassName($className);
681 if (preg_match('/class=["\'][^"\']*\b' . preg_quote($baseClassName, '/') . '\b[^"\']*["\']/', $html)) {
682 $classExists = true;
683 break;
684 }
685 }
686 if (!$classExists) {
687 return $html; // No relevant class found, skip both methods
688 }
689
690 if (class_exists('DOMDocument') && class_exists('DOMXPath')) {
691 return self::replaceContentByClassNameDom($html, $contents);
692 } else {
693 return self::replaceContentByClassNameRegex($html, $contents);
694 }
695 }
696
697 /**
698 * Extract base class name from indexed class name.
699 *
700 * @param string $className The class name (e.g., "eb-feature-list-title.0")
701 * @return string The base class name (e.g., "eb-feature-list-title")
702 */
703 public static function extractBaseClassName($className) {
704 // Check if class name has numeric index at the end
705 if (preg_match('/^(.+)\.(\d+)$/', $className, $matches)) {
706 return $matches[1]; // Return base class name
707 }
708 return $className; // Return original if no index found
709 }
710
711 /**
712 * Extract index from indexed class name.
713 *
714 * @param string $className The class name (e.g., "eb-feature-list-title.0")
715 * @return int|null The index (e.g., 0) or null if no index found
716 */
717 public static function extractClassIndex($className) {
718 // Check if class name has numeric index at the end
719 if (preg_match('/^(.+)\.(\d+)$/', $className, $matches)) {
720 return (int)$matches[2]; // Return index as integer
721 }
722 return null; // Return null if no index found
723 }
724
725 /**
726 * Replace the inner content of tags with given class names in the HTML using DOMDocument.
727 * Supports indexed class names (e.g., "eb-feature-list-title.0", "eb-feature-list-title.1").
728 *
729 * Note: While CSS selectors would be more readable, PHP's DOMDocument doesn't natively support
730 * CSS selectors. We use XPath which is the standard way to query DOM elements in PHP.
731 * For CSS selector support, you would need a third-party library like symfony/css-selector
732 * or QueryPath, but we keep this implementation dependency-free.
733 *
734 * @param string $html The HTML string.
735 * @param array $contents Array of ['attribute' => className, 'content' => newContent]
736 * @return string The updated HTML.
737 */
738 public static function replaceContentByClassNameDom($html, $contents) {
739 $dom = new \DOMDocument();
740 // Suppress errors due to HTML5 tags or fragments
741 $html = self::escapeInvalidEntities($html);
742 @$dom->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
743
744 $xpath = new \DOMXPath($dom);
745 foreach ($contents as $item) {
746 $className = $item['attribute'];
747 $newContent = self::escapeInvalidEntities($item['content']);
748 // $newContent = $item['content'];
749
750 // Extract base class name and index
751 $baseClassName = self::extractBaseClassName($className);
752 $targetIndex = self::extractClassIndex($className);
753
754 // Find elements by base class name using XPath
755 // XPath equivalent to CSS selector: .baseClassName
756 $nodes = $xpath->query("//*[contains(concat(' ', normalize-space(@class), ' '), ' $baseClassName ')]");
757
758 if ($targetIndex !== null) {
759 // If indexed, only replace the element at the specific index
760 if (isset($nodes[$targetIndex])) {
761 $nodes[$targetIndex]->nodeValue = $newContent;
762 }
763 } else {
764 // If not indexed, replace all elements with the class
765 foreach ($nodes as $node) {
766 $node->nodeValue = $newContent;
767 }
768 }
769 }
770 // Remove the XML encoding declaration
771 $result = $dom->saveHTML();
772 $result = preg_replace('/^<\?xml.*?\?>/', '', $result);
773 return $result;
774 }
775
776 /**
777 * Replace the inner content of tags with given class names in the HTML using regex.
778 * Supports indexed class names (e.g., "eb-feature-list-title.0", "eb-feature-list-title.1").
779 *
780 * @param string $html The HTML string.
781 * @param array $contents Array of ['attribute' => className, 'content' => newContent]
782 * @return string The updated HTML.
783 */
784 public static function replaceContentByClassNameRegex($html, $contents) {
785 foreach ($contents as $item) {
786 $className = $item['attribute'];
787 $newContent = $item['content'];
788
789 // Extract base class name and index
790 $baseClassName = self::extractBaseClassName($className);
791 $targetIndex = self::extractClassIndex($className);
792
793 if ($targetIndex !== null) {
794 // Handle indexed replacement
795 $html = self::replaceContentByClassNameRegexIndexed($html, $baseClassName, $newContent, $targetIndex);
796 } else {
797 // Handle non-indexed replacement (original behavior)
798 $quotedClassName = preg_quote($className, '/');
799 $pattern = '/(<([a-z0-9]+)[^>]*class="[^"]*\b' . $quotedClassName . '\b[^"]*"[^>]*>)(.*?)(<\/\2>)/is';
800 $replacement = '$1' . $newContent . '$4';
801 $html = preg_replace($pattern, $replacement, $html);
802 }
803 }
804 return $html;
805 }
806
807 /**
808 * Replace content for a specific indexed occurrence of a class name using regex.
809 *
810 * @param string $html The HTML string.
811 * @param string $baseClassName The base class name (without index).
812 * @param string $newContent The new content to replace.
813 * @param int $targetIndex The zero-based index of the element to replace.
814 * @return string The updated HTML.
815 */
816 public static function replaceContentByClassNameRegexIndexed($html, $baseClassName, $newContent, $targetIndex) {
817 $quotedClassName = preg_quote($baseClassName, '/');
818 /*
819 Regex explanation:
820 - (<([a-z0-9]+)[^>]*class="[^"]*\b$baseClassName\b[^"]*"[^>]*>)
821 - (<([a-z0-9]+)[^>]* ... >) : Captures the opening tag with any attributes
822 - ([a-z0-9]+) : Captures the tag name (e.g., p, div, span)
823 - class="[^"]*\b$baseClassName\b[^"]*" : Ensures the class attribute contains the exact base class name (word boundary)
824 - (.*?) : Captures everything inside the tag (non-greedy)
825 - (<\/\2>) : Matches the corresponding closing tag (\2 is the tag name from earlier)
826 Flags:
827 - i : case-insensitive (for tag names)
828 - s : dot matches newlines
829 */
830 $pattern = '/(<([a-z0-9]+)[^>]*class="[^"]*\b' . $quotedClassName . '\b[^"]*"[^>]*>)(.*?)(<\/\2>)/is';
831
832 $currentIndex = 0;
833 $result = preg_replace_callback($pattern, function($matches) use ($newContent, $targetIndex, &$currentIndex) {
834 if ($currentIndex == $targetIndex) {
835 $currentIndex++;
836 return $matches[1] . $newContent . $matches[4];
837 }
838 $currentIndex++;
839 return $matches[0]; // Return original match unchanged
840 }, $html);
841
842 return $result;
843 }
844
845 /**
846 * Clean block name by removing namespace/plugin prefix
847 *
848 * @param string $block_name The full block name
849 *
850 * @return string Cleaned block name without prefix
851 */
852 public static function cleanBlockName( $block_name ) {
853 // Remove namespace/plugin prefix (everything before the last slash)
854 $parts = explode( '/', $block_name );
855
856 return end( $parts );
857 }
858
859 /**
860 * Escape invalid entities in HTML to prevent DOMDocument warnings.
861 *
862 * @param string $html The HTML string to escape.
863 * @return string The escaped HTML string.
864 */
865 public static function escapeInvalidEntities($html) {
866 // Replace & not followed by one of: #, a-z, A-Z, or 0-9, and then a semicolon
867 return preg_replace('/&(?!(#[0-9]+|[a-zA-Z0-9]+);)/', '&amp;', $html);
868 }
869
870 /**
871 * Remove invalid blocks from array
872 *
873 * @param array $blocks Array of blocks to clean
874 * @return array Cleaned array with only valid blocks
875 */
876 public static function cleanInvalidBlocks(array $blocks) {
877 $cleanedBlocks = [];
878
879 foreach ($blocks as $block) {
880 // Skip if not array
881 if (!is_array($block)) {
882 continue;
883 }
884
885 // Skip if blockName is null or empty
886 if (empty($block['blockName'])) {
887 continue;
888 }
889
890 // Skip if missing required properties
891 if (!isset($block['attrs']) ||
892 !isset($block['innerBlocks']) ||
893 !isset($block['innerHTML']) ||
894 !isset($block['innerContent'])) {
895 continue;
896 }
897
898 // Clean nested blocks recursively
899 if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
900 $block['innerBlocks'] = self::cleanInvalidBlocks($block['innerBlocks']);
901 }
902
903 $cleanedBlocks[] = $block;
904 }
905
906 return $cleanedBlocks;
907 }
908 }
909