diff --git a/.wp-env.json b/.wp-env.json index dd55ce6..5810807 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -1,5 +1,5 @@ { - "core": "WordPress/WordPress", + "core": "WordPress/WordPress#6.9.4", "port": 8498, "testsPort": 8499, "plugins": [ "." ], diff --git a/CLAUDE.md b/CLAUDE.md index 484a006..c52ba56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ This file provides guidance to Claude Code and other AI coding agents when worki - **Plugin URI:** https://www.twentybellows.com/pattern-builder/ - **License:** GPL-2.0-or-later - **WordPress Requires:** 6.6+ +- **Target/test version:** WordPress 6.9.4 (latest production release — all development and testing targets this version) - **PHP Requires:** 7.2+ ## Development Environment @@ -50,7 +51,8 @@ A full architectural analysis is in [`docs/architecture.md`](docs/architecture.m ### Environment Notes - Docker is available (host socket shared). All `wp-env` commands work. - `wp-env` binary is at `node_modules/.bin/wp-env` — run via npm scripts from this directory. -- First `npm run start` will pull WordPress Docker images (~1-2 min). +- Target WordPress version: **6.9.4**. The `.wp-env.json` or override should pin this version. +- wp-env containers (when running): `c66a7bb12251f6943ed9d46e3f7f65aa-wordpress-1` (main), `c66a7bb12251f6943ed9d46e3f7f65aa-tests-wordpress-1` (tests) ### Known Pre-Existing Issues - Several PHP lint violations exist in the codebase (Yoda conditions, inline comment formatting). These are pre-existing and not regressions. Fix them if you touch the file; don't feel obligated to fix unrelated files. diff --git a/includes/class-pattern-builder-abstract-pattern.php b/includes/class-pattern-builder-abstract-pattern.php index 99199fe..12b7a03 100644 --- a/includes/class-pattern-builder-abstract-pattern.php +++ b/includes/class-pattern-builder-abstract-pattern.php @@ -81,6 +81,13 @@ class Abstract_Pattern { */ public $postTypes; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase + /** + * Optional viewport width (in pixels) used for pattern preview rendering. + * + * @var int|null + */ + public $viewportWidth; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase + /** * Pattern source: 'theme' or 'user'. * @@ -135,6 +142,9 @@ public function __construct( $args = array() ) { $this->templateTypes = $args['templateTypes'] ?? array(); // phpcs:ignore WordPress.NamingConventions.ValidVariableName $this->postTypes = $args['postTypes'] ?? array(); // phpcs:ignore WordPress.NamingConventions.ValidVariableName + $viewport_width = $args['viewportWidth'] ?? null; + $this->viewportWidth = is_numeric( $viewport_width ) ? (int) $viewport_width : null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName + $this->filePath = $args['filePath'] ?? null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName } @@ -181,11 +191,12 @@ public static function from_file( $pattern_file ) { 'description' => $pattern_data['description'], 'content' => self::render_pattern( $pattern_file ), 'filePath' => $pattern_file, - 'categories' => '' === $pattern_data['categories'] ? array() : explode( ',', $pattern_data['categories'] ), - 'keywords' => '' === $pattern_data['keywords'] ? array() : explode( ',', $pattern_data['keywords'] ), + 'categories' => '' === $pattern_data['categories'] ? array() : array_map( 'trim', explode( ',', $pattern_data['categories'] ) ), + 'keywords' => '' === $pattern_data['keywords'] ? array() : array_map( 'trim', explode( ',', $pattern_data['keywords'] ) ), 'blockTypes' => '' === $pattern_data['blockTypes'] ? array() : array_map( 'trim', explode( ',', $pattern_data['blockTypes'] ) ), - 'postTypes' => '' === $pattern_data['postTypes'] ? array() : explode( ',', $pattern_data['postTypes'] ), - 'templateTypes' => '' === $pattern_data['templateTypes'] ? array() : explode( ',', $pattern_data['templateTypes'] ), + 'postTypes' => '' === $pattern_data['postTypes'] ? array() : array_map( 'trim', explode( ',', $pattern_data['postTypes'] ) ), + 'templateTypes' => '' === $pattern_data['templateTypes'] ? array() : array_map( 'trim', explode( ',', $pattern_data['templateTypes'] ) ), + 'viewportWidth' => $pattern_data['viewportWidth'], 'source' => 'theme', 'synced' => 'yes' === $pattern_data['synced'], 'inserter' => 'no' !== $pattern_data['inserter'], diff --git a/includes/class-pattern-builder-api.php b/includes/class-pattern-builder-api.php index 44b8e39..0dbb3eb 100644 --- a/includes/class-pattern-builder-api.php +++ b/includes/class-pattern-builder-api.php @@ -46,6 +46,7 @@ public function __construct() { add_action( 'init', array( $this, 'register_patterns' ), 9 ); add_filter( 'rest_request_after_callbacks', array( $this, 'inject_theme_patterns' ), 10, 3 ); + add_filter( 'rest_request_after_callbacks', array( $this, 'inject_faux_patterns' ), 10, 3 ); add_filter( 'rest_pre_dispatch', array( $this, 'handle_hijack_block_update' ), 10, 3 ); add_filter( 'rest_pre_dispatch', array( $this, 'handle_hijack_block_delete' ), 10, 3 ); @@ -214,7 +215,31 @@ function ( $pattern ) { } /** - * Injects theme patterns into the /wp/v2/blocks REST responses. + * Injects theme patterns into /wp/v2/blocks REST responses. + * + * ### List injection (/wp/v2/blocks — GET) + * + * All theme patterns are injected into the list response. This makes them appear as + * `type: PATTERN_TYPES.user` (`"wp_block"`) in the Site Editor's data store, which is + * the only way to make them appear as editable (without a lock icon) in the "All patterns" + * page. Without list injection, edit-site.js hardcodes `type: PATTERN_TYPES.theme` for + * everything from /wp/v2/block-patterns/patterns, and `isItemClickable` returns false for + * those entries, producing the lock icon. + * + * A parallel faux-patterns filter (inject_faux_patterns) handles the patterns endpoint. + * It sets source:'core' on faux entries, which causes edit-site.js EXCLUDED_PATTERN_SOURCES + * to remove them from selectThemePatterns — preventing the "All patterns" page from showing + * both a locked type:theme entry (from the patterns endpoint) AND an editable type:user + * entry (from this list injection) for the same pattern. + * + * Side effect: theme patterns also appear in the block inserter's "My Patterns" tab + * (Gutenberg classifies any entry from /wp/v2/blocks as a user pattern). This is + * an acceptable trade-off to support full editability. + * + * ### Single-pattern injection (/wp/v2/blocks/{id} — GET) + * + * When a specific block is requested by ID and the ID belongs to a tbell_pattern_block + * post, a correctly formatted response is returned so the pattern can be opened and edited. * * @param WP_REST_Response $response The REST response. * @param mixed $server The REST server. @@ -222,33 +247,36 @@ function ( $pattern ) { * @return WP_REST_Response */ public function inject_theme_patterns( $response, $server, $request ) { - // Requesting a single pattern — inject the synced theme pattern. if ( preg_match( '#/wp/v2/blocks/(?P\d+)#', $request->get_route(), $matches ) ) { + // Single-pattern request — inject if the ID belongs to a tbell_pattern_block. $block_id = intval( $matches['id'] ); $tbell_pattern_block = get_post( $block_id ); - if ( $tbell_pattern_block && 'tbell_pattern_block' === $tbell_pattern_block->post_type ) { - // Make sure the pattern has a pattern file. - $pattern_file_path = $this->controller->get_pattern_filepath( Abstract_Pattern::from_post( $tbell_pattern_block ) ); - if ( is_wp_error( $pattern_file_path ) || ! $pattern_file_path ) { - return $response; - } - $tbell_pattern_block->post_name = $this->controller->format_pattern_slug_from_post( $tbell_pattern_block->post_name ); - $data = $this->format_tbell_pattern_block_response( $tbell_pattern_block, $request ); - $response = new WP_REST_Response( $data ); + + if ( ! $tbell_pattern_block || 'tbell_pattern_block' !== $tbell_pattern_block->post_type ) { + return $response; } - } elseif ( '/wp/v2/blocks' === $request->get_route() && 'GET' === $request->get_method() ) { - // Requesting all patterns — inject all synced theme patterns. + + // Only inject if the pattern still has a backing file. + $pattern_file_path = $this->controller->get_pattern_filepath( Abstract_Pattern::from_post( $tbell_pattern_block ) ); + if ( is_wp_error( $pattern_file_path ) || ! $pattern_file_path ) { + return $response; + } + + $tbell_pattern_block->post_name = $this->controller->format_pattern_slug_from_post( $tbell_pattern_block->post_name ); + $data = $this->format_tbell_pattern_block_response( $tbell_pattern_block, $request ); + + return new WP_REST_Response( $data ); + } + + if ( '/wp/v2/blocks' === $request->get_route() && 'GET' === $request->get_method() ) { + // List request — inject all theme patterns so they appear as type:user in the store. + if ( is_wp_error( $response ) ) { + return $response; + } + $data = $response->get_data(); $patterns = $this->controller->get_block_patterns_from_theme_files(); - // Filter out patterns that should be excluded from the inserter. - $patterns = array_filter( - $patterns, - function ( $pattern ) { - return $pattern->inserter; - } - ); - foreach ( $patterns as $pattern ) { $post = $this->controller->get_tbell_pattern_block_post_for_pattern( $pattern ); $data[] = $this->format_tbell_pattern_block_response( $post, $request ); @@ -260,6 +288,124 @@ function ( $pattern ) { return $response; } + /** + * Replaces Pattern Builder–managed entries in the /wp/v2/block-patterns/patterns response + * with "faux" versions that carry correct inserter and faux content. + * + * ### Why this is needed + * + * Pattern Builder registers theme patterns at priority 9 with `inserter: false` to prevent + * WordPress from registering real (locked) versions at priority 10. The registry entries + * therefore have `inserter: false` and hardcoded content, which is not what the editor + * should see. + * + * This filter intercepts the REST response and replaces each managed pattern entry with + * a faux version: + * - `content` is set to `` for synced patterns, or the + * theme file content for unsynced patterns. This content goes through + * syncedPatternFilter.js on the JS side when rendered in the editor. + * - `inserter` is set to `true` for context-restricted patterns (those with `blockTypes` + * or `postTypes` declarations, e.g. Starter Patterns candidates), and `false` for all + * other theme patterns. + * - All other metadata (title, description, categories, keywords, blockTypes, postTypes, + * viewportWidth, source) is preserved from the existing registry entry. + * + * ### Deduplication + * + * Because theme patterns are no longer injected into the /wp/v2/blocks LIST, they do not + * appear in Gutenberg's "My Patterns" tab. They appear exactly once in the patterns + * endpoint response, as a faux entry. + * + * @param WP_REST_Response $response The REST response. + * @param mixed $server The REST server. + * @param WP_REST_Request $request The REST request. + * @return WP_REST_Response + */ + public function inject_faux_patterns( $response, $server, $request ) { + if ( '/wp/v2/block-patterns/patterns' !== $request->get_route() || 'GET' !== $request->get_method() ) { + return $response; + } + + // Guard: unauthenticated or permission-denied requests return WP_Error, not WP_REST_Response. + if ( is_wp_error( $response ) ) { + return $response; + } + + $managed_patterns = $this->controller->get_block_patterns_from_theme_files(); + + if ( empty( $managed_patterns ) ) { + return $response; + } + + // Build a map of pattern name → [pattern, post] for quick lookup. + $managed_map = array(); + foreach ( $managed_patterns as $pattern ) { + $post = $this->controller->get_tbell_pattern_block_post_for_pattern( $pattern ); + if ( $post ) { + $managed_map[ $pattern->name ] = array( + 'pattern' => $pattern, + 'post' => $post, + ); + } + } + + if ( empty( $managed_map ) ) { + return $response; + } + + $data = $response->get_data(); + $modified = false; + + foreach ( $data as $key => $entry ) { + $name = $entry['name'] ?? ''; + + if ( ! isset( $managed_map[ $name ] ) ) { + continue; + } + + $pattern = $managed_map[ $name ]['pattern']; + $post = $managed_map[ $name ]['post']; + + // Faux content: synced patterns expose a wp:block ref; unsynced expose the theme file content. + $content = $pattern->synced + ? '' + : $pattern->content; + + // Context-restricted patterns (blockTypes/postTypes) must be visible in the Starter Patterns + // modal, which filters by inserter !== false before checking blockTypes. All other theme + // patterns remain hidden from the general inserter panel. + $has_context_restriction = ! empty( $pattern->blockTypes ) || ! empty( $pattern->postTypes ); + $inserter = (bool) ( $has_context_restriction ? $pattern->inserter : false ); + + // Start from the existing registry entry (preserves all metadata) and override only + // the fields that Pattern Builder controls. + // + // source: 'core' — WP 6.9.4 edit-site.js EXCLUDED_PATTERN_SOURCES removes these + // from selectThemePatterns, which hardcodes type:PATTERN_TYPES.theme for everything + // from this endpoint. Without exclusion, isItemClickable returns false (lock icon). + // block-editor.js getAllPatterns does NOT filter by EXCLUDED_PATTERN_SOURCES, so + // Starter Patterns continues to work via the faux inserter:true entry. + // Editable user-type entries come from /wp/v2/blocks (inject_theme_patterns list). + // + // id: post ID — enables the "Edit" button to route to /wp/v2/blocks/{id}, which + // inject_theme_patterns() already intercepts to serve correct editable data. + $faux = $entry; + $faux['content'] = $content; + $faux['inserter'] = $inserter; + $faux['source'] = 'core'; + $faux['id'] = $post->ID; + + $data[ $key ] = $faux; + $modified = true; + } + + if ( $modified ) { + $response->set_data( $data ); + } + + return $response; + } + /** * Formats a tbell_pattern_block post as a wp_block REST response. * @@ -309,7 +455,18 @@ public function format_tbell_pattern_block_response( $post, $request ) { // phpc * * If the patterns are already registered, unregisters them first. * Synced patterns are registered with a reference to the post ID of their pattern. - * Unsynced patterns are registered with the content from the tbell_pattern_block post. + * Unsynced patterns are registered with the content from the theme file. + * + * ### Purpose: blocking WordPress's default registration + * + * This runs at priority 9 so it fires before WordPress's own pattern registration + * at priority 10. By registering first (with `inserter: false` and faux content), + * Pattern Builder prevents WordPress from creating real/locked versions that would + * bypass Plugin Builder's editing layer. + * + * What users actually see is controlled by inject_faux_patterns(), which replaces + * the /wp/v2/block-patterns/patterns REST response with faux entries that carry + * correct inserter values and the right content for each pattern type. */ public function register_patterns(): void { @@ -331,17 +488,34 @@ public function register_patterns(): void { $pattern_content = ''; } + /* + * Always register with inserter: false in the pattern registry. + * + * The registry is used only as a blocking mechanism — registering at priority 9 + * prevents WordPress from registering real/locked versions at priority 10. + * What users actually see is controlled by inject_faux_patterns(), which + * replaces the REST response with faux entries carrying correct inserter values. + */ + $inserter_value = false; + $pattern_data = array( 'title' => $pattern->title, - 'inserter' => false, + 'description' => $pattern->description, + 'inserter' => $inserter_value, 'content' => $pattern_content, 'source' => 'theme', + 'categories' => $pattern->categories, + 'keywords' => $pattern->keywords, 'blockTypes' => $pattern->blockTypes, 'templateTypes' => $pattern->templateTypes, ); - // Setting postTypes to an empty array causes registration errors; only set it when non-empty. - if ( $pattern->postTypes ) { + if ( $pattern->viewportWidth ) { + $pattern_data['viewportWidth'] = $pattern->viewportWidth; + } + + // Setting postTypes to an empty array causes registration issues; only include when non-empty. + if ( ! empty( $pattern->postTypes ) ) { $pattern_data['postTypes'] = $pattern->postTypes; }