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 +371 -122 3.0.6 → trunk 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 }