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
← All changes | app/Agent/Controllers/WPController.php +371 -122 3.0.63.2.1 View file →
@@ -7,10 +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;
11 13 use Extendify\Constants;
14 +use Extendify\Shared\Services\HeroDescription;
12 15 use Extendify\Shared\Services\Sanitizer;
16 +use Extendify\Shared\Services\SiteImages;
13 17
14 18 /**
15 19 * The controller for interacting with WordPress.
16 20 */
@@ -50,8 +54,12 @@
50 54 'onyx',
51 55 'glasgow',
52 56 'royal',
53 57 'obsidian',
58 + 'heath',
59 + 'signal',
60 + 'marzipan',
61 + 'butterscotch',
54 62 ];
55 63
56 64 /**
57 65 * Recursively filter an array to include only specified properties.
@@ -283,9 +291,10 @@
283 291 'lineHeight' => $typography['lineHeight'] ?? null,
284 292 'letterSpacing' => $typography['letterSpacing'] ?? null,
285 293 'fontStyle' => $typography['fontStyle'] ?? null,
286 294 'fontWeight' => $typography['fontWeight'] ?? null,
287 - 'textTransform' => $typography['textTransform'] ?? 'none',
295 + // Defaulting to 'none' would cancel the applied vibe's uppercase.
296 + 'textTransform' => $typography['textTransform'] ?? null,
288 297 ], function ($v) {
289 298 return $v !== null;
290 299 })
291 300 ];
@@ -292,8 +301,75 @@
292 301 }
293 302
294 303
295 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 + /**
296 372 * Get the HTML of a specific tagged block code
297 373 *
298 374 * @param \WP_REST_Request $request The REST API request object.
299 375 * @return \WP_REST_Response
@@ -299,69 +375,127 @@
299 375 * @return \WP_REST_Response
300 376 */
301 377 public static function getBlockCode(\WP_REST_Request $request)
302 378 {
303 - $blockId = (int) $request->get_param('blockId');
304 - $postId = (int) $request->get_param('postId');
379 + $rawId = $request->get_param('blockId');
380 + $partSlug = (string) $request->get_param('partSlug');
305 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;
306 393 if ($blockId < 1) {
307 394 return new \WP_REST_Response(['error' => 'Invalid blockId'], 400);
308 395 }
309 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');
310 404 $post = \get_post($postId);
311 405 if (!$post) {
312 406 return new \WP_REST_Response(['error' => 'Post not found'], 404);
313 407 }
314 408
315 - $ignored = \Extendify\Agent\TagBlocks::$ignored;
409 + $found = PostBlockFinder::find(parse_blocks($post->post_content), $blockId);
316 410
317 - $ast = array_values(array_filter(
318 - parse_blocks($post->post_content),
319 - static function ($b) {
320 - return is_array($b) && !empty($b['blockName']);
321 - }
322 - ));
411 + if (!$found || empty($found['block']['blockName'])) {
412 + return new \WP_REST_Response(['error' => 'Block id not found in this post'], 404);
413 + }
323 414
324 - $seq = 0;
325 - $found = null;
415 + return self::blockCodeResponse($found['block'], ['postId' => $postId], $blockId);
416 + }
326 417
327 - $walk = function (array $list) use (&$walk, &$seq, $blockId, &$found, $ignored) {
328 - foreach ($list as $b) {
329 - $name = $b['blockName'] ?? null;
330 - if (!$name) {
331 - continue;
332 - }
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 + }
333 429
334 - // Ignore this block and its subtree (matches tagger behavior)
335 - if (in_array($name, $ignored, true)) {
336 - continue; // do NOT increment seq, do NOT traverse children
337 - }
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 + }
338 439
339 - $seq++;
340 - if ($seq === $blockId) {
341 - $found = $b;
342 - return true;
343 - }
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 + }
344 447
345 - if (!empty($b['innerBlocks']) && $walk($b['innerBlocks'])) {
346 - return true;
347 - }
348 - }
349 - return false;
350 - };
351 - $walk($ast);
448 + return self::blockCodeResponse($found['block'], ['partSlug' => $slug], $blockId);
449 + }
352 450
353 - 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) {
354 481 return new \WP_REST_Response(['error' => 'Block id not found in this post'], 404);
355 482 }
356 483
357 - return new \WP_REST_Response([
358 - 'postId' => $postId,
359 - 'blockId' => $blockId,
360 - 'name' => $found['blockName'],
361 - 'attrs' => $found['attrs'] ?? (object)[],
362 - 'block' => serialize_blocks([$found]),
363 - ], 200);
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);
364 498 }
365 499
366 500 /**
367 501 * Get the rendered HTML of some block code
@@ -373,9 +507,15 @@
373 507 {
374 508 $blockCode = $request->get_param('blockCode');
375 509 $content = \do_blocks($blockCode);
376 510
377 - 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]);
378 518 }
379 519
380 520 /**
381 521 * Get the Hero Patterns from the API
@@ -383,13 +523,20 @@
383 523 * @param string $title The title to replace in the pattern code
384 524 * @param string $description The description to replace in the pattern code
385 525 * @param array $images The images to replace in the pattern code, as an array of urls
386 526 * @param array $cta The cta to replace in the pattern code, as an array with 'label' and 'link' keys
387 - * @param bool $featured Whether to limit to featured patterns
388 - * @return array|WP_Error|array<string|int, mixed> The hero patterns data or a WP_Error on failure
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
389 530 */
390 - protected static function getHeroPatternsData($title, $description, $images, $cta, $featuredOnly = false)
391 - {
531 + protected static function getHeroPatternsData(
532 + $title,
533 + $description,
534 + $images,
535 + $cta,
536 + $featuredOnly = false,
537 + $source = null
538 + ) {
392 539 $response = \wp_remote_post(
393 540 Constants::PATTERNS_HOST . '/api/heros',
394 541 [
395 542 'headers' => [
@@ -399,8 +546,9 @@
399 546 'body' => wp_json_encode([
400 547 "wpVersion" => \get_bloginfo('version'),
401 548 "wpLanguage" => \get_locale(),
402 549 "featured" => $featuredOnly,
550 + "source" => $source,
403 551 ])
404 552 ]
405 553 );
406 554
@@ -409,94 +557,104 @@
409 557 }
410 558
411 559 $body = json_decode(\wp_remote_retrieve_body($response), true);
412 560
413 - $heroPatterns = array_map(
414 - function ($heroPattern) use ($description, $title, $images, $cta) {
415 - $code = $heroPattern['code'] ?? '';
561 + $cursor = 0;
562 + $imageCount = count($images);
563 + $heroPatterns = [];
416 564
417 - if ($title) {
418 - $code = preg_replace(
419 - '/(<!-- wp:heading[^>]*-->[\s\S]*?<h1[^>]*>)[\s\S]*?(<\/h1>[\s\S]*?<!-- \/wp:heading -->)/m',
420 - '${1}' . esc_html($title) . '${2}',
421 - $code,
422 - 1
423 - );
424 - }
565 + foreach ($body as $heroPattern) {
566 + $code = $heroPattern['code'] ?? '';
425 567
426 - if ($description) {
427 - $code = preg_replace(
428 - '/(<!-- wp:paragraph[^>]*-->[\s\S]*?<p[^>]*>)[\s\S]*?(<\/p>[\s\S]*?<!-- \/wp:paragraph -->)/m',
429 - '${1}' . esc_html($description) . '${2}',
430 - $code,
431 - 1
432 - );
433 - }
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 + }
434 576
435 - if ($cta['label'] ?? null) {
436 - $code = preg_replace(
437 - '/(<!-- wp:button[^>]*-->[\s\S]*?<a[^>]*>)[\s\S]*?(<\/a>[\s\S]*?<!-- \/wp:button -->)/m',
438 - '${1}' . esc_html($cta['label']) . '${2}',
439 - $code,
440 - 1
441 - );
442 - }
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 + }
443 593
444 - if ($cta['link'] ?? null) {
445 - $code = preg_replace(
446 - '/(<!-- wp:button[\s\S]*?<a[^>]*\shref=")[^"]*(")/m',
447 - '${1}' . esc_url($cta['link']) . '${2}',
448 - $code,
449 - 1
450 - );
451 - }
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 + }
452 602
453 - foreach ($heroPattern['urls'] ?? [] as $key => $url) {
454 - if (!($images[$key] ?? null)) {
455 - break;
456 - }
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 + }
457 611
458 - $code = str_replace($url, $images[$key], $code);
612 + $patternUrls = $heroPattern['urls'] ?? [];
613 + foreach ($patternUrls as $key => $url) {
614 + if (!$imageCount) {
615 + break;
459 616 }
617 + $code = str_replace($url, $images[($cursor + $key) % $imageCount], $code);
618 + }
619 + $cursor = $imageCount ? ($cursor + count($patternUrls)) % $imageCount : 0;
460 620
461 - $renderedHtml = do_blocks(str_replace('ext-animate--on', '', $code));
621 + $renderedHtml = do_blocks(str_replace('ext-animate--on', '', $code));
462 622
463 - $blockSupportsCss = function_exists('wp_style_engine_get_stylesheet_from_context')
464 - ? wp_style_engine_get_stylesheet_from_context('block-supports')
465 - : '';
623 + $blockSupportsCss = function_exists('wp_style_engine_get_stylesheet_from_context')
624 + ? wp_style_engine_get_stylesheet_from_context('block-supports')
625 + : '';
466 626
467 - // Clear block-supports store so CSS doesn't accumulate across patterns.
468 - \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();
469 629
470 - $linkStyles = array_values(
471 - array_filter(
472 - array_map(
473 - function ($style) {
474 - return wp_styles()->registered[$style]->src ?? null;
475 - },
476 - wp_styles()->queue ?? []
477 - )
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 ?? []
478 637 )
479 - );
638 + )
639 + );
480 640
481 - /**
482 - * Clear queue for the next pattern.
483 - *
484 - * `do_blocks` appends to the global queue the styles needed for the blocks.
485 - */
486 - wp_styles()->queue = [];
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 = [];
487 647
488 - return [
489 - 'id' => $heroPattern['id'],
490 - 'name' => $heroPattern['name'],
491 - 'code' => $code,
492 - 'renderedHtml' => $renderedHtml,
493 - 'blockSupportsCss' => $blockSupportsCss,
494 - 'linkStyles' => $linkStyles,
495 - ];
496 - },
497 - $body
498 - );
648 + $heroPatterns[] = [
649 + 'id' => $heroPattern['id'],
650 + 'name' => $heroPattern['name'],
651 + 'code' => $code,
652 + 'renderedHtml' => $renderedHtml,
653 + 'blockSupportsCss' => $blockSupportsCss,
654 + 'linkStyles' => $linkStyles,
655 + ];
656 + }
499 657
500 658 return $heroPatterns;
501 659 }
502 660
@@ -524,18 +682,109 @@
524 682
525 683 return new \WP_REST_Response(['patterns' => $heroPatterns, 'blockEditorSettings' => $editorSettings ?? null]);
526 684 }
527 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 +
528 772 public static function getSiteDesignVariations(\WP_REST_Request $request)
529 773 {
530 774 $title = $request->get_param('title');
531 - $description = $request->get_param('description');
532 - $images = $request->get_param('images');
775 + $description = HeroDescription::resolve($request->get_param('description'));
776 + $images = $request->get_param('images') ?? [];
533 777 $cta = $request->get_param('cta');
534 778 $featuredOnly = true; // Only show featured patterns
535 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');
536 783
537 - $heroPatterns = self::getHeroPatternsData($title, $description, $images, $cta, $featuredOnly);
784 + $images = self::getHeroSectionImages($images, $siteImages, $postId);
785 +
786 + $heroPatterns = self::getHeroPatternsData($title, $description, $images, $cta, $featuredOnly, $source);
538 787
539 788 if (\is_wp_error($heroPatterns)) {
540 789 return new \WP_REST_Response([], 500);
541 790 }