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 +171 -33 3.1.4 → 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,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,22 +405,27 @@
318 405 if (!$post) {
319 406 return new \WP_REST_Response(['error' => 'Post not found'], 404);
320 407 }
321 408
322 - $found = \Extendify\Agent\PostBlockFinder::find(parse_blocks($post->post_content), $blockId);
409 + $found = PostBlockFinder::find(parse_blocks($post->post_content), $blockId);
323 410
324 411 if (!$found || empty($found['block']['blockName'])) {
325 412 return new \WP_REST_Response(['error' => 'Block id not found in this post'], 404);
326 413 }
327 414
328 - $block = $found['block'];
329 - return new \WP_REST_Response([
330 - '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, [
331 423 'blockId' => $blockId,
332 424 'name' => $block['blockName'],
333 425 'attrs' => $block['attrs'] ?? (object)[],
334 426 'block' => serialize_blocks([$block]),
335 - ], 200);
427 + ]), 200);
336 428 }
337 429
338 430 // Resolves the part the same way SaveController does — active-theme-scoped
339 431 // get_block_template, not a raw get_posts by name — then walks it with the
@@ -339,35 +431,71 @@
339 431 // get_block_template, not a raw get_posts by name — then walks it with the
340 432 // shared preorder finder so the blockId lands on the block save will write.
341 433 private static function getTemplatePartBlockCode(string $slug, int $blockId)
342 434 {
343 - $stylesheet = wp_get_theme()->get_stylesheet();
344 - $template = get_block_template("{$stylesheet}//{$slug}", 'wp_template_part');
345 - if (!$template || empty($template->wp_id)) {
435 + $content = self::templatePartContent($slug);
436 + if ($content === null) {
346 437 return new \WP_REST_Response(['error' => 'Template part not found'], 404);
347 438 }
348 439
349 - $post = \get_post($template->wp_id);
350 - if (!$post) {
351 - 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);
352 446 }
353 447
354 - $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(
355 490 parse_blocks($post->post_content),
356 - $blockId
491 + $composite['seq']
357 492 );
358 493 if (!is_array($found) || empty($found['block']['blockName'])) {
359 - 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);
360 495 }
361 496
362 - $block = $found['block'];
363 - return new \WP_REST_Response([
364 - 'partSlug' => $slug,
365 - 'blockId' => $blockId,
366 - 'name' => $block['blockName'],
367 - 'attrs' => $block['attrs'] ?? (object)[],
368 - 'block' => serialize_blocks([$block]),
369 - ], 200);
497 + return self::blockCodeResponse($found['block'], ['partSlug' => $partSlug], $blockId);
370 498 }
371 499
372 500 /**
373 501 * Get the rendered HTML of some block code
@@ -445,10 +573,18 @@
445 573 1
446 574 );
447 575 }
448 576
449 - if ($description) {
577 + if (trim((string) $description) === '') {
578 + // Catalog paragraphs describe the slot, not the site.
450 579 $code = preg_replace(
580 + '/<!-- wp:paragraph[\s\S]*?<!-- \/wp:paragraph -->/m',
581 + '',
582 + $code,
583 + 1
584 + );
585 + } else {
586 + $code = preg_replace(
451 587 '/(<!-- wp:paragraph[^>]*-->[\s\S]*?<p[^>]*>)[\s\S]*?(<\/p>[\s\S]*?<!-- \/wp:paragraph -->)/m',
452 588 '${1}' . esc_html($description) . '${2}',
453 589 $code,
454 590 1
@@ -560,19 +696,21 @@
560 696 if (count($images) >= $maxSlots) {
561 697 return array_reverse($images);
562 698 }
563 699
564 - if (!$postId || empty($siteImages)) {
700 + $candidates = SiteImages::urls($siteImages);
701 +
702 + if (!$postId || empty($candidates)) {
565 703 return $images;
566 704 }
567 705
568 706 $usedImages = self::resolveUsedImages($postId);
569 - $siteImages = array_map(function ($url) {
707 + $candidates = array_map(function ($url) {
570 708 return self::stripQueryString($url);
571 - }, $siteImages);
709 + }, $candidates);
572 710
573 - $unusedSiteImages = array_values(array_filter(
574 - $siteImages,
711 + $unusedImages = array_values(array_filter(
712 + $candidates,
575 713 function ($url) use ($usedImages, $images) {
576 714 return !in_array($url, $usedImages, true) && !in_array($url, $images, true);
577 715 }
578 716 ));
@@ -577,9 +715,9 @@
577 715 }
578 716 ));
579 717
580 718 $unusedSlots = $maxSlots - count($images);
581 - return array_merge(array_slice($unusedSiteImages, 0, $unusedSlots), $images);
719 + return array_merge(array_slice($unusedImages, 0, $unusedSlots), $images);
582 720 }
583 721
584 722 protected static function stripQueryString(string $url): string
585 723 {
@@ -633,9 +771,9 @@
633 771
634 772 public static function getSiteDesignVariations(\WP_REST_Request $request)
635 773 {
636 774 $title = $request->get_param('title');
637 - $description = $request->get_param('description');
775 + $description = HeroDescription::resolve($request->get_param('description'));
638 776 $images = $request->get_param('images') ?? [];
639 777 $cta = $request->get_param('cta');
640 778 $featuredOnly = true; // Only show featured patterns
641 779 $currentHeroPattern = $request->get_param('currentHeroPattern');