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 +182 -73 3.1.1 → 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,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');