Skip to content
Merged
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
26 changes: 26 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,32 @@ This file provides guidance to Claude Code and other AI coding agents when worki

## Development Environment

## Architecture (Key Design Decisions)

A full architectural analysis is in [`docs/architecture.md`](docs/architecture.md). Key decisions to understand before working in this codebase:

**The core problem:** WordPress's block editor can only edit things with a post ID. File-based theme patterns (`.php` files in `/patterns/`) have no post ID. The plugin solves this with a **DB mirror + REST hijacking** strategy.

**DB Mirror (`tbell_pattern_block` CPT):** Each theme pattern file gets a corresponding `tbell_pattern_block` post that gives it a database identity. This post is the source of the post ID the editor needs. The file remains the source of truth; the DB record is kept in sync.

**REST Hijacking:** The plugin intercepts `/wp/v2/blocks` requests at three filter points:
- `rest_request_after_callbacks` (GET) β€” injects theme pattern posts into the blocks response so the editor sees them alongside user patterns
- `rest_pre_dispatch` (PUT/DELETE) β€” intercepts saves and deletes before the real handler runs, writing changes to the PHP file on disk instead of (or in addition to) the DB

**Pattern Registration (on `init`):** On every page load, the plugin globs the theme's `/patterns/` directory and upserts DB records for any new or changed patterns. This is a known performance issue (TWE-369) β€” no caching yet.

**Editor Integration:** Two things happen in the editor:
- `syncedPatternFilter` intercepts `core/pattern` blocks to enable editing synced theme patterns in context
- `PatternPanelAdditionsPlugin` adds sidebar panels (Source, Sync Status, Associations) when editing a `wp_block` post

**Admin Page:** Plain PHP (Appearance β†’ Pattern Builder). Links to documentation. No JS.

**Companion Plugin:** [`synced-patterns-for-themes`](https://github.com/Twenty-Bellows/synced-patterns-for-themes) is a read-only subset of this plugin for production use. It uses the same REST hijacking approach but blocks edits. It self-deactivates when Pattern Builder is active.

---

## Development Environment

### Prerequisites
- Node.js (v18+ recommended)
- PHP 7.2+ with Composer
Expand Down
2 changes: 1 addition & 1 deletion bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
* Manually load the plugin being tested.
*/
function _manually_load_plugin() {
// Load composer autoloader here, after WordPress core is loaded
// Load composer autoloader here, after WordPress core is loaded.
require __DIR__ . '/vendor/autoload.php';

require __DIR__ . '/pattern-builder.php';
Expand Down
671 changes: 671 additions & 0 deletions docs/architecture.md

Large diffs are not rendered by default.

150 changes: 132 additions & 18 deletions includes/class-pattern-builder-abstract-pattern.php
Original file line number Diff line number Diff line change
@@ -1,28 +1,119 @@
<?php
// phpcs:disable WordPress.NamingConventions.ValidVariableName -- camelCase properties intentionally mirror the JS AbstractPattern class.

namespace TwentyBellows\PatternBuilder;

/**
* Value object representing a single block pattern.
*
* Property names intentionally use camelCase to mirror the JavaScript AbstractPattern class,
* keeping PHP and JS representations symmetrical and reducing mapping friction.
*/
class Abstract_Pattern {

/**
* Post ID (tbell_pattern_block or wp_block).
*
* @var int|null
*/
public $id;

/**
* Pattern slug (namespaced, e.g. "theme-slug/pattern-name").
*
* @var string
*/
public $name;

/**
* Human-readable pattern title.
*
* @var string
*/
public $title;

/**
* Short description shown in the inserter.
*
* @var string
*/
public $description;

/**
* Raw block markup content.
*
* @var string
*/
public $content;

/**
* Array of category slugs.
*
* @var array
*/
public $categories;

/**
* Array of keyword strings.
*
* @var array
*/
public $keywords;

public $blockTypes;
public $templateTypes;
public $postTypes;
/**
* Array of block type slugs this pattern applies to.
*
* @var array
*/
public $blockTypes; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase

/**
* Array of template type slugs this pattern applies to.
*
* @var array
*/
public $templateTypes; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase

/**
* Array of post type slugs this pattern applies to.
*
* @var array
*/
public $postTypes; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase

/**
* Pattern source: 'theme' or 'user'.
*
* @var string
*/
public $source;

/**
* Whether the pattern is synced.
*
* @var bool
*/
public $synced;

/**
* Whether the pattern appears in the block inserter.
*
* @var bool
*/
public $inserter;
public $filePath;

/**
* Absolute filesystem path to the pattern PHP file (theme patterns only).
*
* @var string|null
*/
public $filePath; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase

/**
* Constructor.
*
* @param array $args Pattern arguments.
*/
public function __construct( $args = array() ) {
$this->id = $args['id'] ?? null;

Expand All @@ -40,20 +131,31 @@ public function __construct( $args = array() ) {
$this->categories = $args['categories'] ?? array();
$this->keywords = $args['keywords'] ?? array();

$this->blockTypes = $args['blockTypes'] ?? array();
$this->templateTypes = $args['templateTypes'] ?? array();
$this->postTypes = $args['postTypes'] ?? array();
$this->blockTypes = $args['blockTypes'] ?? array(); // phpcs:ignore WordPress.NamingConventions.ValidVariableName
$this->templateTypes = $args['templateTypes'] ?? array(); // phpcs:ignore WordPress.NamingConventions.ValidVariableName
$this->postTypes = $args['postTypes'] ?? array(); // phpcs:ignore WordPress.NamingConventions.ValidVariableName

$this->filePath = $args['filePath'] ?? null;
$this->filePath = $args['filePath'] ?? null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName
}

/**
* Renders a pattern PHP file using output buffering.
*
* @param string $pattern_file Absolute path to the pattern file.
* @return string Rendered pattern content.
*/
private static function render_pattern( $pattern_file ) {
ob_start();
include $pattern_file;
return ob_get_clean();
}


/**
* Creates an Abstract_Pattern from a theme pattern PHP file.
*
* @param string $pattern_file Absolute path to the pattern file.
* @return self
*/
public static function from_file( $pattern_file ) {
$pattern_data = get_file_data(
$pattern_file,
Expand All @@ -79,20 +181,26 @@ 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'] ),
'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'] ),
'categories' => '' === $pattern_data['categories'] ? array() : explode( ',', $pattern_data['categories'] ),
'keywords' => '' === $pattern_data['keywords'] ? array() : 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'] ),
'source' => 'theme',
'synced' => $pattern_data['synced'] === 'yes' ? true : false,
'inserter' => $pattern_data['inserter'] !== 'no' ? true : false,
'synced' => 'yes' === $pattern_data['synced'],
'inserter' => 'no' !== $pattern_data['inserter'],
)
);

return $new;
}

/**
* Creates an Abstract_Pattern from a registered block pattern array.
*
* @param array $pattern The registered pattern array from WP_Block_Patterns_Registry.
* @return self
*/
public static function from_registry( $pattern ) {
return new self(
array(
Expand All @@ -113,6 +221,12 @@ public static function from_registry( $pattern ) {
);
}

/**
* Creates an Abstract_Pattern from a WP_Post object (wp_block or tbell_pattern_block).
*
* @param \WP_Post $post The post object.
* @return self
*/
public static function from_post( $post ) {
$metadata = get_post_meta( $post->ID );
$categories = wp_get_object_terms( $post->ID, 'wp_pattern_category' );
Expand All @@ -132,7 +246,7 @@ function ( $category ) {
'title' => $post->post_title,
'description' => $post->post_excerpt,
'content' => $post->post_content,
'source' => ( $post->post_type === 'tbell_pattern_block' ) ? 'theme' : 'user',
'source' => ( 'tbell_pattern_block' === $post->post_type ) ? 'theme' : 'user',
'synced' => ( $metadata['wp_pattern_sync_status'][0] ?? 'synced' ) !== 'unsynced',

'blockTypes' => isset( $metadata['wp_pattern_block_types'][0] ) ? explode( ',', $metadata['wp_pattern_block_types'][0] ) : array(),
Expand All @@ -141,7 +255,7 @@ function ( $category ) {

'keywords' => isset( $metadata['wp_pattern_keywords'][0] ) ? explode( ',', $metadata['wp_pattern_keywords'][0] ) : array(),
'categories' => $categories,
'inserter' => isset( $metadata['wp_pattern_inserter'][0] ) ? ( $metadata['wp_pattern_inserter'][0] === 'no' ? false : true ) : true,
'inserter' => isset( $metadata['wp_pattern_inserter'][0] ) ? ( 'no' !== $metadata['wp_pattern_inserter'][0] ) : true,
)
);
}
Expand Down
60 changes: 19 additions & 41 deletions includes/class-pattern-builder-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,48 +28,26 @@ public function create_admin_menu(): void {
}

/**
* Renders the admin menu page.
* Renders the admin menu page as a plain PHP page (no React build required).
*/
public function render_admin_menu_page(): void {
$this->enqueue_assets();
echo '<div id="pattern-builder-app"></div>';
}

/**
* Enqueues the necessary assets for the admin page.
*/
private function enqueue_assets(): void {

$screen = get_current_screen();

if ( $screen->id !== 'appearance_page_pattern-builder' ) {
return;
}

$asset_file = include plugin_dir_path( __FILE__ ) . '../build/PatternBuilder_Admin.asset.php';

wp_enqueue_script(
'pattern-builder-app',
plugins_url( '../build/PatternBuilder_Admin.js', __FILE__ ),
$asset_file['dependencies'],
$asset_file['version']
);

wp_enqueue_style(
'pattern-builder-editor-style',
plugins_url( '../build/PatternBuilder_Admin.css', __FILE__ ),
array(),
$asset_file['version']
);

// Enqueue core editor styles
// wp_enqueue_style( 'wp-edit-blocks' ); // Block editor base styles
// wp_enqueue_style( 'wp-block-library' ); // Front-end block styles
// wp_enqueue_style( 'wp-block-editor' ); // Editor layout styles

// Enqueue media library assets
// wp_enqueue_media();

wp_set_script_translations( 'pattern-builder-app', 'pattern-builder' );
?>
<div class="wrap">
<h1><?php echo esc_html_x( 'Pattern Builder', 'UI String', 'pattern-builder' ); ?> <span style="font-size:14px;font-weight:normal;color:#646970;"><?php esc_html_e( 'by Twenty Bellows', 'pattern-builder' ); ?></span></h1>

<p><?php esc_html_e( 'Pattern Builder adds functionality to the WordPress Editor to enhance the Pattern Building experience. All of the tools are available in the Site Editor and Block Editor β€” open a pattern there to get started.', 'pattern-builder' ); ?></p>

<h2><?php esc_html_e( 'Learn More', 'pattern-builder' ); ?></h2>
<ul>
<li><a href="https://twentybellows.com/pattern-builder-help#what-are-patterns" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'What are Patterns and how are they different than Custom Blocks?', 'pattern-builder' ); ?></a></li>
<li><a href="https://twentybellows.com/pattern-builder-help#theme-vs-user-patterns" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'What is the difference between a Theme Pattern and a User Pattern?', 'pattern-builder' ); ?></a></li>
<li><a href="https://twentybellows.com/pattern-builder-help#synced-vs-unsynced-patterns" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'What is the difference between a Synced Pattern and an Unsynced Pattern?', 'pattern-builder' ); ?></a></li>
<li><a href="https://twentybellows.com/pattern-builder-help#themes-synced-patterns" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Can Themes have Synced Patterns?', 'pattern-builder' ); ?></a></li>
<li><a href="https://twentybellows.com/pattern-builder-help#edit-theme-patterns" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Edit Theme Patterns', 'pattern-builder' ); ?></a></li>
<li><a href="https://twentybellows.com/pattern-builder-help#include-images-in-patterns" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Include image assets used in patterns in your Theme', 'pattern-builder' ); ?></a></li>
<li><a href="https://twentybellows.com/pattern-builder-help#localize-patterns" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Prepare Patterns for Localization', 'pattern-builder' ); ?></a></li>
</ul>
</div>
<?php
}
}
Loading
Loading