PluginProbe
Extendify / 3.0.6
Extendify v3.0.6
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / app / Agent / Controllers / WPController.php

WPController.php in Extendify 3.0.6, at app/Agent/Controllers/WPController.php

688 lines 23.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * WP Controller
5 */
6
7 namespace Extendify\Agent\Controllers;
8
9 defined('ABSPATH') || die('No direct access.');
10
11 use Extendify\Constants;
12 use Extendify\Shared\Services\Sanitizer;
13
14 /**
15 * The controller for interacting with WordPress.
16 */
17
18 class WPController
19 {
20 /**
21 * $ignoredKeys are only removed top-level (line 94) and not recursively
22 *
23 * @var string[]
24 */
25 public static $ignoredKeys = [
26 'title',
27 '$schema',
28 'version',
29 'slug',
30 ];
31 /**
32 * Allowed variations for the extendable theme
33 *
34 * @var string[]
35 */
36 public static $allowedVariationsList = [
37 'bloom',
38 'brick',
39 'cobalt',
40 'coral',
41 'evergreen',
42 'gold',
43 'lilac',
44 'lime',
45 'midnight',
46 'moss',
47 'neon',
48 'rosewood',
49 'slate',
50 'onyx',
51 'glasgow',
52 'royal',
53 'obsidian',
54 ];
55
56 /**
57 * Recursively filter an array to include only specified properties.
58 *
59 * This function traverses the array structure and retains only the properties
60 * specified in the allowed keys, preserving the original hierarchical structure.
61 * Keys that don't match the allowed set are excluded from the result.
62 *
63 * @param array $data The input array to filter
64 * @param array $allowedKeys Associative array of allowed property keys (keys as indices)
65 * @return array Filtered array containing only allowed properties, maintaining structure
66 */
67 protected static function filterArrayByProperties(array $data, array $allowedKeys)
68 {
69 if (empty($allowedKeys) || empty($data)) {
70 return [];
71 }
72
73 $result = [];
74 foreach ($data as $key => $value) {
75 if (isset($allowedKeys[$key])) {
76 $result[$key] = $value;
77 } elseif (is_array($value)) {
78 // Recursively filter nested arrays
79 $filtered = self::filterArrayByProperties($value, $allowedKeys);
80 if (!empty($filtered)) {
81 $result[$key] = $filtered;
82 }
83 }
84 }
85 return $result;
86 }
87
88 /**
89 * Validates if a variation contains only specified properties.
90 *
91 * This function checks whether the variation array contains exclusively the
92 * specified properties throughout its entire hierarchy.
93 *
94 * @param array $variation The theme variation arrays to validate
95 * @param array $allowedKeys List of property names that should be the only ones present
96 * @return bool TRUE if only specified properties exist, FALSE otherwise
97 */
98 protected static function variationHasProperties(array $variation, array $allowedKeys)
99 {
100 if (empty($variation) || empty($allowedKeys)) {
101 return false;
102 }
103
104 $allowedKeys = array_flip($allowedKeys);
105 $data = array_diff_key($variation, array_flip(self::$ignoredKeys));
106 $filtered = self::filterArrayByProperties($data, $allowedKeys);
107
108 return serialize($filtered) === serialize($data);
109 }
110
111 /**
112 * Get the CSS for each variation.
113 *
114 * @param array $variations The theme variations to process.
115 * @param \WP_Theme_JSON $current The current theme JSON data.
116 * @param bool $includeLayoutStyles Whether to include layout styles in the CSS.
117 * @return array The variations with their corresponding CSS.
118 */
119 protected static function getCss($variations, $current, $includeLayoutStyles)
120 {
121 $deduped = [];
122 foreach ($variations as $variation) {
123 $title = $variation['title'] ?? null;
124 if (!$title || isset($deduped[$title])) {
125 continue;
126 }
127 $theme = new \WP_Theme_JSON();
128 $theme->merge($current);
129 $theme->merge(new \WP_Theme_JSON($variation));
130 $css = $theme->get_stylesheet(
131 ["variables", "styles", "presets"],
132 null,
133 ["skip_root_layout_styles" => !$includeLayoutStyles, 'include_block_style_variations' => true]
134 );
135 $variation['css'] = $css;
136 // to make sure we exit early
137 $deduped[$title] = $variation;
138 }
139
140 return array_values($deduped);
141 }
142
143 /**
144 * Get Theme Variations and the compiled CSS for each variation.
145 *
146 * @param \WP_REST_Request $request The REST API request object.
147 * @return \WP_REST_Response
148 */
149 public static function getVariations($request)
150 {
151 $includeLayoutStyles = $request->has_param('includeLayoutStyles');
152 $current = \WP_Theme_JSON_Resolver::get_merged_data();
153 $unfiltered = \WP_Theme_JSON_Resolver::get_style_variations();
154
155 $variations = array_filter($unfiltered, function ($variation) {
156 return self::variationHasProperties($variation, ['color']);
157 });
158
159 $buildSlugMap = function ($unfiltered) {
160 $slugMap = [];
161
162 if (!is_array($unfiltered)) {
163 return $slugMap;
164 }
165
166 foreach ($unfiltered as $rawSlug => $rawVariation) {
167 $title = is_array($rawVariation) ? ($rawVariation['title'] ?? null) : null;
168 $slug = is_array($rawVariation)
169 ? ($rawVariation['slug'] ?? (is_string($rawSlug) ? $rawSlug : null))
170 : null;
171
172 if ($title && $slug && !isset($slugMap[$title])) {
173 $slugMap[$title] = $slug;
174 }
175 }
176 return $slugMap;
177 };
178 $slugMap = $buildSlugMap($unfiltered);
179 array_walk($variations, function (&$variation) use ($slugMap) {
180 if (!is_array($variation) || isset($variation['slug'])) {
181 return;
182 }
183
184 $title = $variation['title'] ?? null;
185 if ($title && isset($slugMap[$title])) {
186 $variation['slug'] = $slugMap[$title];
187 }
188 });
189
190 $deduped = static::getCss($variations, $current, $includeLayoutStyles);
191 // if the theme is extendable we need to filter the variations using the allowed variations list
192 if (\get_option('stylesheet') === 'extendable') {
193 $deduped = array_filter($deduped, function ($variation) {
194 return in_array($variation['slug'], self::$allowedVariationsList);
195 });
196 }
197
198
199 return new \WP_REST_Response(array_values($deduped));
200 }
201
202 /**
203 * Get Theme fonts Variations and the compiled CSS for each variation.
204 *
205 * @param \WP_REST_Request $request The REST API request object.
206 * @return \WP_REST_Response
207 */
208 public static function getFontsVariations($request)
209 {
210 $includeLayoutStyles = $request->has_param('includeLayoutStyles');
211 $current = \WP_Theme_JSON_Resolver::get_merged_data();
212 $unfiltered = \WP_Theme_JSON_Resolver::get_style_variations();
213
214 $fontsVariations = array_filter($unfiltered, function ($variation) {
215 return self::variationHasProperties($variation, ['elements', 'typography']);
216 });
217
218 $processedFonts = array_map(function ($variation) {
219 if (!isset($variation['styles']['elements']) || !is_array($variation['styles']['elements'])) {
220 return $variation;
221 }
222
223 $variation['styles']['elements'] = array_map(
224 [self::class, 'normalizeElementTypography'],
225 $variation['styles']['elements']
226 );
227
228 if (!isset($variation['styles']['typography'])) {
229 $variation['styles']['typography'] = [
230 'fontFamily' => 'var(--wp--preset--font-family--inter)'
231 ];
232 }
233
234 // Removing the settings that cause the style to change.
235 unset($variation['settings']);
236
237 return $variation;
238 }, $fontsVariations);
239
240 $deduped = static::getCss($processedFonts, $current, $includeLayoutStyles);
241 return new \WP_REST_Response($deduped);
242 }
243
244 /**
245 * Get block style variations (vibes) from merged global styles
246 *
247 * @param \WP_REST_Request $request The request.
248 * @return \WP_REST_Response
249 */
250 public static function getBlockStyleVariations($request)
251 {
252 // Get theme + DB merged Global Styles
253 $merged = wp_get_global_styles();
254 $blocks = $merged['blocks'] ?? [];
255
256 $variations = [];
257
258 foreach ($blocks as $blockName => $blockData) {
259 if (!isset($blockData['variations'])) {
260 continue;
261 }
262
263 $variations[$blockName] = $blockData['variations'];
264 }
265
266 return new \WP_REST_Response($variations, 200);
267 }
268
269 /**
270 * Normalize typography properties for theme element styles.
271 *
272 * @param array $elementStyles The element styles array containing typography configuration
273 * @return array Normalized typography properties with filtered null values
274 */
275 protected static function normalizeElementTypography(array $elementStyles)
276 {
277 $typography = $elementStyles['typography'] ?? [];
278
279 return [
280 'typography' => array_filter([
281 'fontFamily' => $typography['fontFamily'] ?? null,
282 'fontSize' => $typography['fontSize'] ?? null,
283 'lineHeight' => $typography['lineHeight'] ?? null,
284 'letterSpacing' => $typography['letterSpacing'] ?? null,
285 'fontStyle' => $typography['fontStyle'] ?? null,
286 'fontWeight' => $typography['fontWeight'] ?? null,
287 'textTransform' => $typography['textTransform'] ?? 'none',
288 ], function ($v) {
289 return $v !== null;
290 })
291 ];
292 }
293
294
295 /**
296 * Get the HTML of a specific tagged block code
297 *
298 * @param \WP_REST_Request $request The REST API request object.
299 * @return \WP_REST_Response
300 */
301 public static function getBlockCode(\WP_REST_Request $request)
302 {
303 $blockId = (int) $request->get_param('blockId');
304 $postId = (int) $request->get_param('postId');
305
306 if ($blockId < 1) {
307 return new \WP_REST_Response(['error' => 'Invalid blockId'], 400);
308 }
309
310 $post = \get_post($postId);
311 if (!$post) {
312 return new \WP_REST_Response(['error' => 'Post not found'], 404);
313 }
314
315 $ignored = \Extendify\Agent\TagBlocks::$ignored;
316
317 $ast = array_values(array_filter(
318 parse_blocks($post->post_content),
319 static function ($b) {
320 return is_array($b) && !empty($b['blockName']);
321 }
322 ));
323
324 $seq = 0;
325 $found = null;
326
327 $walk = function (array $list) use (&$walk, &$seq, $blockId, &$found, $ignored) {
328 foreach ($list as $b) {
329 $name = $b['blockName'] ?? null;
330 if (!$name) {
331 continue;
332 }
333
334 // Ignore this block and its subtree (matches tagger behavior)
335 if (in_array($name, $ignored, true)) {
336 continue; // do NOT increment seq, do NOT traverse children
337 }
338
339 $seq++;
340 if ($seq === $blockId) {
341 $found = $b;
342 return true;
343 }
344
345 if (!empty($b['innerBlocks']) && $walk($b['innerBlocks'])) {
346 return true;
347 }
348 }
349 return false;
350 };
351 $walk($ast);
352
353 if (!is_array($found) || empty($found['blockName'])) {
354 return new \WP_REST_Response(['error' => 'Block id not found in this post'], 404);
355 }
356
357 return new \WP_REST_Response([
358 'postId' => $postId,
359 'blockId' => $blockId,
360 'name' => $found['blockName'],
361 'attrs' => $found['attrs'] ?? (object)[],
362 'block' => serialize_blocks([$found]),
363 ], 200);
364 }
365
366 /**
367 * Get the rendered HTML of some block code
368 *
369 * @param \WP_REST_Request $request The REST API request object.
370 * @return \WP_REST_Response
371 */
372 public static function getBlockHtml($request)
373 {
374 $blockCode = $request->get_param('blockCode');
375 $content = \do_blocks($blockCode);
376
377 return new \WP_REST_Response(['content' => trim($content)]);
378 }
379
380 /**
381 * Get the Hero Patterns from the API
382 *
383 * @param string $title The title to replace in the pattern code
384 * @param string $description The description to replace in the pattern code
385 * @param array $images The images to replace in the pattern code, as an array of urls
386 * @param array $cta The cta to replace in the pattern code, as an array with 'label' and 'link' keys
387 * @param bool $featured Whether to limit to featured patterns
388 * @return array|WP_Error|array<string|int, mixed> The hero patterns data or a WP_Error on failure
389 */
390 protected static function getHeroPatternsData($title, $description, $images, $cta, $featuredOnly = false)
391 {
392 $response = \wp_remote_post(
393 Constants::PATTERNS_HOST . '/api/heros',
394 [
395 'headers' => [
396 'Content-Type' => 'application/json',
397 'Accept' => 'application/json',
398 ],
399 'body' => wp_json_encode([
400 "wpVersion" => \get_bloginfo('version'),
401 "wpLanguage" => \get_locale(),
402 "featured" => $featuredOnly,
403 ])
404 ]
405 );
406
407 if (\is_wp_error($response)) {
408 return $response;
409 }
410
411 $body = json_decode(\wp_remote_retrieve_body($response), true);
412
413 $heroPatterns = array_map(
414 function ($heroPattern) use ($description, $title, $images, $cta) {
415 $code = $heroPattern['code'] ?? '';
416
417 if ($title) {
418 $code = preg_replace(
419 '/(<!-- wp:heading[^>]*-->[\s\S]*?<h1[^>]*>)[\s\S]*?(<\/h1>[\s\S]*?<!-- \/wp:heading -->)/m',
420 '${1}' . esc_html($title) . '${2}',
421 $code,
422 1
423 );
424 }
425
426 if ($description) {
427 $code = preg_replace(
428 '/(<!-- wp:paragraph[^>]*-->[\s\S]*?<p[^>]*>)[\s\S]*?(<\/p>[\s\S]*?<!-- \/wp:paragraph -->)/m',
429 '${1}' . esc_html($description) . '${2}',
430 $code,
431 1
432 );
433 }
434
435 if ($cta['label'] ?? null) {
436 $code = preg_replace(
437 '/(<!-- wp:button[^>]*-->[\s\S]*?<a[^>]*>)[\s\S]*?(<\/a>[\s\S]*?<!-- \/wp:button -->)/m',
438 '${1}' . esc_html($cta['label']) . '${2}',
439 $code,
440 1
441 );
442 }
443
444 if ($cta['link'] ?? null) {
445 $code = preg_replace(
446 '/(<!-- wp:button[\s\S]*?<a[^>]*\shref=")[^"]*(")/m',
447 '${1}' . esc_url($cta['link']) . '${2}',
448 $code,
449 1
450 );
451 }
452
453 foreach ($heroPattern['urls'] ?? [] as $key => $url) {
454 if (!($images[$key] ?? null)) {
455 break;
456 }
457
458 $code = str_replace($url, $images[$key], $code);
459 }
460
461 $renderedHtml = do_blocks(str_replace('ext-animate--on', '', $code));
462
463 $blockSupportsCss = function_exists('wp_style_engine_get_stylesheet_from_context')
464 ? wp_style_engine_get_stylesheet_from_context('block-supports')
465 : '';
466
467 // Clear block-supports store so CSS doesn't accumulate across patterns.
468 \WP_Style_Engine_CSS_Rules_Store::remove_all_stores();
469
470 $linkStyles = array_values(
471 array_filter(
472 array_map(
473 function ($style) {
474 return wp_styles()->registered[$style]->src ?? null;
475 },
476 wp_styles()->queue ?? []
477 )
478 )
479 );
480
481 /**
482 * Clear queue for the next pattern.
483 *
484 * `do_blocks` appends to the global queue the styles needed for the blocks.
485 */
486 wp_styles()->queue = [];
487
488 return [
489 'id' => $heroPattern['id'],
490 'name' => $heroPattern['name'],
491 'code' => $code,
492 'renderedHtml' => $renderedHtml,
493 'blockSupportsCss' => $blockSupportsCss,
494 'linkStyles' => $linkStyles,
495 ];
496 },
497 $body
498 );
499
500 return $heroPatterns;
501 }
502
503 /**
504 * Get the Hero Patterns
505 *
506 * @param \WP_REST_Request $request The REST API request object.
507 * @return \WP_REST_Response
508 */
509 public static function getHeroPatterns(\WP_REST_Request $request)
510 {
511 $title = $request->get_param('title');
512 $description = $request->get_param('description');
513 $images = $request->get_param('images');
514 $cta = $request->get_param('cta');
515
516 $heroPatterns = self::getHeroPatternsData($title, $description, $images, $cta);
517
518 if (\is_wp_error($heroPatterns)) {
519 return new \WP_REST_Response([], 500);
520 }
521
522 $blockEditorContext = new \WP_Block_Editor_Context(array( 'name' => 'core/edit-post' ));
523 $editorSettings = get_block_editor_settings([], $blockEditorContext);
524
525 return new \WP_REST_Response(['patterns' => $heroPatterns, 'blockEditorSettings' => $editorSettings ?? null]);
526 }
527
528 public static function getSiteDesignVariations(\WP_REST_Request $request)
529 {
530 $title = $request->get_param('title');
531 $description = $request->get_param('description');
532 $images = $request->get_param('images');
533 $cta = $request->get_param('cta');
534 $featuredOnly = true; // Only show featured patterns
535 $currentHeroPattern = $request->get_param('currentHeroPattern');
536
537 $heroPatterns = self::getHeroPatternsData($title, $description, $images, $cta, $featuredOnly);
538
539 if (\is_wp_error($heroPatterns)) {
540 return new \WP_REST_Response([], 500);
541 }
542
543 $heroPatterns = array_values(
544 array_filter(
545 $heroPatterns,
546 function ($heroPattern) use ($currentHeroPattern) {
547 if (!$currentHeroPattern) {
548 return true;
549 }
550
551 return $heroPattern['name'] !== $currentHeroPattern;
552 }
553 )
554 );
555
556 $current = \WP_Theme_JSON_Resolver::get_merged_data('theme');
557
558 $unfiltered = \WP_Theme_JSON_Resolver::get_style_variations();
559
560 // Keep only full style variations — exclude color-only and font-only
561 // presets that get_style_variations() returns from styles/colors/* and
562 // styles/typography/*.
563 $colorAndFontsVariations = array_filter($unfiltered, function ($variation) {
564 $hasPalette = ($variation['settings']['color']['palette'] ?? []) !== [];
565 $hasTypography = ($variation['styles']['typography'] ?? []) !== []
566 || ($variation['settings']['typography'] ?? []) !== [];
567 $hasElements = ($variation['styles']['elements'] ?? []) !== [];
568 return $hasPalette && $hasTypography && $hasElements;
569 });
570
571 $buildSlugMap = function ($unfiltered) {
572 $slugMap = [];
573
574 if (!is_array($unfiltered)) {
575 return $slugMap;
576 }
577
578 foreach ($unfiltered as $rawSlug => $rawVariation) {
579 $title = is_array($rawVariation) ? ($rawVariation['title'] ?? null) : null;
580 $slug = is_array($rawVariation)
581 ? ($rawVariation['slug'] ?? (is_string($rawSlug) ? $rawSlug : null))
582 : null;
583
584 if ($title && $slug && !isset($slugMap[$title])) {
585 $slugMap[$title] = $slug;
586 }
587 }
588 return $slugMap;
589 };
590 $slugMap = $buildSlugMap($unfiltered);
591 array_walk($colorAndFontsVariations, function (&$variation) use ($slugMap) {
592 if (!is_array($variation) || isset($variation['slug'])) {
593 return;
594 }
595
596 $title = $variation['title'] ?? null;
597 if ($title && isset($slugMap[$title])) {
598 $variation['slug'] = $slugMap[$title];
599 }
600 });
601
602 $processedFonts = array_map(function ($variation) {
603 if (!isset($variation['styles']['elements']) || !is_array($variation['styles']['elements'])) {
604 return $variation;
605 }
606
607 $variation['styles']['elements'] = array_map(
608 [self::class, 'normalizeElementTypography'],
609 $variation['styles']['elements']
610 );
611
612 if (!isset($variation['styles']['typography'])) {
613 $variation['styles']['typography'] = [
614 'fontFamily' => 'var(--wp--preset--font-family--inter)'
615 ];
616 }
617
618 return $variation;
619 }, $colorAndFontsVariations);
620
621 $deduped = static::getCss($processedFonts, $current, true);
622
623 $blockEditorContext = new \WP_Block_Editor_Context(array( 'name' => 'core/edit-post' ));
624 $editorSettings = get_block_editor_settings([], $blockEditorContext);
625 $editorSettings['styles'] = [];
626
627 return new \WP_REST_Response(
628 [
629 'patterns' => $heroPatterns,
630 'colorAndFontsVariations' => $deduped,
631 'blockEditorSettings' => $editorSettings ?? null,
632 ]
633 );
634 }
635
636 /**
637 * Sets a lock on a post to prevent concurrent editing.
638 *
639 * @param \WP_REST_Request $request The REST API request object containing postId.
640 * @return \WP_REST_Response Response indicating success of the lock operation.
641 */
642 public static function lockPost($request)
643 {
644 $postId = (int) $request->get_param('postId');
645 require_once ABSPATH . '/wp-admin/includes/post.php';
646 $data = \wp_set_post_lock($postId);
647 return new \WP_REST_Response(['success' => $data !== false]);
648 }
649
650 /**
651 * Persist the data
652 *
653 * @param \WP_REST_Request $request - The request.
654 * @return \WP_REST_Response
655 */
656 public static function updateOption($request)
657 {
658 $params = $request->get_json_params();
659 $key = $params['option'];
660 $sanitized = Sanitizer::sanitizeUnknown($params['value']);
661
662 if (strpos($key, 'extendify_') === 0) {
663 $key = substr($key, 10);
664 }
665 \update_option('extendify_' . $key, $sanitized);
666
667 return new \WP_REST_Response('OK');
668 }
669
670 /**
671 * Get the data
672 *
673 * @param \WP_REST_Request $request - The request.
674 * @return \WP_REST_Response
675 */
676 public static function getOption($request)
677 {
678 $key = $request->get_param('option');
679
680 if (strpos($key, 'extendify_') === 0) {
681 $key = substr($key, 10);
682 }
683 $value = \get_option('extendify_' . $key, null);
684
685 return new \WP_REST_Response($value);
686 }
687 }
688