From 41a32920bce472cb6811af8a393c8ac08d76d404 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Thu, 26 Mar 2026 12:00:06 +0000 Subject: [PATCH 1/7] =?UTF-8?q?fix:=20TWE-375=20=E2=80=94=20restore=20Star?= =?UTF-8?q?ter=20Patterns=20when=20plugin=20is=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: register_patterns() was forcing 'inserter' => false on ALL theme patterns. Gutenberg's __experimentalGetAllowedPatterns selector explicitly filters out inserter:false patterns before applying any blockTypes filtering: const parsedPatterns = patterns.filter(({ inserter = true }) => !!inserter).map(enhancePatternWithParsedBlocks); This means getPatternsByBlockTypes('core/post-content') — which powers the Starter Patterns modal (useStartPatterns in @wordpress/editor) — never receives any theme patterns, so the modal shows nothing. Fix: Only force inserter:false for patterns with no blockTypes or postTypes restrictions. Patterns with these restrictions are designed for specific contexts (Starter Patterns modal, template inserter, block transform list) and require inserter:true to be reachable from those surfaces. Patterns that explicitly set 'Inserter: no' in their PHP header already have $pattern->inserter === false and continue to be hidden everywhere. Rule applied in register_patterns(): $has_context_restriction = !empty($pattern->blockTypes) || !empty($pattern->postTypes) $inserter_value = $has_context_restriction ? $pattern->inserter : false Also fixed in this commit: - Restore missing metadata fields that were dropped from register(): description, categories, keywords (all available on Abstract_Pattern) - Add viewportWidth to Abstract_Pattern (was read from PHP header in from_file() but never stored; lost during registration) - Add trim() to categories/keywords/postTypes/templateTypes explode() calls in from_file() for consistency with blockTypes - Pass viewportWidth through to register_block_pattern() when set Verified with wp-env (live WP 6.6 + simple-theme): - theme-restrictions-test (blockTypes:core/post-content, postTypes:page) → inserter=true, block_types=core/post-content, post_types=page ✅ - All other 6 simple-theme patterns → inserter=false ✅ - All three quality gates pass --- ...class-pattern-builder-abstract-pattern.php | 19 +++++++-- includes/class-pattern-builder-api.php | 42 +++++++++++++++++-- 2 files changed, 54 insertions(+), 7 deletions(-) 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..9aec3c0 100644 --- a/includes/class-pattern-builder-api.php +++ b/includes/class-pattern-builder-api.php @@ -310,6 +310,27 @@ 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. + * + * ### Inserter visibility + * + * By default, theme patterns are hidden from the regular block inserter panel + * (`'inserter' => false`). However, patterns that declare `blockTypes` or `postTypes` + * restrictions are specifically designed for context-sensitive surfaces — most notably + * the Starter Patterns modal (shown when creating a new page), which uses + * `getPatternsByBlockTypes('core/post-content')` on the JS side. + * + * Gutenberg's `__experimentalGetAllowedPatterns` selector filters out all patterns + * where `inserter === false` *before* applying `blockTypes` filtering. Forcing + * `inserter: false` on restricted patterns therefore breaks Starter Patterns entirely. + * + * Rule applied here: + * - Pattern has `blockTypes` or `postTypes` → use the theme-declared `inserter` value + * (default: `true`). The context restriction already limits where the pattern + * appears; it will not clutter the general patterns panel. + * - Pattern has no `blockTypes`/`postTypes` → force `inserter: false` to keep the + * general inserter panel free of raw theme patterns. + * - In either case: if the theme explicitly sets `Inserter: no`, `$pattern->inserter` + * is `false` and that value is always respected. */ public function register_patterns(): void { @@ -331,17 +352,32 @@ public function register_patterns(): void { $pattern_content = ''; } + /* + * Patterns with blockTypes/postTypes restrictions are context-specific + * (e.g. Starter Patterns modal). Use the theme's declared inserter value. + * All other theme patterns are hidden from the general block inserter. + */ + $has_context_restriction = ! empty( $pattern->blockTypes ) || ! empty( $pattern->postTypes ); + $inserter_value = $has_context_restriction ? $pattern->inserter : 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; } From e9c3f0817d3279bdbbbefb70932e400076bab512 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Thu, 26 Mar 2026 12:40:33 +0000 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20TWE-375=20=E2=80=94=20prevent=20pote?= =?UTF-8?q?ntial=20duplication=20in=20Starter=20Patterns=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the previous commit (inserter:true for context-restricted patterns), those patterns correctly appear in the Starter Patterns modal. However, they were ALSO still being injected into /wp/v2/blocks via inject_theme_patterns() because the injection filter only excluded patterns where $pattern->inserter was false. This creates two separate entries in Gutenberg's getAllPatterns merge: 'simple-theme/…' (from pattern registry, has blockTypes) 'core/block/{id}' (from /wp/v2/blocks injection, NO blockTypes in mapUserPattern output) In the current WP 6.9.4 / Gutenberg bundled build, the second entry does not pass getPatternsByBlockTypes because mapUserPattern omits blockTypes. However, this is an implementation detail of mapUserPattern that can change, and the dual-source presence is architecturally unsound in any version. Fix: exclude context-restricted patterns (those with blockTypes or postTypes declarations) from the /wp/v2/blocks LIST injection. These patterns are already correctly served via the pattern registry with inserter:true. Individual fetches via /wp/v2/blocks/{id} are unaffected — context-restricted patterns remain individually loadable for editing via Pattern Builder sidebar. Verified in live WP 6.9.4 instance: - /wp/v2/block-patterns/patterns: theme-restrictions-test present once, inserter=true, block_types=core/post-content ✅ - /wp/v2/blocks LIST: theme-restrictions-test NOT present ✅ - /wp/v2/blocks/106 (single fetch): returns correctly, title verified ✅ - Starter Patterns apply flow: wp:block{ref:106} parses, renders (172 chars) ✅ - block-to-pattern conversion: wp:block{ref:106} → wp:pattern{slug:...} → renders ✅ --- includes/class-pattern-builder-api.php | 39 +++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/includes/class-pattern-builder-api.php b/includes/class-pattern-builder-api.php index 9aec3c0..e0519a5 100644 --- a/includes/class-pattern-builder-api.php +++ b/includes/class-pattern-builder-api.php @@ -216,13 +216,32 @@ function ( $pattern ) { /** * Injects theme patterns into the /wp/v2/blocks REST responses. * + * ### Which patterns are injected into the /wp/v2/blocks LIST + * + * This filter makes theme patterns appear in the Site Editor's editable blocks list. + * However, context-restricted patterns — those with `blockTypes` or `postTypes` + * declarations — are intentionally excluded from the list injection. + * + * Reason: context-restricted patterns already have `inserter: true` in the pattern + * registry (see register_patterns()) so they are correctly served via the + * /wp/v2/block-patterns/patterns endpoint to context-aware surfaces like the Starter + * Patterns modal. Including them in /wp/v2/blocks as well creates two separate entries + * in Gutenberg's getAllPatterns merge (one as 'simple-theme/…' from the registry, one + * as 'core/block/{id}' from the reusable blocks feed), which can surface as duplicates + * in the Starter Patterns modal depending on how a given Gutenberg version resolves the + * merge. + * + * Context-restricted patterns remain individually fetchable via /wp/v2/blocks/{id}, + * so they can still be opened and edited by navigating to them directly (e.g. via the + * Pattern Builder sidebar). + * * @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_theme_patterns( $response, $server, $request ) { - // Requesting a single pattern — inject the synced theme pattern. + // Requesting a single pattern — inject the theme pattern regardless of context restrictions. if ( preg_match( '#/wp/v2/blocks/(?P\d+)#', $request->get_route(), $matches ) ) { $block_id = intval( $matches['id'] ); $tbell_pattern_block = get_post( $block_id ); @@ -237,15 +256,27 @@ public function inject_theme_patterns( $response, $server, $request ) { $response = new WP_REST_Response( $data ); } } elseif ( '/wp/v2/blocks' === $request->get_route() && 'GET' === $request->get_method() ) { - // Requesting all patterns — inject all synced theme patterns. + // Requesting all patterns — inject theme patterns into the editable blocks list. $data = $response->get_data(); $patterns = $this->controller->get_block_patterns_from_theme_files(); - // Filter out patterns that should be excluded from the inserter. + /* + * Exclude patterns that: + * (a) have their inserter explicitly disabled — they should not be browsable anywhere, or + * (b) have blockTypes or postTypes restrictions — these are already served via the pattern + * registry with inserter:true, so injecting them here too would create duplicate + * entries in Gutenberg's getAllPatterns merge. + */ $patterns = array_filter( $patterns, function ( $pattern ) { - return $pattern->inserter; + if ( ! $pattern->inserter ) { + return false; + } + if ( ! empty( $pattern->blockTypes ) || ! empty( $pattern->postTypes ) ) { + return false; + } + return true; } ); From ccb3036e5cadad8efe7de06d127c8cc7efc7de7e Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Thu, 26 Mar 2026 14:34:39 +0000 Subject: [PATCH 3/7] chore: pin wp-env to WordPress 6.9.4; document target WP version --- .wp-env.json | 2 +- CLAUDE.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) 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. From 5e03f6353b087be2770b2de0f5a1b3fc6cc4735b Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Thu, 26 Mar 2026 14:39:10 +0000 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20TWE-375=20=E2=80=94=20revert=20round?= =?UTF-8?q?=202,=20restore=20/wp/v2/blocks=20injection=20for=20context-res?= =?UTF-8?q?tricted=20patterns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 (commit e9c3f08) excluded context-restricted patterns from the /wp/v2/blocks LIST injection to eliminate a potential duplication source. This was wrong — the exclusion broke editability of those patterns from the Site Editor Patterns list and Pattern Builder sidebar navigation. Reverted to: context-restricted patterns ARE included in /wp/v2/blocks (same as all other inserter-eligible patterns). === SURFACE ANALYSIS (WP 6.9.4) === Round 1 state (current, after this commit): SURFACE 1 — Starter Patterns modal getPatternsByBlockTypes("core/post-content") → __experimentalGetAllowedPatterns (inserter != false) → filter blockTypes.includes("core/post-content") mapUserPattern output for core/block/* entries does NOT include blockTypes. Only the registry entry (simple-theme/theme-restrictions-test) passes. Result: 1 match, NO DUPLICATION ✅ SURFACE 2 — Block inserter Patterns "All" tab getAllPatterns merges reusable blocks + registry patterns. simple-theme/theme-restrictions-test → from registry [theme-pattern] core/block/106 → from /wp/v2/blocks via mapUserPattern [user-pattern] Both appear. They represent the same content but via different mechanisms (one inserts the pattern; the other inserts a synced core/block ref). This is a pre-existing characteristic of the plugin architecture — all synced theme patterns appear in both the pattern registry AND /wp/v2/blocks. SURFACE 3 — Block inserter Patterns "Theme" tab isPatternFiltered excludes core/block/* entries from the Theme tab. Only simple-theme/theme-restrictions-test appears here. NEW from Round 1: this pattern was inserter:false before, hidden entirely. SURFACE 4 — Block inserter "My Patterns" tab Only type:user patterns (core/block/* names) appear here. core/block/106 appears — pre-existing for all synced theme patterns. SURFACE 5 — Editability /wp/v2/blocks/{id} single-pattern fetch: works for all tbell_pattern_block posts (inject_theme_patterns handles the single-pattern case). Pattern Builder sidebar: navigates by ID — works ✅ === WHAT "DUPLICATION" ACTUALLY IS === The duplication is NOT two entries in the same Starter Patterns modal. It is the same content appearing in two different inserter tabs: • "Theme" tab: simple-theme/theme-restrictions-test (theme pattern) • "My Patterns" tab: core/block/106 (synced user pattern) This is a pre-existing characteristic of the inject_theme_patterns architecture, not specific to this fix. All synced theme patterns appear in /wp/v2/blocks (My Patterns) regardless. Round 1 additionally makes context-restricted patterns visible in the Theme tab. === TRADE-OFF === WordPress\'s inserter API offers no mechanism to appear in the Starter Patterns modal without also appearing in the general Patterns panel. Both surfaces use __experimentalGetAllowedPatterns (inserter != false) as a prerequisite. The blockTypes filter that feeds the Starter Patterns modal runs AFTER the inserter check. Context-restricted patterns (blockTypes/postTypes declarations) are specifically designed for user-facing contexts. Having them visible in the Theme inserter tab is arguably correct — they are meant to be insertable by users in the relevant context. Verified in live WP 6.9.4 wp-env instance. --- includes/class-pattern-builder-api.php | 39 +++----------------------- 1 file changed, 4 insertions(+), 35 deletions(-) diff --git a/includes/class-pattern-builder-api.php b/includes/class-pattern-builder-api.php index e0519a5..4b8e1e4 100644 --- a/includes/class-pattern-builder-api.php +++ b/includes/class-pattern-builder-api.php @@ -216,32 +216,13 @@ function ( $pattern ) { /** * Injects theme patterns into the /wp/v2/blocks REST responses. * - * ### Which patterns are injected into the /wp/v2/blocks LIST - * - * This filter makes theme patterns appear in the Site Editor's editable blocks list. - * However, context-restricted patterns — those with `blockTypes` or `postTypes` - * declarations — are intentionally excluded from the list injection. - * - * Reason: context-restricted patterns already have `inserter: true` in the pattern - * registry (see register_patterns()) so they are correctly served via the - * /wp/v2/block-patterns/patterns endpoint to context-aware surfaces like the Starter - * Patterns modal. Including them in /wp/v2/blocks as well creates two separate entries - * in Gutenberg's getAllPatterns merge (one as 'simple-theme/…' from the registry, one - * as 'core/block/{id}' from the reusable blocks feed), which can surface as duplicates - * in the Starter Patterns modal depending on how a given Gutenberg version resolves the - * merge. - * - * Context-restricted patterns remain individually fetchable via /wp/v2/blocks/{id}, - * so they can still be opened and edited by navigating to them directly (e.g. via the - * Pattern Builder sidebar). - * * @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_theme_patterns( $response, $server, $request ) { - // Requesting a single pattern — inject the theme pattern regardless of context restrictions. + // Requesting a single pattern — inject the synced theme pattern. if ( preg_match( '#/wp/v2/blocks/(?P\d+)#', $request->get_route(), $matches ) ) { $block_id = intval( $matches['id'] ); $tbell_pattern_block = get_post( $block_id ); @@ -256,27 +237,15 @@ public function inject_theme_patterns( $response, $server, $request ) { $response = new WP_REST_Response( $data ); } } elseif ( '/wp/v2/blocks' === $request->get_route() && 'GET' === $request->get_method() ) { - // Requesting all patterns — inject theme patterns into the editable blocks list. + // Requesting all patterns — inject all inserter-eligible theme patterns. $data = $response->get_data(); $patterns = $this->controller->get_block_patterns_from_theme_files(); - /* - * Exclude patterns that: - * (a) have their inserter explicitly disabled — they should not be browsable anywhere, or - * (b) have blockTypes or postTypes restrictions — these are already served via the pattern - * registry with inserter:true, so injecting them here too would create duplicate - * entries in Gutenberg's getAllPatterns merge. - */ + // Filter out patterns that should be excluded from the inserter. $patterns = array_filter( $patterns, function ( $pattern ) { - if ( ! $pattern->inserter ) { - return false; - } - if ( ! empty( $pattern->blockTypes ) || ! empty( $pattern->postTypes ) ) { - return false; - } - return true; + return $pattern->inserter; } ); From c0dc4cf93a75364939ba46bd690167871189ea49 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Thu, 26 Mar 2026 15:17:42 +0000 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20TWE-375=20Path=20A=20=E2=80=94=20hij?= =?UTF-8?q?ack=20/wp/v2/block-patterns/patterns,=20remove=20from=20blocks?= =?UTF-8?q?=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Three-part change ### 1. New: inject_faux_patterns() — hijacks /wp/v2/block-patterns/patterns Intercepts the REST response and replaces each Pattern Builder-managed theme pattern entry with a faux version: content → wp:block{ref:ID} for synced patterns theme file content for unsynced patterns inserter → true for context-restricted patterns (blockTypes/postTypes) false for all other theme patterns All other metadata preserved from the registry entry (title, description, categories, keywords, block_types, post_types, viewport_width, source). ### 2. inject_theme_patterns() — LIST injection removed Theme patterns are no longer injected into the /wp/v2/blocks LIST response. Only individual /wp/v2/blocks/{id} fetches are handled (unchanged). This eliminates the dual-source appearance that caused the same content to appear in both the Theme patterns tab and My Patterns tab of the inserter. ### 3. register_patterns() — always inserter:false in registry The registry remains a blocking mechanism (priority 9 fires before WP's priority 10 registration). All patterns are registered with inserter:false. What users see is now controlled entirely by inject_faux_patterns(). ## Verified in WP 6.9.4 wp-env | Check | Result | |-------|--------| | /wp/v2/block-patterns/patterns — 7 theme patterns, faux content | OK | | Synced: wp:block{ref:N} content-type | OK | | Unsynced: direct markup content-type | OK | | context-restricted: inserter=true, block_types=core/post-content | OK | | others: inserter=false | OK | | /wp/v2/blocks LIST — 0 theme pattern entries | OK | | /wp/v2/blocks/{id} individual fetch — all 7 return correct data | OK | | Starter Patterns modal — 1 match (theme-restrictions-test), no dup | OK | | getAllPatterns merge — 7 unique entries, 0 duplicates | OK | | Synced faux content renders — 172 chars | OK | | My Patterns tab — 0 theme patterns | OK | --- includes/class-pattern-builder-api.php | 195 ++++++++++++++++++------- 1 file changed, 142 insertions(+), 53 deletions(-) diff --git a/includes/class-pattern-builder-api.php b/includes/class-pattern-builder-api.php index 4b8e1e4..1a257da 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,17 @@ function ( $pattern ) { } /** - * Injects theme patterns into the /wp/v2/blocks REST responses. + * Injects theme patterns into individual /wp/v2/blocks/{id} REST responses. + * + * When the Site Editor or Pattern Builder sidebar requests a specific block by ID, + * and that ID belongs to a tbell_pattern_block post, this filter returns a correctly + * formatted response so the pattern can be opened and edited. + * + * Note: Theme patterns are intentionally NOT injected into the /wp/v2/blocks LIST + * response. The list is used by Gutenberg's getAllPatterns merge, and theme patterns + * are now served exclusively via the /wp/v2/block-patterns/patterns endpoint + * (see inject_faux_patterns()). Keeping them out of the blocks list prevents + * the same content from appearing in both "My Patterns" and "Theme" inserter tabs. * * @param WP_REST_Response $response The REST response. * @param mixed $server The REST server. @@ -222,38 +233,124 @@ 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 ) ) { - $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 ( ! preg_match( '#/wp/v2/blocks/(?P\d+)#', $request->get_route(), $matches ) ) { + return $response; + } + + $block_id = intval( $matches['id'] ); + $tbell_pattern_block = get_post( $block_id ); + + if ( ! $tbell_pattern_block || 'tbell_pattern_block' !== $tbell_pattern_block->post_type ) { + return $response; + } + + // 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 ); + } + + /** + * 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; + } + + $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, + ); } - } elseif ( '/wp/v2/blocks' === $request->get_route() && 'GET' === $request->get_method() ) { - // Requesting all patterns — inject all inserter-eligible theme patterns. - $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; - } - ); + } + + if ( empty( $managed_map ) ) { + return $response; + } - foreach ( $patterns as $pattern ) { - $post = $this->controller->get_tbell_pattern_block_post_for_pattern( $pattern ); - $data[] = $this->format_tbell_pattern_block_response( $post, $request ); + $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. + $faux = $entry; + $faux['content'] = $content; + $faux['inserter'] = $inserter; + + $data[ $key ] = $faux; + $modified = true; + } + + if ( $modified ) { $response->set_data( $data ); } @@ -309,28 +406,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. * - * ### Inserter visibility + * ### Purpose: blocking WordPress's default registration * - * By default, theme patterns are hidden from the regular block inserter panel - * (`'inserter' => false`). However, patterns that declare `blockTypes` or `postTypes` - * restrictions are specifically designed for context-sensitive surfaces — most notably - * the Starter Patterns modal (shown when creating a new page), which uses - * `getPatternsByBlockTypes('core/post-content')` on the JS side. + * 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. * - * Gutenberg's `__experimentalGetAllowedPatterns` selector filters out all patterns - * where `inserter === false` *before* applying `blockTypes` filtering. Forcing - * `inserter: false` on restricted patterns therefore breaks Starter Patterns entirely. - * - * Rule applied here: - * - Pattern has `blockTypes` or `postTypes` → use the theme-declared `inserter` value - * (default: `true`). The context restriction already limits where the pattern - * appears; it will not clutter the general patterns panel. - * - Pattern has no `blockTypes`/`postTypes` → force `inserter: false` to keep the - * general inserter panel free of raw theme patterns. - * - In either case: if the theme explicitly sets `Inserter: no`, `$pattern->inserter` - * is `false` and that value is always respected. + * 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 { @@ -353,12 +440,14 @@ public function register_patterns(): void { } /* - * Patterns with blockTypes/postTypes restrictions are context-specific - * (e.g. Starter Patterns modal). Use the theme's declared inserter value. - * All other theme patterns are hidden from the general block inserter. + * 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. */ - $has_context_restriction = ! empty( $pattern->blockTypes ) || ! empty( $pattern->postTypes ); - $inserter_value = $has_context_restriction ? $pattern->inserter : false; + $inserter_value = false; $pattern_data = array( 'title' => $pattern->title, From cabd571b880c0d998ee494ae11fb31a56c9aadfa Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Thu, 26 Mar 2026 15:37:18 +0000 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20TWE-375=20Round=205=20=E2=80=94=20so?= =?UTF-8?q?urce:user=20+=20id=20on=20faux=20patterns=20(removes=20lock=20i?= =?UTF-8?q?con,=20enables=20Edit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inject_faux_patterns() now sets two additional fields on each faux entry: source: 'user' WordPress treats source:'theme' as read-only in the Site Editor's 'All patterns' page (lock icon, no editing). Setting source:'user' removes the lock and makes the pattern appear editable. id: $tbell_post_id Provides the post ID so the 'Edit' button routes to /wp/v2/blocks/{id}. Pattern Builder's inject_theme_patterns() hook already intercepts individual fetches at this route and returns the correct editable data. Verified in WP 6.9.4 wp-env: source=user, id={post_id} on all 7 simple-theme faux entries /wp/v2/blocks/{id} returns correct editable data for all 7 Starter Patterns: 1 match (theme-restrictions-test), no duplication getAllPatterns: 7 unique entries, 0 duplicates isPatternFiltered: names are still simple-theme/*, NOT core/block/*, so they appear in Theme tab (not My Patterns) — source field does not affect isUserPattern detection in WP 6.9.4 --- includes/class-pattern-builder-api.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/includes/class-pattern-builder-api.php b/includes/class-pattern-builder-api.php index 1a257da..ccab1e4 100644 --- a/includes/class-pattern-builder-api.php +++ b/includes/class-pattern-builder-api.php @@ -342,9 +342,17 @@ public function inject_faux_patterns( $response, $server, $request ) { // Start from the existing registry entry (preserves all metadata) and override only // the fields that Pattern Builder controls. + // + // source: 'user' — removes the lock icon; WordPress treats source:'theme' as read-only + // in the "All patterns" Site Editor page. + // id: post ID — enables the "Edit" button to route to /wp/v2/blocks/{id}, which + // Pattern Builder's inject_theme_patterns() hook already intercepts to serve + // the correct editable data. $faux = $entry; $faux['content'] = $content; $faux['inserter'] = $inserter; + $faux['source'] = 'user'; + $faux['id'] = $post->ID; $data[ $key ] = $faux; $modified = true; From 27badd11608be417862c9e14cab8c2e262f63535 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Thu, 26 Mar 2026 16:04:04 +0000 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20TWE-375=20Round=206=20=E2=80=94=20WP?= =?UTF-8?q?=5FError=20guard=20+=20source:core=20+=20restore=20list=20injec?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 — WP_Error guard: inject_faux_patterns() now checks is_wp_error(response) after the route check and returns immediately. Unauthenticated requests to the patterns endpoint return WP_Error, causing a fatal call to WP_Error::get_data(). Bug 2 — Lock icon (root cause confirmed from WP 6.9.4 JS source): edit-site.js selectThemePatterns: .map((pattern) => ({ ...pattern, type: PATTERN_TYPES.theme })) This hardcodes type:'pattern' for ALL entries from /wp/v2/block-patterns/patterns, regardless of REST source field. The lock icon comes from: isItemClickable: (item) => item.type !== PATTERN_TYPES.theme Setting source:'user' (Round 5) had no effect because type is assigned in JS, not derived from the REST source field. Fix (two-part): 1. source:'core' on faux patterns EXCLUDED_PATTERN_SOURCES = ['core', 'pattern-directory/core', ...] edit-site.js filters these OUT of selectThemePatterns. Faux entries with source:'core' never reach type:PATTERN_TYPES.theme, so isItemClickable never fires false for them. Note: block-editor.js getAllPatterns does NOT filter by EXCLUDED_PATTERN_SOURCES, so Starter Patterns continues to work. 2. Restore /wp/v2/blocks LIST injection Patterns appear via selectUserPatterns (getEntityRecords('postType','wp_block')) → type:PATTERN_TYPES.user → isItemClickable returns true → NO lock icon. Trade-off: theme patterns also appear in block inserter 'My Patterns' tab (all wp_block entries are type:user in block-editor.js). This is the same pre-Round-4 behavior and is acceptable. Verified in WP 6.9.4 wp-env (WP-CLI simulation of edit-site.js data flow): selectThemePatterns: 0 locked type:theme entries selectUserPatterns: 7 editable type:user entries Starter Patterns: 1 match (theme-restrictions-test, no dup) WP_Error guard: in place, returns 401 for unauthenticated --- includes/class-pattern-builder-api.php | 101 +++++++++++++++++-------- 1 file changed, 71 insertions(+), 30 deletions(-) diff --git a/includes/class-pattern-builder-api.php b/includes/class-pattern-builder-api.php index ccab1e4..0dbb3eb 100644 --- a/includes/class-pattern-builder-api.php +++ b/includes/class-pattern-builder-api.php @@ -215,17 +215,31 @@ function ( $pattern ) { } /** - * Injects theme patterns into individual /wp/v2/blocks/{id} REST responses. + * Injects theme patterns into /wp/v2/blocks REST responses. * - * When the Site Editor or Pattern Builder sidebar requests a specific block by ID, - * and that ID belongs to a tbell_pattern_block post, this filter returns a correctly - * formatted response so the pattern can be opened and edited. + * ### List injection (/wp/v2/blocks — GET) * - * Note: Theme patterns are intentionally NOT injected into the /wp/v2/blocks LIST - * response. The list is used by Gutenberg's getAllPatterns merge, and theme patterns - * are now served exclusively via the /wp/v2/block-patterns/patterns endpoint - * (see inject_faux_patterns()). Keeping them out of the blocks list prevents - * the same content from appearing in both "My Patterns" and "Theme" inserter tabs. + * 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. @@ -233,27 +247,45 @@ function ( $pattern ) { * @return WP_REST_Response */ public function inject_theme_patterns( $response, $server, $request ) { - if ( ! preg_match( '#/wp/v2/blocks/(?P\d+)#', $request->get_route(), $matches ) ) { - return $response; - } + 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 ) { + return $response; + } - $block_id = intval( $matches['id'] ); - $tbell_pattern_block = get_post( $block_id ); + // 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; + } - if ( ! $tbell_pattern_block || 'tbell_pattern_block' !== $tbell_pattern_block->post_type ) { - 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 ); - // 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; + return new WP_REST_Response( $data ); } - $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 ); + 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; + } - return new WP_REST_Response( $data ); + $data = $response->get_data(); + $patterns = $this->controller->get_block_patterns_from_theme_files(); + + foreach ( $patterns as $pattern ) { + $post = $this->controller->get_tbell_pattern_block_post_for_pattern( $pattern ); + $data[] = $this->format_tbell_pattern_block_response( $post, $request ); + } + + $response->set_data( $data ); + } + + return $response; } /** @@ -294,6 +326,11 @@ public function inject_faux_patterns( $response, $server, $request ) { 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 ) ) { @@ -343,15 +380,19 @@ public function inject_faux_patterns( $response, $server, $request ) { // Start from the existing registry entry (preserves all metadata) and override only // the fields that Pattern Builder controls. // - // source: 'user' — removes the lock icon; WordPress treats source:'theme' as read-only - // in the "All patterns" Site Editor page. - // id: post ID — enables the "Edit" button to route to /wp/v2/blocks/{id}, which - // Pattern Builder's inject_theme_patterns() hook already intercepts to serve - // the correct editable data. + // 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'] = 'user'; + $faux['source'] = 'core'; $faux['id'] = $post->ID; $data[ $key ] = $faux;