| @@ -7,9 +7,12 @@ | ||
| 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; |
| 13 | 16 | use Extendify\Shared\Services\SiteImages; |
| 14 | 17 | |
| 15 | 18 | /** |
| @@ -51,8 +54,12 @@ | ||
| 51 | 54 | 'onyx', |
| 52 | 55 | 'glasgow', |
| 53 | 56 | 'royal', |
| 54 | 57 | 'obsidian', |
| 58 | + 'heath', | |
| 59 | + 'signal', | |
| 60 | + 'marzipan', | |
| 61 | + 'butterscotch', | |
| 55 | 62 | ]; |
| 56 | 63 | |
| 57 | 64 | /** |
| 58 | 65 | * Recursively filter an array to include only specified properties. |
| @@ -284,9 +291,10 @@ | ||
| 284 | 291 | 'lineHeight' => $typography['lineHeight'] ?? null, |
| 285 | 292 | 'letterSpacing' => $typography['letterSpacing'] ?? null, |
| 286 | 293 | 'fontStyle' => $typography['fontStyle'] ?? null, |
| 287 | 294 | 'fontWeight' => $typography['fontWeight'] ?? null, |
| 288 | - 'textTransform' => $typography['textTransform'] ?? 'none', | |
| 295 | + // Defaulting to 'none' would cancel the applied vibe's uppercase. | |
| 296 | + 'textTransform' => $typography['textTransform'] ?? null, | |
| 289 | 297 | ], function ($v) { |
| 290 | 298 | return $v !== null; |
| 291 | 299 | }) |
| 292 | 300 | ]; |
| @@ -293,8 +301,75 @@ | ||
| 293 | 301 | } |
| 294 | 302 | |
| 295 | 303 | |
| 296 | 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 | + /** | |
| 297 | 372 | * Get the HTML of a specific tagged block code |
| 298 | 373 | * |
| 299 | 374 | * @param \WP_REST_Request $request The REST API request object. |
| 300 | 375 | * @return \WP_REST_Response |
| @@ -300,10 +375,22 @@ | ||
| 300 | 375 | * @return \WP_REST_Response |
| 301 | 376 | */ |
| 302 | 377 | public static function getBlockCode(\WP_REST_Request $request) |
| 303 | 378 | { |
| 304 | - $blockId = (int) $request->get_param('blockId'); | |
| 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 | |
| @@ -308,9 +395,8 @@ | ||
| 308 | 395 | } |
| 309 | 396 | |
| 310 | 397 | // A template-part source resolves the part and walks it with the shared |
| 311 | 398 | // preorder finder, instead of the post path below. |
| 312 | - $partSlug = (string) $request->get_param('partSlug'); | |
| 313 | 399 | if ($partSlug !== '') { |
| 314 | 400 | return self::getTemplatePartBlockCode($partSlug, $blockId); |
| 315 | 401 | } |
| 316 | 402 | |
| @@ -319,22 +405,27 @@ | ||
| 319 | 405 | if (!$post) { |
| 320 | 406 | return new \WP_REST_Response(['error' => 'Post not found'], 404); |
| 321 | 407 | } |
| 322 | 408 | |
| 323 | - $found = \Extendify\Agent\PostBlockFinder::find(parse_blocks($post->post_content), $blockId); | |
| 409 | + $found = PostBlockFinder::find(parse_blocks($post->post_content), $blockId); | |
| 324 | 410 | |
| 325 | 411 | if (!$found || empty($found['block']['blockName'])) { |
| 326 | 412 | return new \WP_REST_Response(['error' => 'Block id not found in this post'], 404); |
| 327 | 413 | } |
| 328 | 414 | |
| 329 | - $block = $found['block']; | |
| 330 | - return new \WP_REST_Response([ | |
| 331 | - '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, [ | |
| 332 | 423 | 'blockId' => $blockId, |
| 333 | 424 | 'name' => $block['blockName'], |
| 334 | 425 | 'attrs' => $block['attrs'] ?? (object)[], |
| 335 | 426 | 'block' => serialize_blocks([$block]), |
| 336 | - ], 200); | |
| 427 | + ]), 200); | |
| 337 | 428 | } |
| 338 | 429 | |
| 339 | 430 | // Resolves the part the same way SaveController does — active-theme-scoped |
| 340 | 431 | // get_block_template, not a raw get_posts by name — then walks it with the |
| @@ -340,35 +431,71 @@ | ||
| 340 | 431 | // get_block_template, not a raw get_posts by name — then walks it with the |
| 341 | 432 | // shared preorder finder so the blockId lands on the block save will write. |
| 342 | 433 | private static function getTemplatePartBlockCode(string $slug, int $blockId) |
| 343 | 434 | { |
| 344 | - $stylesheet = wp_get_theme()->get_stylesheet(); | |
| 345 | - $template = get_block_template("{$stylesheet}//{$slug}", 'wp_template_part'); | |
| 346 | - if (!$template || empty($template->wp_id)) { | |
| 435 | + $content = self::templatePartContent($slug); | |
| 436 | + if ($content === null) { | |
| 347 | 437 | return new \WP_REST_Response(['error' => 'Template part not found'], 404); |
| 348 | 438 | } |
| 349 | 439 | |
| 350 | - $post = \get_post($template->wp_id); | |
| 351 | - if (!$post) { | |
| 352 | - 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); | |
| 353 | 446 | } |
| 354 | 447 | |
| 355 | - $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( | |
| 356 | 490 | parse_blocks($post->post_content), |
| 357 | - $blockId | |
| 491 | + $composite['seq'] | |
| 358 | 492 | ); |
| 359 | 493 | if (!is_array($found) || empty($found['block']['blockName'])) { |
| 360 | - 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); | |
| 361 | 495 | } |
| 362 | 496 | |
| 363 | - $block = $found['block']; | |
| 364 | - return new \WP_REST_Response([ | |
| 365 | - 'partSlug' => $slug, | |
| 366 | - 'blockId' => $blockId, | |
| 367 | - 'name' => $block['blockName'], | |
| 368 | - 'attrs' => $block['attrs'] ?? (object)[], | |
| 369 | - 'block' => serialize_blocks([$block]), | |
| 370 | - ], 200); | |
| 497 | + return self::blockCodeResponse($found['block'], ['partSlug' => $partSlug], $blockId); | |
| 371 | 498 | } |
| 372 | 499 | |
| 373 | 500 | /** |
| 374 | 501 | * Get the rendered HTML of some block code |
| @@ -446,10 +573,18 @@ | ||
| 446 | 573 | 1 |
| 447 | 574 | ); |
| 448 | 575 | } |
| 449 | 576 | |
| 450 | - if ($description) { | |
| 577 | + if (trim((string) $description) === '') { | |
| 578 | + // Catalog paragraphs describe the slot, not the site. | |
| 451 | 579 | $code = preg_replace( |
| 580 | + '/<!-- wp:paragraph[\s\S]*?<!-- \/wp:paragraph -->/m', | |
| 581 | + '', | |
| 582 | + $code, | |
| 583 | + 1 | |
| 584 | + ); | |
| 585 | + } else { | |
| 586 | + $code = preg_replace( | |
| 452 | 587 | '/(<!-- wp:paragraph[^>]*-->[\s\S]*?<p[^>]*>)[\s\S]*?(<\/p>[\s\S]*?<!-- \/wp:paragraph -->)/m', |
| 453 | 588 | '${1}' . esc_html($description) . '${2}', |
| 454 | 589 | $code, |
| 455 | 590 | 1 |
| @@ -636,9 +771,9 @@ | ||
| 636 | 771 | |
| 637 | 772 | public static function getSiteDesignVariations(\WP_REST_Request $request) |
| 638 | 773 | { |
| 639 | 774 | $title = $request->get_param('title'); |
| 640 | - $description = $request->get_param('description'); | |
| 775 | + $description = HeroDescription::resolve($request->get_param('description')); | |
| 641 | 776 | $images = $request->get_param('images') ?? []; |
| 642 | 777 | $cta = $request->get_param('cta'); |
| 643 | 778 | $featuredOnly = true; // Only show featured patterns |
| 644 | 779 | $currentHeroPattern = $request->get_param('currentHeroPattern'); |