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

928 lines 32.0 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\Agent\PostBlockFinder;
12 use Extendify\Agent\TemplatePartBlockFinder;
13 use Extendify\Constants;
14 use Extendify\Shared\Services\Sanitizer;
15 use Extendify\Shared\Services\SiteImages;
16
17 /**
18 * The controller for interacting with WordPress.
19 */
20
21 class WPController
22 {
23 /**
24 * $ignoredKeys are only removed top-level (line 94) and not recursively
25 *
26 * @var string[]
27 */
28 public static $ignoredKeys = [
29 'title',
30 '$schema',
31 'version',
32 'slug',
33 ];
34 /**
35 * Allowed variations for the extendable theme
36 *
37 * @var string[]
38 */
39 public static $allowedVariationsList = [
40 'bloom',
41 'brick',
42 'cobalt',
43 'coral',
44 'evergreen',
45 'gold',
46 'lilac',
47 'lime',
48 'midnight',
49 'moss',
50 'neon',
51 'rosewood',
52 'slate',
53 'onyx',
54 'glasgow',
55 'royal',
56 'obsidian',
57 'heath',
58 'signal',
59 'marzipan',
60 'butterscotch',
61 ];
62
63 /**
64 * Recursively filter an array to include only specified properties.
65 *
66 * This function traverses the array structure and retains only the properties
67 * specified in the allowed keys, preserving the original hierarchical structure.
68 * Keys that don't match the allowed set are excluded from the result.
69 *
70 * @param array $data The input array to filter
71 * @param array $allowedKeys Associative array of allowed property keys (keys as indices)
72 * @return array Filtered array containing only allowed properties, maintaining structure
73 */
74 protected static function filterArrayByProperties(array $data, array $allowedKeys)
75 {
76 if (empty($allowedKeys) || empty($data)) {
77 return [];
78 }
79
80 $result = [];
81 foreach ($data as $key => $value) {
82 if (isset($allowedKeys[$key])) {
83 $result[$key] = $value;
84 } elseif (is_array($value)) {
85 // Recursively filter nested arrays
86 $filtered = self::filterArrayByProperties($value, $allowedKeys);
87 if (!empty($filtered)) {
88 $result[$key] = $filtered;
89 }
90 }
91 }
92 return $result;
93 }
94
95 /**
96 * Validates if a variation contains only specified properties.
97 *
98 * This function checks whether the variation array contains exclusively the
99 * specified properties throughout its entire hierarchy.
100 *
101 * @param array $variation The theme variation arrays to validate
102 * @param array $allowedKeys List of property names that should be the only ones present
103 * @return bool TRUE if only specified properties exist, FALSE otherwise
104 */
105 protected static function variationHasProperties(array $variation, array $allowedKeys)
106 {
107 if (empty($variation) || empty($allowedKeys)) {
108 return false;
109 }
110
111 $allowedKeys = array_flip($allowedKeys);
112 $data = array_diff_key($variation, array_flip(self::$ignoredKeys));
113 $filtered = self::filterArrayByProperties($data, $allowedKeys);
114
115 return serialize($filtered) === serialize($data);
116 }
117
118 /**
119 * Get the CSS for each variation.
120 *
121 * @param array $variations The theme variations to process.
122 * @param \WP_Theme_JSON $current The current theme JSON data.
123 * @param bool $includeLayoutStyles Whether to include layout styles in the CSS.
124 * @return array The variations with their corresponding CSS.
125 */
126 protected static function getCss($variations, $current, $includeLayoutStyles)
127 {
128 $deduped = [];
129 foreach ($variations as $variation) {
130 $title = $variation['title'] ?? null;
131 if (!$title || isset($deduped[$title])) {
132 continue;
133 }
134 $theme = new \WP_Theme_JSON();
135 $theme->merge($current);
136 $theme->merge(new \WP_Theme_JSON($variation));
137 $css = $theme->get_stylesheet(
138 ["variables", "styles", "presets"],
139 null,
140 ["skip_root_layout_styles" => !$includeLayoutStyles, 'include_block_style_variations' => true]
141 );
142 $variation['css'] = $css;
143 // to make sure we exit early
144 $deduped[$title] = $variation;
145 }
146
147 return array_values($deduped);
148 }
149
150 /**
151 * Get Theme Variations and the compiled CSS for each variation.
152 *
153 * @param \WP_REST_Request $request The REST API request object.
154 * @return \WP_REST_Response
155 */
156 public static function getVariations($request)
157 {
158 $includeLayoutStyles = $request->has_param('includeLayoutStyles');
159 $current = \WP_Theme_JSON_Resolver::get_merged_data();
160 $unfiltered = \WP_Theme_JSON_Resolver::get_style_variations();
161
162 $variations = array_filter($unfiltered, function ($variation) {
163 return self::variationHasProperties($variation, ['color']);
164 });
165
166 $buildSlugMap = function ($unfiltered) {
167 $slugMap = [];
168
169 if (!is_array($unfiltered)) {
170 return $slugMap;
171 }
172
173 foreach ($unfiltered as $rawSlug => $rawVariation) {
174 $title = is_array($rawVariation) ? ($rawVariation['title'] ?? null) : null;
175 $slug = is_array($rawVariation)
176 ? ($rawVariation['slug'] ?? (is_string($rawSlug) ? $rawSlug : null))
177 : null;
178
179 if ($title && $slug && !isset($slugMap[$title])) {
180 $slugMap[$title] = $slug;
181 }
182 }
183 return $slugMap;
184 };
185 $slugMap = $buildSlugMap($unfiltered);
186 array_walk($variations, function (&$variation) use ($slugMap) {
187 if (!is_array($variation) || isset($variation['slug'])) {
188 return;
189 }
190
191 $title = $variation['title'] ?? null;
192 if ($title && isset($slugMap[$title])) {
193 $variation['slug'] = $slugMap[$title];
194 }
195 });
196
197 $deduped = static::getCss($variations, $current, $includeLayoutStyles);
198 // if the theme is extendable we need to filter the variations using the allowed variations list
199 if (\get_option('stylesheet') === 'extendable') {
200 $deduped = array_filter($deduped, function ($variation) {
201 return in_array($variation['slug'], self::$allowedVariationsList);
202 });
203 }
204
205
206 return new \WP_REST_Response(array_values($deduped));
207 }
208
209 /**
210 * Get Theme fonts Variations and the compiled CSS for each variation.
211 *
212 * @param \WP_REST_Request $request The REST API request object.
213 * @return \WP_REST_Response
214 */
215 public static function getFontsVariations($request)
216 {
217 $includeLayoutStyles = $request->has_param('includeLayoutStyles');
218 $current = \WP_Theme_JSON_Resolver::get_merged_data();
219 $unfiltered = \WP_Theme_JSON_Resolver::get_style_variations();
220
221 $fontsVariations = array_filter($unfiltered, function ($variation) {
222 return self::variationHasProperties($variation, ['elements', 'typography']);
223 });
224
225 $processedFonts = array_map(function ($variation) {
226 if (!isset($variation['styles']['elements']) || !is_array($variation['styles']['elements'])) {
227 return $variation;
228 }
229
230 $variation['styles']['elements'] = array_map(
231 [self::class, 'normalizeElementTypography'],
232 $variation['styles']['elements']
233 );
234
235 if (!isset($variation['styles']['typography'])) {
236 $variation['styles']['typography'] = [
237 'fontFamily' => 'var(--wp--preset--font-family--inter)'
238 ];
239 }
240
241 // Removing the settings that cause the style to change.
242 unset($variation['settings']);
243
244 return $variation;
245 }, $fontsVariations);
246
247 $deduped = static::getCss($processedFonts, $current, $includeLayoutStyles);
248 return new \WP_REST_Response($deduped);
249 }
250
251 /**
252 * Get block style variations (vibes) from merged global styles
253 *
254 * @param \WP_REST_Request $request The request.
255 * @return \WP_REST_Response
256 */
257 public static function getBlockStyleVariations($request)
258 {
259 // Get theme + DB merged Global Styles
260 $merged = wp_get_global_styles();
261 $blocks = $merged['blocks'] ?? [];
262
263 $variations = [];
264
265 foreach ($blocks as $blockName => $blockData) {
266 if (!isset($blockData['variations'])) {
267 continue;
268 }
269
270 $variations[$blockName] = $blockData['variations'];
271 }
272
273 return new \WP_REST_Response($variations, 200);
274 }
275
276 /**
277 * Normalize typography properties for theme element styles.
278 *
279 * @param array $elementStyles The element styles array containing typography configuration
280 * @return array Normalized typography properties with filtered null values
281 */
282 protected static function normalizeElementTypography(array $elementStyles)
283 {
284 $typography = $elementStyles['typography'] ?? [];
285
286 return [
287 'typography' => array_filter([
288 'fontFamily' => $typography['fontFamily'] ?? null,
289 'fontSize' => $typography['fontSize'] ?? null,
290 'lineHeight' => $typography['lineHeight'] ?? null,
291 'letterSpacing' => $typography['letterSpacing'] ?? null,
292 'fontStyle' => $typography['fontStyle'] ?? null,
293 'fontWeight' => $typography['fontWeight'] ?? null,
294 // Defaulting to 'none' would cancel the applied vibe's uppercase.
295 'textTransform' => $typography['textTransform'] ?? null,
296 ], function ($v) {
297 return $v !== null;
298 })
299 ];
300 }
301
302
303 /**
304 * Where else the synced pattern or menu a composite blockId names is used
305 *
306 * @param \WP_REST_Request $request The REST API request object.
307 * @return \WP_REST_Response
308 */
309 public static function getSharedBlockUsage(\WP_REST_Request $request)
310 {
311 $composite = TemplatePartBlockFinder::parseCompositeId(
312 $request->get_param('blockId')
313 );
314 if (!$composite) {
315 return new \WP_REST_Response(['scope' => 'block', 'pages' => []], 200);
316 }
317
318 $seen = [];
319 $pages = [];
320 $sitewide = self::collectRefUsage($composite['ref'], $seen, $pages);
321
322 return new \WP_REST_Response([
323 'scope' => $sitewide ? 'site' : ($pages ? 'pages' : 'block'),
324 'pages' => array_values($pages),
325 ], 200);
326 }
327
328 // A template or part renders it everywhere, so a page list only matters otherwise.
329 private static function collectRefUsage(int $ref, array &$seen, array &$pages): bool
330 {
331 if (isset($seen[$ref])) {
332 return false;
333 }
334 $seen[$ref] = true;
335
336 $sitewide = false;
337 foreach (self::postsReferencing($ref) as $row) {
338 $id = (int) $row->ID;
339 if (in_array($row->post_type, ['wp_template', 'wp_template_part'], true)) {
340 $sitewide = true;
341 continue;
342 }
343 // A pattern nested in a pattern inherits wherever the outer one lands.
344 if ($row->post_type === 'wp_block') {
345 $sitewide = self::collectRefUsage($id, $seen, $pages) || $sitewide;
346 continue;
347 }
348 $pages[$id] = ['id' => $id, 'title' => \get_the_title($id)];
349 }
350
351 return $sitewide;
352 }
353
354 // The ref is literal text inside a block comment, which WP indexes nowhere.
355 private static function postsReferencing(int $ref)
356 {
357 global $wpdb;
358
359 $like = $wpdb->esc_like('"ref":' . $ref);
360 return $wpdb->get_results($wpdb->prepare(
361 "SELECT ID, post_type FROM {$wpdb->posts}
362 WHERE post_status IN ('publish', 'private', 'draft')
363 AND post_type IN ('post', 'page', 'wp_block', 'wp_template', 'wp_template_part')
364 AND (post_content LIKE %s OR post_content LIKE %s)",
365 '%' . $like . '}%',
366 '%' . $like . ',%'
367 ));
368 }
369
370 /**
371 * Get the HTML of a specific tagged block code
372 *
373 * @param \WP_REST_Request $request The REST API request object.
374 * @return \WP_REST_Response
375 */
376 public static function getBlockCode(\WP_REST_Request $request)
377 {
378 $rawId = $request->get_param('blockId');
379 $partSlug = (string) $request->get_param('partSlug');
380
381 $composite = TemplatePartBlockFinder::parseCompositeId($rawId);
382 if ($composite) {
383 return self::getRefPostBlockCode(
384 $composite,
385 (string) $rawId,
386 $partSlug,
387 (int) $request->get_param('postId')
388 );
389 }
390
391 $blockId = (int) $rawId;
392 if ($blockId < 1) {
393 return new \WP_REST_Response(['error' => 'Invalid blockId'], 400);
394 }
395
396 // A template-part source resolves the part and walks it with the shared
397 // preorder finder, instead of the post path below.
398 if ($partSlug !== '') {
399 return self::getTemplatePartBlockCode($partSlug, $blockId);
400 }
401
402 $postId = (int) $request->get_param('postId');
403 $post = \get_post($postId);
404 if (!$post) {
405 return new \WP_REST_Response(['error' => 'Post not found'], 404);
406 }
407
408 $found = PostBlockFinder::find(parse_blocks($post->post_content), $blockId);
409
410 if (!$found || empty($found['block']['blockName'])) {
411 return new \WP_REST_Response(['error' => 'Block id not found in this post'], 404);
412 }
413
414 return self::blockCodeResponse($found['block'], ['postId' => $postId], $blockId);
415 }
416
417 // One response shape for every source, so the client reads the same fields
418 // whichever post the block turned out to live in.
419 private static function blockCodeResponse(array $block, array $scope, $blockId)
420 {
421 return new \WP_REST_Response(array_merge($scope, [
422 'blockId' => $blockId,
423 'name' => $block['blockName'],
424 'attrs' => $block['attrs'] ?? (object)[],
425 'block' => serialize_blocks([$block]),
426 ]), 200);
427 }
428
429 // Resolves the part the same way SaveController does — active-theme-scoped
430 // get_block_template, not a raw get_posts by name — then walks it with the
431 // shared preorder finder so the blockId lands on the block save will write.
432 private static function getTemplatePartBlockCode(string $slug, int $blockId)
433 {
434 $content = self::templatePartContent($slug);
435 if ($content === null) {
436 return new \WP_REST_Response(['error' => 'Template part not found'], 404);
437 }
438
439 $found = TemplatePartBlockFinder::find(
440 parse_blocks($content),
441 $blockId
442 );
443 if (!is_array($found) || empty($found['block']['blockName'])) {
444 return new \WP_REST_Response(['error' => 'Block id not found in this template part'], 404);
445 }
446
447 return self::blockCodeResponse($found['block'], ['partSlug' => $slug], $blockId);
448 }
449
450 // Post and theme file number identically, so an id survives the first edit.
451 private static function templatePartContent(string $slug)
452 {
453 $stylesheet = \wp_get_theme()->get_stylesheet();
454 $template = \get_block_template("{$stylesheet}//{$slug}", 'wp_template_part');
455 if (!$template) {
456 return null;
457 }
458 $post = empty($template->wp_id) ? null : \get_post($template->wp_id);
459 return $post ? $post->post_content : $template->content;
460 }
461
462 // A synced pattern's or menu's blocks live in the post its id names, walked
463 // with the same finder — the part they render inside owns none of them.
464 private static function getRefPostBlockCode(
465 array $composite,
466 string $blockId,
467 string $partSlug,
468 int $postId
469 ) {
470 $post = \get_post($composite['ref']);
471 if (!$post || $post->post_type !== $composite['postType']) {
472 return new \WP_REST_Response(['error' => 'Referenced post not found'], 404);
473 }
474
475 $containerPost = $partSlug === '' ? \get_post($postId) : null;
476 $container = $partSlug !== ''
477 ? self::templatePartContent($partSlug)
478 : ($containerPost ? $containerPost->post_content : null);
479 if ($container === null) {
480 return new \WP_REST_Response(['error' => 'Block id not found in this post'], 404);
481 }
482
483 // Unchecked, every pattern and menu on the site reads back from any page.
484 if (!TemplatePartBlockFinder::contentRendersRef($container, $composite['ref'])) {
485 return new \WP_REST_Response(['error' => 'Block id not found in this post'], 404);
486 }
487
488 $found = TemplatePartBlockFinder::find(
489 parse_blocks($post->post_content),
490 $composite['seq']
491 );
492 if (!is_array($found) || empty($found['block']['blockName'])) {
493 return new \WP_REST_Response(['error' => 'Block id not found in this referenced post'], 404);
494 }
495
496 return self::blockCodeResponse($found['block'], ['partSlug' => $partSlug], $blockId);
497 }
498
499 /**
500 * Get the rendered HTML of some block code
501 *
502 * @param \WP_REST_Request $request The REST API request object.
503 * @return \WP_REST_Response
504 */
505 public static function getBlockHtml($request)
506 {
507 $blockCode = $request->get_param('blockCode');
508 $content = \do_blocks($blockCode);
509
510 // Layout supports register per-container CSS for a page-side enqueue
511 // that never happens on a REST fragment — ship it with the markup.
512 $styles = function_exists('wp_style_engine_get_stylesheet_from_context')
513 ? \wp_style_engine_get_stylesheet_from_context('block-supports')
514 : '';
515
516 return new \WP_REST_Response(['content' => trim($content), 'styles' => $styles]);
517 }
518
519 /**
520 * Get the Hero Patterns from the API
521 *
522 * @param string $title The title to replace in the pattern code
523 * @param string $description The description to replace in the pattern code
524 * @param array $images The images to replace in the pattern code, as an array of urls
525 * @param array $cta The cta to replace in the pattern code, as an array with 'label' and 'link' keys
526 * @param bool $featuredOnly Whether to limit to featured patterns
527 * @param string|null $source What triggered the request, e.g. 'change-site-design-workflow'
528 * @return array|\WP_Error|array<string|int, mixed> The hero patterns data or a WP_Error on failure
529 */
530 protected static function getHeroPatternsData(
531 $title,
532 $description,
533 $images,
534 $cta,
535 $featuredOnly = false,
536 $source = null
537 ) {
538 $response = \wp_remote_post(
539 Constants::PATTERNS_HOST . '/api/heros',
540 [
541 'headers' => [
542 'Content-Type' => 'application/json',
543 'Accept' => 'application/json',
544 ],
545 'body' => wp_json_encode([
546 "wpVersion" => \get_bloginfo('version'),
547 "wpLanguage" => \get_locale(),
548 "featured" => $featuredOnly,
549 "source" => $source,
550 ])
551 ]
552 );
553
554 if (\is_wp_error($response)) {
555 return $response;
556 }
557
558 $body = json_decode(\wp_remote_retrieve_body($response), true);
559
560 $cursor = 0;
561 $imageCount = count($images);
562 $heroPatterns = [];
563
564 foreach ($body as $heroPattern) {
565 $code = $heroPattern['code'] ?? '';
566
567 if ($title) {
568 $code = preg_replace(
569 '/(<!-- wp:heading[^>]*-->[\s\S]*?<h1[^>]*>)[\s\S]*?(<\/h1>[\s\S]*?<!-- \/wp:heading -->)/m',
570 '${1}' . esc_html($title) . '${2}',
571 $code,
572 1
573 );
574 }
575
576 if ($description) {
577 $code = preg_replace(
578 '/(<!-- wp:paragraph[^>]*-->[\s\S]*?<p[^>]*>)[\s\S]*?(<\/p>[\s\S]*?<!-- \/wp:paragraph -->)/m',
579 '${1}' . esc_html($description) . '${2}',
580 $code,
581 1
582 );
583 }
584
585 if ($cta['label'] ?? null) {
586 $code = preg_replace(
587 '/(<!-- wp:button[^>]*-->[\s\S]*?<a[^>]*>)[\s\S]*?(<\/a>[\s\S]*?<!-- \/wp:button -->)/m',
588 '${1}' . esc_html($cta['label']) . '${2}',
589 $code,
590 1
591 );
592 }
593
594 if ($cta['link'] ?? null) {
595 $code = preg_replace(
596 '/(<!-- wp:button[\s\S]*?<a[^>]*\shref=")[^"]*(")/m',
597 '${1}' . esc_url($cta['link']) . '${2}',
598 $code,
599 1
600 );
601 }
602
603 $patternUrls = $heroPattern['urls'] ?? [];
604 foreach ($patternUrls as $key => $url) {
605 if (!$imageCount) {
606 break;
607 }
608 $code = str_replace($url, $images[($cursor + $key) % $imageCount], $code);
609 }
610 $cursor = $imageCount ? ($cursor + count($patternUrls)) % $imageCount : 0;
611
612 $renderedHtml = do_blocks(str_replace('ext-animate--on', '', $code));
613
614 $blockSupportsCss = function_exists('wp_style_engine_get_stylesheet_from_context')
615 ? wp_style_engine_get_stylesheet_from_context('block-supports')
616 : '';
617
618 // Clear block-supports store so CSS doesn't accumulate across patterns.
619 \WP_Style_Engine_CSS_Rules_Store::remove_all_stores();
620
621 $linkStyles = array_values(
622 array_filter(
623 array_map(
624 function ($style) {
625 return wp_styles()->registered[$style]->src ?? null;
626 },
627 wp_styles()->queue ?? []
628 )
629 )
630 );
631
632 /**
633 * Clear queue for the next pattern.
634 *
635 * `do_blocks` appends to the global queue the styles needed for the blocks.
636 */
637 wp_styles()->queue = [];
638
639 $heroPatterns[] = [
640 'id' => $heroPattern['id'],
641 'name' => $heroPattern['name'],
642 'code' => $code,
643 'renderedHtml' => $renderedHtml,
644 'blockSupportsCss' => $blockSupportsCss,
645 'linkStyles' => $linkStyles,
646 ];
647 }
648
649 return $heroPatterns;
650 }
651
652 /**
653 * Get the Hero Patterns
654 *
655 * @param \WP_REST_Request $request The REST API request object.
656 * @return \WP_REST_Response
657 */
658 public static function getHeroPatterns(\WP_REST_Request $request)
659 {
660 $title = $request->get_param('title');
661 $description = $request->get_param('description');
662 $images = $request->get_param('images');
663 $cta = $request->get_param('cta');
664
665 $heroPatterns = self::getHeroPatternsData($title, $description, $images, $cta);
666
667 if (\is_wp_error($heroPatterns)) {
668 return new \WP_REST_Response([], 500);
669 }
670
671 $blockEditorContext = new \WP_Block_Editor_Context(array( 'name' => 'core/edit-post' ));
672 $editorSettings = get_block_editor_settings([], $blockEditorContext);
673
674 return new \WP_REST_Response(['patterns' => $heroPatterns, 'blockEditorSettings' => $editorSettings ?? null]);
675 }
676
677 /**
678 * Build the image list for the hero section.
679 *
680 * Returns up to 6 images: unused site images first, already-used images last.
681 * When already at capacity, reverses the array so the last-used image leads next time.
682 */
683 protected static function getHeroSectionImages(array $images, array $siteImages, int $postId): array
684 {
685 $maxSlots = 6;
686
687 if (count($images) >= $maxSlots) {
688 return array_reverse($images);
689 }
690
691 $candidates = SiteImages::urls($siteImages);
692
693 if (!$postId || empty($candidates)) {
694 return $images;
695 }
696
697 $usedImages = self::resolveUsedImages($postId);
698 $candidates = array_map(function ($url) {
699 return self::stripQueryString($url);
700 }, $candidates);
701
702 $unusedImages = array_values(array_filter(
703 $candidates,
704 function ($url) use ($usedImages, $images) {
705 return !in_array($url, $usedImages, true) && !in_array($url, $images, true);
706 }
707 ));
708
709 $unusedSlots = $maxSlots - count($images);
710 return array_merge(array_slice($unusedImages, 0, $unusedSlots), $images);
711 }
712
713 protected static function stripQueryString(string $url): string
714 {
715 $parsed = wp_parse_url($url);
716 return ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? '') . ($parsed['path'] ?? '');
717 }
718
719 /**
720 * Get all Unsplash URLs used in a post.
721 */
722 protected static function resolveUsedImages(int $postId): array
723 {
724 $post = get_post($postId);
725 if (!$post) {
726 return [];
727 }
728
729 $processor = new \WP_HTML_Tag_Processor($post->post_content);
730 $usedImages = [];
731
732 while ($processor->next_tag('img')) {
733 $src = $processor->get_attribute('src');
734 if (!$src) {
735 continue;
736 }
737
738 $baseUrl = self::stripQueryString($src);
739
740 if (str_contains($src, 'unsplash.com')) {
741 $usedImages[] = $baseUrl;
742 continue;
743 }
744
745 $attachmentId = attachment_url_to_postid(esc_url($baseUrl));
746
747 if (!$attachmentId) {
748 continue;
749 }
750
751 $sourceUrl = get_post_meta($attachmentId, '_extendify_source_url', true);
752
753 if (!$sourceUrl) {
754 continue;
755 }
756
757 $usedImages[] = self::stripQueryString($sourceUrl);
758 }
759
760 return array_values(array_unique($usedImages));
761 }
762
763 public static function getSiteDesignVariations(\WP_REST_Request $request)
764 {
765 $title = $request->get_param('title');
766 $description = $request->get_param('description');
767 $images = $request->get_param('images') ?? [];
768 $cta = $request->get_param('cta');
769 $featuredOnly = true; // Only show featured patterns
770 $currentHeroPattern = $request->get_param('currentHeroPattern');
771 $postId = (int) $request->get_param('postId');
772 $siteImages = $request->get_param('siteImages') ?? [];
773 $source = $request->get_param('source');
774
775 $images = self::getHeroSectionImages($images, $siteImages, $postId);
776
777 $heroPatterns = self::getHeroPatternsData($title, $description, $images, $cta, $featuredOnly, $source);
778
779 if (\is_wp_error($heroPatterns)) {
780 return new \WP_REST_Response([], 500);
781 }
782
783 $heroPatterns = array_values(
784 array_filter(
785 $heroPatterns,
786 function ($heroPattern) use ($currentHeroPattern) {
787 if (!$currentHeroPattern) {
788 return true;
789 }
790
791 return $heroPattern['name'] !== $currentHeroPattern;
792 }
793 )
794 );
795
796 $current = \WP_Theme_JSON_Resolver::get_merged_data('theme');
797
798 $unfiltered = \WP_Theme_JSON_Resolver::get_style_variations();
799
800 // Keep only full style variations — exclude color-only and font-only
801 // presets that get_style_variations() returns from styles/colors/* and
802 // styles/typography/*.
803 $colorAndFontsVariations = array_filter($unfiltered, function ($variation) {
804 $hasPalette = ($variation['settings']['color']['palette'] ?? []) !== [];
805 $hasTypography = ($variation['styles']['typography'] ?? []) !== []
806 || ($variation['settings']['typography'] ?? []) !== [];
807 $hasElements = ($variation['styles']['elements'] ?? []) !== [];
808 return $hasPalette && $hasTypography && $hasElements;
809 });
810
811 $buildSlugMap = function ($unfiltered) {
812 $slugMap = [];
813
814 if (!is_array($unfiltered)) {
815 return $slugMap;
816 }
817
818 foreach ($unfiltered as $rawSlug => $rawVariation) {
819 $title = is_array($rawVariation) ? ($rawVariation['title'] ?? null) : null;
820 $slug = is_array($rawVariation)
821 ? ($rawVariation['slug'] ?? (is_string($rawSlug) ? $rawSlug : null))
822 : null;
823
824 if ($title && $slug && !isset($slugMap[$title])) {
825 $slugMap[$title] = $slug;
826 }
827 }
828 return $slugMap;
829 };
830 $slugMap = $buildSlugMap($unfiltered);
831 array_walk($colorAndFontsVariations, function (&$variation) use ($slugMap) {
832 if (!is_array($variation) || isset($variation['slug'])) {
833 return;
834 }
835
836 $title = $variation['title'] ?? null;
837 if ($title && isset($slugMap[$title])) {
838 $variation['slug'] = $slugMap[$title];
839 }
840 });
841
842 $processedFonts = array_map(function ($variation) {
843 if (!isset($variation['styles']['elements']) || !is_array($variation['styles']['elements'])) {
844 return $variation;
845 }
846
847 $variation['styles']['elements'] = array_map(
848 [self::class, 'normalizeElementTypography'],
849 $variation['styles']['elements']
850 );
851
852 if (!isset($variation['styles']['typography'])) {
853 $variation['styles']['typography'] = [
854 'fontFamily' => 'var(--wp--preset--font-family--inter)'
855 ];
856 }
857
858 return $variation;
859 }, $colorAndFontsVariations);
860
861 $deduped = static::getCss($processedFonts, $current, true);
862
863 $blockEditorContext = new \WP_Block_Editor_Context(array( 'name' => 'core/edit-post' ));
864 $editorSettings = get_block_editor_settings([], $blockEditorContext);
865 $editorSettings['styles'] = [];
866
867 return new \WP_REST_Response(
868 [
869 'patterns' => $heroPatterns,
870 'colorAndFontsVariations' => $deduped,
871 'blockEditorSettings' => $editorSettings ?? null,
872 ]
873 );
874 }
875
876 /**
877 * Sets a lock on a post to prevent concurrent editing.
878 *
879 * @param \WP_REST_Request $request The REST API request object containing postId.
880 * @return \WP_REST_Response Response indicating success of the lock operation.
881 */
882 public static function lockPost($request)
883 {
884 $postId = (int) $request->get_param('postId');
885 require_once ABSPATH . '/wp-admin/includes/post.php';
886 $data = \wp_set_post_lock($postId);
887 return new \WP_REST_Response(['success' => $data !== false]);
888 }
889
890 /**
891 * Persist the data
892 *
893 * @param \WP_REST_Request $request - The request.
894 * @return \WP_REST_Response
895 */
896 public static function updateOption($request)
897 {
898 $params = $request->get_json_params();
899 $key = $params['option'];
900 $sanitized = Sanitizer::sanitizeUnknown($params['value']);
901
902 if (strpos($key, 'extendify_') === 0) {
903 $key = substr($key, 10);
904 }
905 \update_option('extendify_' . $key, $sanitized);
906
907 return new \WP_REST_Response('OK');
908 }
909
910 /**
911 * Get the data
912 *
913 * @param \WP_REST_Request $request - The request.
914 * @return \WP_REST_Response
915 */
916 public static function getOption($request)
917 {
918 $key = $request->get_param('option');
919
920 if (strpos($key, 'extendify_') === 0) {
921 $key = substr($key, 10);
922 }
923 $value = \get_option('extendify_' . $key, null);
924
925 return new \WP_REST_Response($value);
926 }
927 }
928