PluginProbe
Extendify / 3.0.4
Extendify v3.0.4
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.4, at app/Agent/Controllers/WPController.php

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