PluginProbe
Extendify / trunk
Extendify vtrunk
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
← All changes | app/Agent/Controllers/WPController.php +381 -124 3.0.5 → trunk View file →
@@ -7,9 +7,14 @@
7 7 namespace Extendify\Agent\Controllers;
8 8
9 9 defined('ABSPATH') || die('No direct access.');
10 10
11 +use Extendify\Agent\PostBlockFinder;
12 +use Extendify\Agent\TemplatePartBlockFinder;
13 +use Extendify\Constants;
14 +use Extendify\Shared\Services\HeroDescription;
11 15 use Extendify\Shared\Services\Sanitizer;
16 +use Extendify\Shared\Services\SiteImages;
12 17
13 18 /**
14 19 * The controller for interacting with WordPress.
15 20 */
@@ -49,8 +54,12 @@
49 54 'onyx',
50 55 'glasgow',
51 56 'royal',
52 57 'obsidian',
58 + 'heath',
59 + 'signal',
60 + 'marzipan',
61 + 'butterscotch',
53 62 ];
54 63
55 64 /**
56 65 * Recursively filter an array to include only specified properties.
@@ -282,9 +291,10 @@
282 291 'lineHeight' => $typography['lineHeight'] ?? null,
283 292 'letterSpacing' => $typography['letterSpacing'] ?? null,
284 293 'fontStyle' => $typography['fontStyle'] ?? null,
285 294 'fontWeight' => $typography['fontWeight'] ?? null,
286 - 'textTransform' => $typography['textTransform'] ?? 'none',
295 + // Defaulting to 'none' would cancel the applied vibe's uppercase.
296 + 'textTransform' => $typography['textTransform'] ?? null,
287 297 ], function ($v) {
288 298 return $v !== null;
289 299 })
290 300 ];
@@ -291,8 +301,75 @@
291 301 }
292 302
293 303
294 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 + /**
295 372 * Get the HTML of a specific tagged block code
296 373 *
297 374 * @param \WP_REST_Request $request The REST API request object.
298 375 * @return \WP_REST_Response
@@ -298,69 +375,127 @@
298 375 * @return \WP_REST_Response
299 376 */
300 377 public static function getBlockCode(\WP_REST_Request $request)
301 378 {
302 - $blockId = (int) $request->get_param('blockId');
303 - $postId = (int) $request->get_param('postId');
379 + $rawId = $request->get_param('blockId');
380 + $partSlug = (string) $request->get_param('partSlug');
304 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;
305 393 if ($blockId < 1) {
306 394 return new \WP_REST_Response(['error' => 'Invalid blockId'], 400);
307 395 }
308 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');
309 404 $post = \get_post($postId);
310 405 if (!$post) {
311 406 return new \WP_REST_Response(['error' => 'Post not found'], 404);
312 407 }
313 408
314 - $ignored = ['core/query', 'core/post-template', 'core/post-content'];
409 + $found = PostBlockFinder::find(parse_blocks($post->post_content), $blockId);
315 410
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 - ));
411 + if (!$found || empty($found['block']['blockName'])) {
412 + return new \WP_REST_Response(['error' => 'Block id not found in this post'], 404);
413 + }
322 414
323 - $seq = 0;
324 - $found = null;
415 + return self::blockCodeResponse($found['block'], ['postId' => $postId], $blockId);
416 + }
325 417
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 - }
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 + }
332 429
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 - }
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 + }
337 439
338 - $seq++;
339 - if ($seq === $blockId) {
340 - $found = $b;
341 - return true;
342 - }
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 + }
343 447
344 - if (!empty($b['innerBlocks']) && $walk($b['innerBlocks'])) {
345 - return true;
346 - }
347 - }
348 - return false;
349 - };
350 - $walk($ast);
448 + return self::blockCodeResponse($found['block'], ['partSlug' => $slug], $blockId);
449 + }
351 450
352 - if (!is_array($found) || empty($found['blockName'])) {
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) {
353 481 return new \WP_REST_Response(['error' => 'Block id not found in this post'], 404);
354 482 }
355 483
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);
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);
363 498 }
364 499
365 500 /**
366 501 * Get the rendered HTML of some block code
@@ -372,9 +507,15 @@
372 507 {
373 508 $blockCode = $request->get_param('blockCode');
374 509 $content = \do_blocks($blockCode);
375 510
376 - return new \WP_REST_Response(['content' => trim($content)]);
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]);
377 518 }
378 519
379 520 /**
380 521 * Get the Hero Patterns from the API
@@ -382,15 +523,22 @@
382 523 * @param string $title The title to replace in the pattern code
383 524 * @param string $description The description to replace in the pattern code
384 525 * @param array $images The images to replace in the pattern code, as an array of urls
385 526 * @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
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
388 530 */
389 - protected static function getHeroPatternsData($title, $description, $images, $cta, $featuredOnly = false)
390 - {
531 + protected static function getHeroPatternsData(
532 + $title,
533 + $description,
534 + $images,
535 + $cta,
536 + $featuredOnly = false,
537 + $source = null
538 + ) {
391 539 $response = \wp_remote_post(
392 - 'https://patterns.extendify.com/api/heros',
540 + Constants::PATTERNS_HOST . '/api/heros',
393 541 [
394 542 'headers' => [
395 543 'Content-Type' => 'application/json',
396 544 'Accept' => 'application/json',
@@ -398,8 +546,9 @@
398 546 'body' => wp_json_encode([
399 547 "wpVersion" => \get_bloginfo('version'),
400 548 "wpLanguage" => \get_locale(),
401 549 "featured" => $featuredOnly,
550 + "source" => $source,
402 551 ])
403 552 ]
404 553 );
405 554
@@ -408,94 +557,104 @@
408 557 }
409 558
410 559 $body = json_decode(\wp_remote_retrieve_body($response), true);
411 560
412 - $heroPatterns = array_map(
413 - function ($heroPattern) use ($description, $title, $images, $cta) {
414 - $code = $heroPattern['code'] ?? '';
561 + $cursor = 0;
562 + $imageCount = count($images);
563 + $heroPatterns = [];
415 564
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 - }
565 + foreach ($body as $heroPattern) {
566 + $code = $heroPattern['code'] ?? '';
424 567
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 - }
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 + }
433 576
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 - }
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 + }
442 593
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 - }
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 + }
451 602
452 - foreach ($heroPattern['urls'] ?? [] as $key => $url) {
453 - if (!($images[$key] ?? null)) {
454 - break;
455 - }
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 + }
456 611
457 - $code = str_replace($url, $images[$key], $code);
612 + $patternUrls = $heroPattern['urls'] ?? [];
613 + foreach ($patternUrls as $key => $url) {
614 + if (!$imageCount) {
615 + break;
458 616 }
617 + $code = str_replace($url, $images[($cursor + $key) % $imageCount], $code);
618 + }
619 + $cursor = $imageCount ? ($cursor + count($patternUrls)) % $imageCount : 0;
459 620
460 - $renderedHtml = do_blocks(str_replace('ext-animate--on', '', $code));
621 + $renderedHtml = do_blocks(str_replace('ext-animate--on', '', $code));
461 622
462 - $blockSupportsCss = function_exists('wp_style_engine_get_stylesheet_from_context')
463 - ? wp_style_engine_get_stylesheet_from_context('block-supports')
464 - : '';
623 + $blockSupportsCss = function_exists('wp_style_engine_get_stylesheet_from_context')
624 + ? wp_style_engine_get_stylesheet_from_context('block-supports')
625 + : '';
465 626
466 - // Clear block-supports store so CSS doesn't accumulate across patterns.
467 - \WP_Style_Engine_CSS_Rules_Store::remove_all_stores();
627 + // Clear block-supports store so CSS doesn't accumulate across patterns.
628 + \WP_Style_Engine_CSS_Rules_Store::remove_all_stores();
468 629
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 - )
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 ?? []
477 637 )
478 - );
638 + )
639 + );
479 640
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 = [];
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 = [];
486 647
487 - return [
488 - 'id' => $heroPattern['id'],
489 - 'name' => $heroPattern['name'],
490 - 'code' => $code,
491 - 'renderedHtml' => $renderedHtml,
492 - 'blockSupportsCss' => $blockSupportsCss,
493 - 'linkStyles' => $linkStyles,
494 - ];
495 - },
496 - $body
497 - );
648 + $heroPatterns[] = [
649 + 'id' => $heroPattern['id'],
650 + 'name' => $heroPattern['name'],
651 + 'code' => $code,
652 + 'renderedHtml' => $renderedHtml,
653 + 'blockSupportsCss' => $blockSupportsCss,
654 + 'linkStyles' => $linkStyles,
655 + ];
656 + }
498 657
499 658 return $heroPatterns;
500 659 }
501 660
@@ -523,19 +682,110 @@
523 682
524 683 return new \WP_REST_Response(['patterns' => $heroPatterns, 'blockEditorSettings' => $editorSettings ?? null]);
525 684 }
526 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 +
527 772 public static function getSiteDesignVariations(\WP_REST_Request $request)
528 773 {
529 774 $title = $request->get_param('title');
530 - $description = $request->get_param('description');
531 - $images = $request->get_param('images');
775 + $description = HeroDescription::resolve($request->get_param('description'));
776 + $images = $request->get_param('images') ?? [];
532 777 $cta = $request->get_param('cta');
533 778 $featuredOnly = true; // Only show featured patterns
534 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');
535 783
536 - $heroPatterns = self::getHeroPatternsData($title, $description, $images, $cta, $featuredOnly);
784 + $images = self::getHeroSectionImages($images, $siteImages, $postId);
537 785
786 + $heroPatterns = self::getHeroPatternsData($title, $description, $images, $cta, $featuredOnly, $source);
787 +
538 788 if (\is_wp_error($heroPatterns)) {
539 789 return new \WP_REST_Response([], 500);
540 790 }
541 791
@@ -555,10 +805,17 @@
555 805 $current = \WP_Theme_JSON_Resolver::get_merged_data('theme');
556 806
557 807 $unfiltered = \WP_Theme_JSON_Resolver::get_style_variations();
558 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/*.
559 812 $colorAndFontsVariations = array_filter($unfiltered, function ($variation) {
560 - return self::variationHasProperties($variation, ['color', 'elements', 'typography']);
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;
561 818 });
562 819
563 820 $buildSlugMap = function ($unfiltered) {
564 821 $slugMap = [];