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

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