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 +182 -73 3.1.33.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,10 +375,22 @@
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');
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
@@ -307,9 +395,8 @@
307 395 }
308 396
309 397 // A template-part source resolves the part and walks it with the shared
310 398 // preorder finder, instead of the post path below.
311 - $partSlug = (string) $request->get_param('partSlug');
312 399 if ($partSlug !== '') {
313 400 return self::getTemplatePartBlockCode($partSlug, $blockId);
314 401 }
315 402
@@ -318,57 +405,27 @@
318 405 if (!$post) {
319 406 return new \WP_REST_Response(['error' => 'Post not found'], 404);
320 407 }
321 408
322 - $ignored = \Extendify\Agent\TagBlocks::$ignored;
409 + $found = PostBlockFinder::find(parse_blocks($post->post_content), $blockId);
323 410
324 - $ast = array_values(array_filter(
325 - parse_blocks($post->post_content),
326 - static function ($b) {
327 - return is_array($b) && !empty($b['blockName']);
328 - }
329 - ));
330 -
331 - $seq = 0;
332 - $found = null;
333 -
334 - $walk = function (array $list) use (&$walk, &$seq, $blockId, &$found, $ignored) {
335 - foreach ($list as $b) {
336 - $name = $b['blockName'] ?? null;
337 - if (!$name) {
338 - continue;
339 - }
340 -
341 - // Ignore this block and its subtree (matches tagger behavior)
342 - if (in_array($name, $ignored, true)) {
343 - continue; // do NOT increment seq, do NOT traverse children
344 - }
345 -
346 - $seq++;
347 - if ($seq === $blockId) {
348 - $found = $b;
349 - return true;
350 - }
351 -
352 - if (!empty($b['innerBlocks']) && $walk($b['innerBlocks'])) {
353 - return true;
354 - }
355 - }
356 - return false;
357 - };
358 - $walk($ast);
359 -
360 - if (!is_array($found) || empty($found['blockName'])) {
411 + if (!$found || empty($found['block']['blockName'])) {
361 412 return new \WP_REST_Response(['error' => 'Block id not found in this post'], 404);
362 413 }
363 414
364 - return new \WP_REST_Response([
365 - 'postId' => $postId,
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, [
366 423 'blockId' => $blockId,
367 - 'name' => $found['blockName'],
368 - 'attrs' => $found['attrs'] ?? (object)[],
369 - 'block' => serialize_blocks([$found]),
370 - ], 200);
424 + 'name' => $block['blockName'],
425 + 'attrs' => $block['attrs'] ?? (object)[],
426 + 'block' => serialize_blocks([$block]),
427 + ]), 200);
371 428 }
372 429
373 430 // Resolves the part the same way SaveController does — active-theme-scoped
374 431 // get_block_template, not a raw get_posts by name — then walks it with the
@@ -374,35 +431,71 @@
374 431 // get_block_template, not a raw get_posts by name — then walks it with the
375 432 // shared preorder finder so the blockId lands on the block save will write.
376 433 private static function getTemplatePartBlockCode(string $slug, int $blockId)
377 434 {
378 - $stylesheet = wp_get_theme()->get_stylesheet();
379 - $template = get_block_template("{$stylesheet}//{$slug}", 'wp_template_part');
380 - if (!$template || empty($template->wp_id)) {
435 + $content = self::templatePartContent($slug);
436 + if ($content === null) {
381 437 return new \WP_REST_Response(['error' => 'Template part not found'], 404);
382 438 }
383 439
384 - $post = \get_post($template->wp_id);
385 - if (!$post) {
386 - return new \WP_REST_Response(['error' => 'Template part not found'], 404);
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);
387 446 }
388 447
389 - $found = \Extendify\Agent\TemplatePartBlockFinder::find(
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(
390 490 parse_blocks($post->post_content),
391 - $blockId
491 + $composite['seq']
392 492 );
393 493 if (!is_array($found) || empty($found['block']['blockName'])) {
394 - return new \WP_REST_Response(['error' => 'Block id not found in this template part'], 404);
494 + return new \WP_REST_Response(['error' => 'Block id not found in this referenced post'], 404);
395 495 }
396 496
397 - $block = $found['block'];
398 - return new \WP_REST_Response([
399 - 'partSlug' => $slug,
400 - 'blockId' => $blockId,
401 - 'name' => $block['blockName'],
402 - 'attrs' => $block['attrs'] ?? (object)[],
403 - 'block' => serialize_blocks([$block]),
404 - ], 200);
497 + return self::blockCodeResponse($found['block'], ['partSlug' => $partSlug], $blockId);
405 498 }
406 499
407 500 /**
408 501 * Get the rendered HTML of some block code
@@ -414,9 +507,15 @@
414 507 {
415 508 $blockCode = $request->get_param('blockCode');
416 509 $content = \do_blocks($blockCode);
417 510
418 - 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]);
419 518 }
420 519
421 520 /**
422 521 * Get the Hero Patterns from the API
@@ -474,10 +573,18 @@
474 573 1
475 574 );
476 575 }
477 576
478 - if ($description) {
577 + if (trim((string) $description) === '') {
578 + // Catalog paragraphs describe the slot, not the site.
479 579 $code = preg_replace(
580 + '/<!-- wp:paragraph[\s\S]*?<!-- \/wp:paragraph -->/m',
581 + '',
582 + $code,
583 + 1
584 + );
585 + } else {
586 + $code = preg_replace(
480 587 '/(<!-- wp:paragraph[^>]*-->[\s\S]*?<p[^>]*>)[\s\S]*?(<\/p>[\s\S]*?<!-- \/wp:paragraph -->)/m',
481 588 '${1}' . esc_html($description) . '${2}',
482 589 $code,
483 590 1
@@ -589,19 +696,21 @@
589 696 if (count($images) >= $maxSlots) {
590 697 return array_reverse($images);
591 698 }
592 699
593 - if (!$postId || empty($siteImages)) {
700 + $candidates = SiteImages::urls($siteImages);
701 +
702 + if (!$postId || empty($candidates)) {
594 703 return $images;
595 704 }
596 705
597 706 $usedImages = self::resolveUsedImages($postId);
598 - $siteImages = array_map(function ($url) {
707 + $candidates = array_map(function ($url) {
599 708 return self::stripQueryString($url);
600 - }, $siteImages);
709 + }, $candidates);
601 710
602 - $unusedSiteImages = array_values(array_filter(
603 - $siteImages,
711 + $unusedImages = array_values(array_filter(
712 + $candidates,
604 713 function ($url) use ($usedImages, $images) {
605 714 return !in_array($url, $usedImages, true) && !in_array($url, $images, true);
606 715 }
607 716 ));
@@ -606,9 +715,9 @@
606 715 }
607 716 ));
608 717
609 718 $unusedSlots = $maxSlots - count($images);
610 - return array_merge(array_slice($unusedSiteImages, 0, $unusedSlots), $images);
719 + return array_merge(array_slice($unusedImages, 0, $unusedSlots), $images);
611 720 }
612 721
613 722 protected static function stripQueryString(string $url): string
614 723 {
@@ -662,9 +771,9 @@
662 771
663 772 public static function getSiteDesignVariations(\WP_REST_Request $request)
664 773 {
665 774 $title = $request->get_param('title');
666 - $description = $request->get_param('description');
775 + $description = HeroDescription::resolve($request->get_param('description'));
667 776 $images = $request->get_param('images') ?? [];
668 777 $cta = $request->get_param('cta');
669 778 $featuredOnly = true; // Only show featured patterns
670 779 $currentHeroPattern = $request->get_param('currentHeroPattern');