| 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]+);)/', '&', $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 |
|