PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.4.0
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.4.0
3.8.0 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 All 112 releases
templately / includes / Core / Importer / Utils / AIContentHelper.php

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

781 lines 25.9 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 * Recursively flatten a nested array by extracting elements with 'contents' and using their ID as key
82 *
83 * @param array $array The array to flatten
84 * @param array $flat Reference to the flattened array
85 * @return array The flattened array
86 */
87 public static function flattenById($array, &$flat = []) {
88 foreach ($array as $key => $value) {
89 if (is_array($value)) {
90 // If this is an element with 'widgetType' and 'contents', use its parent key as ID
91 if (isset($value['widgetType']) && isset($value['contents'])) {
92 $flat[$key] = $value;
93 }
94 // Recurse into children
95 self::flattenById($value, $flat);
96 }
97 }
98 return $flat;
99 }
100
101
102
103 /**
104 * Set a value in a nested array using a dot notation path
105 *
106 * @param array $array Reference to the array to modify
107 * @param array $path The path as an array of keys
108 * @param mixed $value The value to set
109 */
110 public static function setNestedValue(&$array, $path, $value) {
111 $key = array_shift($path);
112
113 if (empty($path)) {
114 // We've reached the final key, set the value
115 $array[$key] = $value;
116 } else {
117 // Initialize the nested array if it doesn't exist
118 if (!isset($array[$key]) || !is_array($array[$key])) {
119 $array[$key] = [];
120 }
121
122 // Continue recursively
123 self::setNestedValue($array[$key], $path, $value);
124 }
125 }
126
127 /**
128 * Process AI content by merging it with the original template
129 *
130 * @param string $old_template_id The template ID to process
131 * @return array Array containing the processed template and whether it's AI content
132 */
133 public function processAiContent($old_template_id) {
134 // Generate file paths
135 $paths = $this->generateAiFilePaths($old_template_id);
136 $original_file = $paths['original_file'];
137 $ai_file = $paths['ai_file_path'];
138
139 $file = $original_file;
140 $isAi = false;
141
142 // Check for AI file
143 if ($this->hasAiFile($ai_file)) {
144 $file = $ai_file;
145 $isAi = true;
146 }
147
148 // Read the template JSON
149 $template_json = Utils::read_json_file($file);
150
151 if ($isAi) {
152 // Read original template JSON content for merging
153 $original_template_json = Utils::read_json_file($original_file);
154 $ai_template_json = $template_json;
155
156 if($this->platform === 'elementor'){
157 $template_json = self::mergeAiContentWithOriginal($ai_template_json, $original_template_json);
158 }
159 else if($this->platform === 'gutenberg'){
160 $template_json = self::mergeAiContentWithOriginalGutenberg($ai_template_json, $original_template_json);
161 }
162
163 // Write debug files after merging
164 $this->writeDebugFile($original_file, $template_json, 'ao');
165 }
166
167 return [
168 'template_json' => $template_json,
169 'is_ai' => $isAi,
170 'file_used' => $file
171 ];
172 }
173
174 /**
175 * Merge AI content with the original template
176 *
177 * @param array $ai_template_json The AI template JSON
178 * @param array $original_template_json The original template JSON data
179 * @return array The merged template JSON
180 */
181 public static function mergeAiContentWithOriginal($ai_template_json, $original_template_json) {
182 // 1. Flatten the AI template
183 $flat = self::flattenById($ai_template_json);
184 $keys = array_keys($flat);
185
186 // 2. Loop through original content only once and update elements directly
187 self::updateElementorContentRecursively($flat, $keys, $original_template_json['content']);
188
189 return $original_template_json;
190 }
191
192 /**
193 * Update Elementor content recursively by looping through original content only once
194 *
195 * @param array $flat The flattened AI content array
196 * @param array $keys Array of element IDs from the flat array
197 * @param array $content Reference to the original content to update
198 */
199 public static function updateElementorContentRecursively($flat, array $keys, &$content) {
200 if (!is_array($content)) {
201 return;
202 }
203
204 // Check if this element has an ID and needs updating
205 if (isset($content['id']) && in_array($content['id'], $keys)) {
206 $element_id = $content['id'];
207 $element = $flat[$element_id];
208
209 if (isset($element['contents'])) {
210 // Update settings based on contents
211 foreach ($element['contents'] as $item) {
212 if (isset($item['attribute'], $item['content'])) {
213 // Normalize content: unescape closing tags (remove backslash before </)
214 $content_value = is_string($item['content'])
215 ? str_replace(['<\/', '<\\/'], '</', $item['content'])
216 : $item['content'];
217
218 // Support for dot notation in attribute paths
219 if (strpos($item['attribute'], '.') !== false) {
220 $path = explode('.', $item['attribute']);
221 self::setNestedValue($content['settings'], $path, $content_value);
222 } else {
223 $content['settings'][$item['attribute']] = $content_value;
224 }
225 }
226 }
227 }
228 }
229
230 // Recurse through elements array
231 if (isset($content['elements']) && is_array($content['elements'])) {
232 foreach ($content['elements'] as &$element) {
233 self::updateElementorContentRecursively($flat, $keys, $element);
234 }
235 }
236
237 // Recurse through all other array elements
238 foreach ($content as &$value) {
239 if (is_array($value)) {
240 self::updateElementorContentRecursively($flat, $keys, $value);
241 }
242 }
243 }
244
245 /**
246 * Merge AI content with the original Gutenberg template using advanced content replacement
247 *
248 * @param array $ai_template_json The AI template JSON
249 * @param array $original_template_json The original template JSON data
250 * @return array The merged template JSON
251 */
252 public static function mergeAiContentWithOriginalGutenberg($ai_template_json, $original_template_json) {
253 if (empty($original_template_json['content'])) {
254 return $original_template_json;
255 }
256
257 // 1. Flatten the AI template by block ID (for blocks with 'contents')
258 $flat = [];
259 self::flattenGutenbergById($ai_template_json, $flat);
260 $generated = $flat;
261 $keys = array_keys($generated);
262
263 // 2. Parse the original Gutenberg content
264 $blocks = parse_blocks($original_template_json['content']);
265
266 // 3. Clean invalid blocks from parsed content
267 $blocks = self::cleanInvalidBlocks($blocks);
268
269 // 4. Replace content recursively using advanced replacer logic
270 $blocks = self::replaceGutenbergContentRecursively($generated, $keys, $blocks);
271
272 // 4. Clean invalid blocks before serialization
273 $blocks = self::cleanInvalidBlocks($blocks);
274
275 // 5. Serialize the updated blocks back to content
276 $original_template_json['content'] = serialize_blocks($blocks);
277
278 return $original_template_json;
279 }
280
281 /**
282 * Replace content recursively in Gutenberg blocks (ported from GutenbergContentReplacer)
283 */
284 public static function replaceGutenbergContentRecursively($generated, array $keys, &$blocks) {
285 $htmlSources = [
286 'testimonial',
287 'feature-list',
288 'notice',
289 'pricing-table',
290 'typing-text',
291 'interactive-promo',
292 'call-to-action'
293 ];
294
295 foreach ($blocks as &$block) {
296 if (!empty($block['attrs']['blockId'])) {
297 $blockId = $block['attrs']['blockId'];
298 if (in_array($blockId, $keys)) {
299 $blockData = $generated[$blockId];
300 $block_name = self::cleanBlockName( $block['blockName'] );
301
302 // Store old content BEFORE updating attributes
303 $oldContentMap = [];
304 if (!empty($blockData['contents']) && !in_array($block_name, $htmlSources)) {
305 foreach ($blockData['contents'] as $content) {
306 $attribute = $content['attribute'];
307 $oldContent = self::getNestedGutenbergAttribute($block['attrs'], $attribute);
308 if ($oldContent !== null) {
309 $oldContentMap[$attribute] = $oldContent;
310 }
311 }
312 }
313
314 // Replace content in attributes
315 if (!empty($blockData['contents']) && !in_array($block_name, $htmlSources)) {
316 foreach ($blockData['contents'] as $content) {
317 $attribute = $content['attribute'];
318 $newContent = $content['content'];
319 self::setNestedGutenbergAttribute($block['attrs'], $attribute, $newContent);
320 }
321 }
322
323 // Replace content in innerHTML and innerContent using old content
324 if (!empty($block['innerHTML']) || !empty($block['innerContent'])) {
325 self::replaceInGutenbergHtmlContent($block, $blockData, $oldContentMap);
326 }
327
328 if(in_array($block_name, $htmlSources)){
329 if (!empty($blockData['contents'])) {
330 if (!empty($block['innerHTML'])) {
331 $block['innerHTML'] = self::replaceContentByClassName($block['innerHTML'], $blockData['contents']);
332 }
333 if (!empty($block['innerContent']) && is_array($block['innerContent'])) {
334 foreach ($block['innerContent'] as &$content) {
335 if (is_string($content)) {
336 $content = self::replaceContentByClassName($content, $blockData['contents']);
337 }
338 }
339 }
340 }
341 }
342 if($block["blockName"] === 'essential-blocks/accordion'){
343 $block_inner_block_ids = array_map(function($innerBlock) {
344 return $innerBlock["attrs"]["blockId"] ?? null;
345 }, $block["innerBlocks"]);
346 $_generated = array_fill_keys($block_inner_block_ids, ['contents' => $blockData['contents']]);
347
348 if(isset($block["innerBlocks"][0]["attrs"]["accordionLists"]) && count($block["innerBlocks"][0]["attrs"]["accordionLists"]) > 1){
349 $block["innerBlocks"] = self::replaceGutenbergContentRecursively($_generated, $block_inner_block_ids, $block['innerBlocks']);
350 }
351 else {
352 // $block["attrs"]["accordionLists"][0]["id"]
353 $attrAccordionLists = $block["attrs"]["accordionLists"];
354 foreach ($block["innerBlocks"] as $key => $accordion) {
355 foreach($accordion["attrs"]["accordionLists"] as $accordionKey => $accordionList){
356 // $accordionList["id"]
357 // search $block["attrs"]["accordionLists"] by $accordionList["id"] and replace $accordionList with searched one
358 $ids = array_column($attrAccordionLists, 'id');
359 $foundIndex = array_search($accordionList["id"], $ids);
360 if ($foundIndex !== false) {
361 $block["innerBlocks"][$key]["attrs"]["accordionLists"][$accordionKey] = $attrAccordionLists[$foundIndex];
362 }
363 }
364 }
365 }
366 }
367 }
368
369 // Process nested blocks recursively
370 if (!empty($block['innerBlocks'])) {
371 $block['innerBlocks'] = self::replaceGutenbergContentRecursively($generated, $keys, $block['innerBlocks']);
372 }
373 }
374 }
375 return $blocks;
376 }
377
378 /**
379 * Set nested attribute value using dot notation (ported from GutenbergContentReplacer)
380 */
381 public static function setNestedGutenbergAttribute(&$attrs, $path, $value) {
382 $keys = explode('.', $path);
383 $current = &$attrs;
384 for ($i = 0; $i < count($keys) - 1; $i++) {
385 $key = $keys[$i];
386 if (!isset($current[$key])) {
387 $current[$key] = [];
388 }
389 $current = &$current[$key];
390 }
391 $finalKey = end($keys);
392 $current[$finalKey] = $value;
393 }
394
395 /**
396 * Get nested attribute value using dot notation (ported from GutenbergContentReplacer)
397 */
398 public static function getNestedGutenbergAttribute($attrs, $path) {
399 $keys = explode('.', $path);
400 $current = $attrs;
401 foreach ($keys as $key) {
402 if (!isset($current[$key])) {
403 return null;
404 }
405 $current = $current[$key];
406 }
407 return $current;
408 }
409
410 /**
411 * Replace content in innerHTML and innerContent while preserving HTML structure (ported from GutenbergContentReplacer)
412 */
413 public static function replaceInGutenbergHtmlContent(&$block, $blockData, $oldContentMap) {
414 if (empty($blockData['contents']) || empty($oldContentMap)) return;
415 $replacements = [];
416 foreach ($blockData['contents'] as $content) {
417 $attribute = $content['attribute'];
418 $newContent = $content['content'];
419 if (isset($oldContentMap[$attribute])) {
420 $oldAttributeContent = $oldContentMap[$attribute];
421 $decodedUnicodeContent = json_decode('"' . $oldAttributeContent . '"');
422 $normalizedAttributeContent = self::normalizeGutenbergUnicodeContent($oldAttributeContent);
423 $normalizedNewContent = self::normalizeGutenbergUnicodeContent($newContent);
424 if ($normalizedAttributeContent !== $normalizedNewContent) {
425 $replacements[] = [
426 'originalFormat' => $oldAttributeContent,
427 'decodedFormat' => $decodedUnicodeContent,
428 'normalizedFormat' => $normalizedAttributeContent,
429 'newContent' => $newContent,
430 'attribute' => $attribute
431 ];
432 }
433 }
434 }
435 if (!empty($block['innerHTML']) && !empty($replacements)) {
436 $block['innerHTML'] = self::replaceGutenbergContentInHtml($block['innerHTML'], $replacements);
437 }
438 if (!empty($block['innerContent']) && is_array($block['innerContent'])) {
439 foreach ($block['innerContent'] as &$content) {
440 if (is_string($content)) {
441 $content = self::replaceGutenbergContentInHtml($content, $replacements);
442 }
443 }
444 }
445 }
446
447 /**
448 * Replace content in HTML while preserving structure and handling Unicode (ported from GutenbergContentReplacer)
449 */
450 public static function replaceGutenbergContentInHtml($html, $replacements) {
451 foreach ($replacements as $replacement) {
452 $originalFormat = $replacement['originalFormat'];
453 $decodedFormat = $replacement['decodedFormat'];
454 $normalizedFormat = $replacement['normalizedFormat'];
455 $newContent = $replacement['newContent'];
456 if (empty($originalFormat)) continue;
457 $htmlNewContent = $newContent;
458 $html = str_replace($originalFormat, $htmlNewContent, $html);
459 if ($decodedFormat !== null && $decodedFormat !== $originalFormat) {
460 $html = str_replace($decodedFormat, $htmlNewContent, $html);
461 }
462 if ($normalizedFormat !== $decodedFormat && $normalizedFormat !== $originalFormat) {
463 $html = str_replace($normalizedFormat, $htmlNewContent, $html);
464 }
465 }
466 return $html;
467 }
468
469 /**
470 * Normalize Unicode content to handle different apostrophe types and other Unicode variations (ported from GutenbergContentReplacer)
471 */
472 public static function normalizeGutenbergUnicodeContent($content) {
473 $decoded = json_decode('"' . $content . '"');
474 if ($decoded !== null) {
475 $content = $decoded;
476 }
477 $unicodeReplacements = [
478 '\u2019' => "'",
479 '\u2018' => "'",
480 '\u201C' => '"',
481 '\u201D' => '"',
482 '\u2013' => '-',
483 '\u2014' => '-',
484 '\u2026' => '...',
485 "\u{2019}" => "'",
486 "\u{2018}" => "'",
487 "\u{201C}" => '"',
488 "\u{201D}" => '"',
489 "\u{2013}" => '-',
490 "\u{2014}" => '-',
491 "\u{2026}" => '...'
492 ];
493 return str_replace(array_keys($unicodeReplacements), array_values($unicodeReplacements), $content);
494 }
495
496 /**
497 * Convert content to HTML format (handle line breaks and inline tags) (ported from GutenbergContentReplacer)
498 */
499 public static function convertGutenbergToHtmlFormat($content) {
500 $content = str_replace("\n", '<br>', $content);
501 $content = str_replace("\r\n", '<br>', $content);
502 return $content;
503 }
504
505 /**
506 * Recursively flatten a nested Gutenberg AI array by extracting blocks with 'contents' and using their blockId as key
507 *
508 * @param array $array The array to flatten
509 * @param array $flat Reference to the flattened array
510 * @return array The flattened array
511 */
512 public static function flattenGutenbergById($array, &$flat = []) {
513 foreach ($array as $key => $value) {
514 if (is_array($value)) {
515 // If this is a block with 'blockName' and 'contents', use its parent key as ID
516 if (isset($value['blockName']) && isset($value['contents'])) {
517 $flat[$key] = $value;
518 }
519 // Recurse into children
520 self::flattenGutenbergById($value, $flat);
521 }
522 }
523 return $flat;
524 }
525
526 /**
527 * Write debug file only if TEMPLATELY_DEV_VIEWS is defined and true
528 * Handles .ai.json to .ao.json or .og.json as appropriate
529 */
530 protected function writeDebugFile($ai_file, $data, $type = 'ao') {
531 if ((defined('TEMPLATELY_DEV') && TEMPLATELY_DEV) || (defined('IMPORT_DEBUG') && IMPORT_DEBUG)) {
532 $replace = ".{$type}.json";
533 $debug_file = str_replace('.json', $replace, $ai_file);
534 file_put_contents($debug_file, json_encode($data));
535 }
536 }
537
538 /**
539 * Replace the inner content of tags with given class names in the HTML.
540 * Supports indexed class names (e.g., "eb-feature-list-title.0", "eb-feature-list-title.1").
541 * Falls back to regex if DOMDocument does not find the class.
542 *
543 * @param string $html The HTML string.
544 * @param array $contents Array of ['attribute' => className, 'content' => newContent]
545 * @return string The updated HTML.
546 */
547 public static function replaceContentByClassName($html, $contents) {
548 $classExists = false;
549 foreach ($contents as $item) {
550 $className = $item['attribute'];
551 // Extract base class name (remove index if present)
552 $baseClassName = self::extractBaseClassName($className);
553 if (preg_match('/class=["\'][^"\']*\b' . preg_quote($baseClassName, '/') . '\b[^"\']*["\']/', $html)) {
554 $classExists = true;
555 break;
556 }
557 }
558 if (!$classExists) {
559 return $html; // No relevant class found, skip both methods
560 }
561
562 if (class_exists('DOMDocument') && class_exists('DOMXPath')) {
563 return self::replaceContentByClassNameDom($html, $contents);
564 } else {
565 return self::replaceContentByClassNameRegex($html, $contents);
566 }
567 }
568
569 /**
570 * Extract base class name from indexed class name.
571 *
572 * @param string $className The class name (e.g., "eb-feature-list-title.0")
573 * @return string The base class name (e.g., "eb-feature-list-title")
574 */
575 public static function extractBaseClassName($className) {
576 // Check if class name has numeric index at the end
577 if (preg_match('/^(.+)\.(\d+)$/', $className, $matches)) {
578 return $matches[1]; // Return base class name
579 }
580 return $className; // Return original if no index found
581 }
582
583 /**
584 * Extract index from indexed class name.
585 *
586 * @param string $className The class name (e.g., "eb-feature-list-title.0")
587 * @return int|null The index (e.g., 0) or null if no index found
588 */
589 public static function extractClassIndex($className) {
590 // Check if class name has numeric index at the end
591 if (preg_match('/^(.+)\.(\d+)$/', $className, $matches)) {
592 return (int)$matches[2]; // Return index as integer
593 }
594 return null; // Return null if no index found
595 }
596
597 /**
598 * Replace the inner content of tags with given class names in the HTML using DOMDocument.
599 * Supports indexed class names (e.g., "eb-feature-list-title.0", "eb-feature-list-title.1").
600 *
601 * Note: While CSS selectors would be more readable, PHP's DOMDocument doesn't natively support
602 * CSS selectors. We use XPath which is the standard way to query DOM elements in PHP.
603 * For CSS selector support, you would need a third-party library like symfony/css-selector
604 * or QueryPath, but we keep this implementation dependency-free.
605 *
606 * @param string $html The HTML string.
607 * @param array $contents Array of ['attribute' => className, 'content' => newContent]
608 * @return string The updated HTML.
609 */
610 public static function replaceContentByClassNameDom($html, $contents) {
611 $dom = new \DOMDocument();
612 // Suppress errors due to HTML5 tags or fragments
613 $html = self::escapeInvalidEntities($html);
614 @$dom->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
615
616 $xpath = new \DOMXPath($dom);
617 foreach ($contents as $item) {
618 $className = $item['attribute'];
619 $newContent = self::escapeInvalidEntities($item['content']);
620 // $newContent = $item['content'];
621
622 // Extract base class name and index
623 $baseClassName = self::extractBaseClassName($className);
624 $targetIndex = self::extractClassIndex($className);
625
626 // Find elements by base class name using XPath
627 // XPath equivalent to CSS selector: .baseClassName
628 $nodes = $xpath->query("//*[contains(concat(' ', normalize-space(@class), ' '), ' $baseClassName ')]");
629
630 if ($targetIndex !== null) {
631 // If indexed, only replace the element at the specific index
632 if (isset($nodes[$targetIndex])) {
633 $nodes[$targetIndex]->nodeValue = $newContent;
634 }
635 } else {
636 // If not indexed, replace all elements with the class
637 foreach ($nodes as $node) {
638 $node->nodeValue = $newContent;
639 }
640 }
641 }
642 // Remove the XML encoding declaration
643 $result = $dom->saveHTML();
644 $result = preg_replace('/^<\?xml.*?\?>/', '', $result);
645 return $result;
646 }
647
648 /**
649 * Replace the inner content of tags with given class names in the HTML using regex.
650 * Supports indexed class names (e.g., "eb-feature-list-title.0", "eb-feature-list-title.1").
651 *
652 * @param string $html The HTML string.
653 * @param array $contents Array of ['attribute' => className, 'content' => newContent]
654 * @return string The updated HTML.
655 */
656 public static function replaceContentByClassNameRegex($html, $contents) {
657 foreach ($contents as $item) {
658 $className = $item['attribute'];
659 $newContent = $item['content'];
660
661 // Extract base class name and index
662 $baseClassName = self::extractBaseClassName($className);
663 $targetIndex = self::extractClassIndex($className);
664
665 if ($targetIndex !== null) {
666 // Handle indexed replacement
667 $html = self::replaceContentByClassNameRegexIndexed($html, $baseClassName, $newContent, $targetIndex);
668 } else {
669 // Handle non-indexed replacement (original behavior)
670 $quotedClassName = preg_quote($className, '/');
671 $pattern = '/(<([a-z0-9]+)[^>]*class="[^"]*\b' . $quotedClassName . '\b[^"]*"[^>]*>)(.*?)(<\/\2>)/is';
672 $replacement = '$1' . $newContent . '$4';
673 $html = preg_replace($pattern, $replacement, $html);
674 }
675 }
676 return $html;
677 }
678
679 /**
680 * Replace content for a specific indexed occurrence of a class name using regex.
681 *
682 * @param string $html The HTML string.
683 * @param string $baseClassName The base class name (without index).
684 * @param string $newContent The new content to replace.
685 * @param int $targetIndex The zero-based index of the element to replace.
686 * @return string The updated HTML.
687 */
688 public static function replaceContentByClassNameRegexIndexed($html, $baseClassName, $newContent, $targetIndex) {
689 $quotedClassName = preg_quote($baseClassName, '/');
690 /*
691 Regex explanation:
692 - (<([a-z0-9]+)[^>]*class="[^"]*\b$baseClassName\b[^"]*"[^>]*>)
693 - (<([a-z0-9]+)[^>]* ... >) : Captures the opening tag with any attributes
694 - ([a-z0-9]+) : Captures the tag name (e.g., p, div, span)
695 - class="[^"]*\b$baseClassName\b[^"]*" : Ensures the class attribute contains the exact base class name (word boundary)
696 - (.*?) : Captures everything inside the tag (non-greedy)
697 - (<\/\2>) : Matches the corresponding closing tag (\2 is the tag name from earlier)
698 Flags:
699 - i : case-insensitive (for tag names)
700 - s : dot matches newlines
701 */
702 $pattern = '/(<([a-z0-9]+)[^>]*class="[^"]*\b' . $quotedClassName . '\b[^"]*"[^>]*>)(.*?)(<\/\2>)/is';
703
704 $currentIndex = 0;
705 $result = preg_replace_callback($pattern, function($matches) use ($newContent, $targetIndex, &$currentIndex) {
706 if ($currentIndex == $targetIndex) {
707 $currentIndex++;
708 return $matches[1] . $newContent . $matches[4];
709 }
710 $currentIndex++;
711 return $matches[0]; // Return original match unchanged
712 }, $html);
713
714 return $result;
715 }
716
717 /**
718 * Clean block name by removing namespace/plugin prefix
719 *
720 * @param string $block_name The full block name
721 *
722 * @return string Cleaned block name without prefix
723 */
724 public static function cleanBlockName( $block_name ) {
725 // Remove namespace/plugin prefix (everything before the last slash)
726 $parts = explode( '/', $block_name );
727
728 return end( $parts );
729 }
730
731 /**
732 * Escape invalid entities in HTML to prevent DOMDocument warnings.
733 *
734 * @param string $html The HTML string to escape.
735 * @return string The escaped HTML string.
736 */
737 public static function escapeInvalidEntities($html) {
738 // Replace & not followed by one of: #, a-z, A-Z, or 0-9, and then a semicolon
739 return preg_replace('/&(?!(#[0-9]+|[a-zA-Z0-9]+);)/', '&amp;', $html);
740 }
741
742 /**
743 * Remove invalid blocks from array
744 *
745 * @param array $blocks Array of blocks to clean
746 * @return array Cleaned array with only valid blocks
747 */
748 public static function cleanInvalidBlocks(array $blocks) {
749 $cleanedBlocks = [];
750
751 foreach ($blocks as $block) {
752 // Skip if not array
753 if (!is_array($block)) {
754 continue;
755 }
756
757 // Skip if blockName is null or empty
758 if (empty($block['blockName'])) {
759 continue;
760 }
761
762 // Skip if missing required properties
763 if (!isset($block['attrs']) ||
764 !isset($block['innerBlocks']) ||
765 !isset($block['innerHTML']) ||
766 !isset($block['innerContent'])) {
767 continue;
768 }
769
770 // Clean nested blocks recursively
771 if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
772 $block['innerBlocks'] = self::cleanInvalidBlocks($block['innerBlocks']);
773 }
774
775 $cleanedBlocks[] = $block;
776 }
777
778 return $cleanedBlocks;
779 }
780 }
781