Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .wp-env.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"core": "WordPress/WordPress",
"core": "WordPress/WordPress#6.9.4",
"port": 8498,
"testsPort": 8499,
"plugins": [ "." ],
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
19 changes: 15 additions & 4 deletions includes/class-pattern-builder-abstract-pattern.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'.
*
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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'],
Expand Down
224 changes: 199 additions & 25 deletions includes/class-pattern-builder-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down Expand Up @@ -214,41 +215,68 @@ 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.
* @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.
if ( preg_match( '#/wp/v2/blocks/(?P<id>\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 );
Expand All @@ -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 `<!-- wp:block {"ref": ID} /-->` 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
? '<!-- wp:block {"ref":' . $post->ID . '} /-->'
: $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.
*
Expand Down Expand Up @@ -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 {

Expand All @@ -331,17 +488,34 @@ public function register_patterns(): void {
$pattern_content = '<!-- wp:block {"ref":' . $post->ID . '} /-->';
}

/*
* 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;
}

Expand Down