diff --git a/CLAUDE.md b/CLAUDE.md index b2afb12..484a006 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/bootstrap.php b/bootstrap.php index 3873066..a5e2592 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -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'; diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..d3551fb --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,671 @@ +# Pattern Builder — Architecture Deep Dive + +> **Author:** Kedalion (architectural review) +> **Date:** 2026-03-25 +> **Version reviewed:** 1.0.4 + +--- + +## Table of Contents + +1. [What This Plugin Does](#1-what-this-plugin-does) +2. [Companion Plugin: Synced Patterns for Themes](#2-companion-plugin-synced-patterns-for-themes) +3. [PHP Architecture](#3-php-architecture) +4. [JavaScript Architecture](#4-javascript-architecture) +5. [Data Flow](#5-data-flow) +6. [The REST API Hijacking Strategy](#6-the-rest-api-hijacking-strategy) +7. [Pattern File Format](#7-pattern-file-format) +8. [Slug Encoding](#8-slug-encoding) +9. [Image Import and Export](#9-image-import-and-export) +10. [Localization System](#10-localization-system) +11. [Security Layer](#11-security-layer) +12. [Test Coverage](#12-test-coverage) +13. [Issues and Defects](#13-issues-and-defects) +14. [Architectural Observations](#14-architectural-observations) +15. [Summary Scorecard](#15-summary-scorecard) + +--- + +## 1. What This Plugin Does + +Pattern Builder solves a real WordPress problem: theme patterns (stored as `.php` files in `/patterns/`) are not editable in the WordPress editor. WordPress ships them as read-only artefacts. You build them in your IDE, commit them, and that's it. + +The plugin bridges the gap by: + +- Making theme patterns appear as editable blocks in the Site Editor and Block Editor +- Allowing pattern edits to round-trip back to the PHP file on disk +- Supporting synced theme patterns (which WordPress core historically didn't support for file-based patterns) +- Providing image import (media → theme assets), localization injection, and pattern lifecycle management (create, convert between theme/user, delete) + +It does all of this without modifying WordPress core and without requiring custom block types. That's the impressive part. It's also the source of most of the complexity. + +--- + +## 2. Companion Plugin: Synced Patterns for Themes + +The companion plugin ([`Twenty-Bellows/synced-patterns-for-themes`](https://github.com/Twenty-Bellows/synced-patterns-for-themes)) is a **read-only predecessor** to Pattern Builder. They share the same conceptual approach (custom post type as DB mirror, REST hijacking, `syncedPatternFilter` JS) but the companion: + +- Only handles **synced** patterns (skips unsynced) +- **Blocks editing** — its `handle_hijack_block_update` returns a hard `403` with the message "Synced Theme Patterns cannot be updated in the editor without additional tools." Pattern Builder is those additional tools. +- Has no admin page, no image import, no localization, no custom REST endpoints +- Uses `pb_block` post type (vs `tbell_pattern_block`) +- Deactivates itself when Pattern Builder is active: + ```php + if (is_plugin_active('pattern-builder/pattern-builder.php')) { + return; + } + ``` +- Uses PSR-2 code style, not WPCS; no security wrapper class +- Its JS (`syncedPatternFilter.js`) is **identical** to Pattern Builder's — same hook, same component + +**Relationship:** The companion is the "lightweight standalone" for theme developers who want synced patterns in production without the full editing toolchain. Pattern Builder is the authoring environment that uses the same underlying mechanism. They can coexist in a developer's workflow (companion in production builds, Pattern Builder in dev/staging), and thanks to the early-return guard they won't conflict. + +**The companion's code quality is lower.** It has no path validation for file operations, role capability assignment uses the full capability list (13 caps vs Pattern Builder's focused 2), and it lacks the WPCS linting infrastructure. + +--- + +## 3. PHP Architecture + +### 3.1 Entry Point and Bootstrap + +``` +pattern-builder.php + └── Pattern_Builder::get_instance() (singleton) + ├── new Pattern_Builder_API() + ├── new Pattern_Builder_Admin() + ├── new Pattern_Builder_Editor() + └── new Pattern_Builder_Post_Type() +``` + +All wiring happens in constructors via `add_action`/`add_filter`. No static boot methods, no DI container. Simple and appropriate for a WordPress plugin of this size. + +The `require_once` chain is manual (no PSR-4 autoloading). `composer.json` has no `autoload` section — it's dev-only (PHPUnit, PHPCS). This is fine for a plugin; it just means class file locations are load-order-dependent. + +### 3.2 Class Responsibilities + +| Class | Responsibility | +|-------|---------------| +| `Pattern_Builder` | Singleton orchestrator. Instantiates the four subsystems. | +| `Pattern_Builder_API` | REST route registration, REST hijacking filters, pattern registration on `init`, block-to-pattern conversion. The largest and most complex class. | +| `Pattern_Builder_Admin` | Registers the "Appearance → Pattern Builder" admin menu page and enqueues the admin build. | +| `Pattern_Builder_Editor` | Enqueues the editor build on `enqueue_block_editor_assets`. | +| `Pattern_Builder_Post_Type` | Registers `tbell_pattern_block` CPT, assigns capabilities to roles, handles front-end rendering of `tbell_pattern_block` refs, and adds `content` attribute to `core/pattern` block type. | +| `Pattern_Builder_Controller` | Business logic: file I/O, DB upserts, image import/export, block formatter, slug encoding, pattern file metadata builder. The "model" layer. | +| `Abstract_Pattern` | Value object. PHP-side mirror of the JS `AbstractPattern` class. Factory methods: `from_file()`, `from_post()`, `from_registry()`. | +| `Pattern_Builder_Localization` | Static utility class. Transforms parsed block arrays to inject PHP localization calls around translatable strings. | +| `Pattern_Builder_Security` | Static utility class. Path validation, WP Filesystem wrappers for safe file write/delete/move. | + +### 3.3 The `tbell_pattern_block` Custom Post Type + +This CPT is the database mirror for theme pattern files. Key properties: + +- `public: true` — **problematic** (see Issues §13.5) +- `show_in_rest: true`, `rest_base: 'tbell_pattern_blocks'` +- `supports: ['title', 'editor', 'revisions']` +- `capability_type: 'tbell_pattern_block'`, `map_meta_cap: true` +- Capabilities assigned to `administrator` and `editor` roles on every `init` + +**Purpose:** When the pattern registry and the editor need a post ID to reference a synced theme pattern as a `wp:block {"ref": N}`, this CPT provides that ID. Without it, WordPress has no mechanism to give a file-based pattern a database identity. + +**Meta fields** (all `show_in_rest: true`, all stored as comma-delimited strings despite being logically arrays): + +| Meta Key | Purpose | +|----------|---------| +| `wp_pattern_sync_status` | `'unsynced'` or absent (synced) | +| `wp_pattern_block_types` | Comma-delimited block type slugs | +| `wp_pattern_template_types` | Comma-delimited template type slugs | +| `wp_pattern_post_types` | Comma-delimited post type slugs | +| `wp_pattern_inserter` | `'no'` or absent (visible) | + +Note: `wp_pattern_keywords` is registered as REST meta (`type: 'string', single: true`) matching the comma-separated storage used by the controller. + +### 3.4 Pattern Registration Flow (on `init`, priority 9) + +``` +glob(stylesheet_directory/patterns/*.php) + → for each file: + Abstract_Pattern::from_file($file) + → get_file_data() (parse PHP header comments) + → ob_start() + include() (execute PHP to get rendered content) + create_tbell_pattern_block_post_for_pattern($pattern) + → get_page_by_path() to find existing post + → wp_insert_post() or wp_update_post() + → wp_set_object_terms() for categories + unregister if already registered + register with inserter: false + → synced: content = + → unsynced: content = rendered PHP output +``` + +This runs on **every page load**. Every theme pattern means at least one `get_page_by_path()` query and a conditional upsert. For themes with 10+ patterns, this adds measurable database load on every request, not just on admin pages. + +--- + +## 4. JavaScript Architecture + +### 4.1 Build System + +Two entry points via `webpack.config.js` (extends `@wordpress/scripts` default): + +| Build | Output | Purpose | +|-------|--------|---------| +| `PatternBuilder_Admin.js` | `build/PatternBuilder_Admin.{js,css}` | Admin page UI | +| `PatternBuilder_EditorTools.js` | `build/PatternBuilder_EditorTools.{js,css}` | Block/site editor integration | + +Asset dependencies are auto-generated by `@wordpress/scripts` as `.asset.php` files and used in `wp_enqueue_script()`. + +### 4.2 Admin Page + +The admin page (Appearance → Pattern Builder) is rendered as a **plain PHP page** — no React, no JS build artifact. `Pattern_Builder_Admin` registers the menu entry and renders a simple `
` with a title, a description paragraph, and a list of help links pointing to `twentybellows.com/pattern-builder-help`. + +There is no admin JS entry point. The admin page requires no asset enqueue. + +### 4.3 Editor Build (`PatternBuilder_EditorTools.js`) + +Three plugins registered: + +``` +registerPlugin('pattern-builder-editor-side-panel', EditorSidePanel) +registerPlugin('pattern-builder-pattern-panel-additions', PatternPanelAdditionsPlugin) +registerPlugin('pattern-builder-save-monitor', PatternSaveMonitor) +``` + +**`EditorSidePanel`** — A `PluginSidebar` with a `Navigator` (multi-screen router): +- `/` → category list + "Create Pattern" + "Configuration" buttons +- `/create` → `PatternCreatePanel` (create new pattern via POST to `/wp/v2/blocks`) +- `/browse/:category` → `PatternBrowserPanel` → `PatternList` → `PatternPreview` +- `/configuration` → `PatternBuilderConfiguration` + +Pattern data is fetched once on mount via `fetchAllPatterns()` (direct `apiFetch`, not the Redux store). Categories are derived via `useMemo`. No refresh mechanism after mount. + +**`PatternPanelAdditionsPlugin`** — Adds three `PluginDocumentSettingPanel` panels when editing a `wp_block` post: +- `PatternSourcePanel` — theme/user toggle, dispatches to `core` store +- `PatternSyncedStatusPanel` — synced/unsynced toggle, dispatches to `core` store +- `PatternAssociationsPanel` — block types, post types, template types, inserter visibility + +**`PatternSaveMonitor`** — Invisible component. Uses `apiFetch.use()` to register middleware that appends `patternBuilderLocalize=true` and/or `patternBuilderImportImages=false` query params to PUT/POST requests targeting pattern endpoints, based on `localStorage` settings. + +**`syncedPatternFilter`** — Hooks into `editor.BlockEdit`: +```js +addFilter('editor.BlockEdit', 'pattern-builder/pattern-edit', syncedPatternFilter); +``` +Intercepts `core/pattern` blocks that have both `slug` and `content`. If the pattern's content resolves to a single `core/block` (a reusable block reference), renders `SyncedPatternRenderer` instead. `SyncedPatternRenderer` uses `queueMicrotask` + `registry.batch()` to replace the pattern block with cloned inner blocks, passing `content` as block overrides. + +### 4.4 State Management + +There is no custom Redux store in the current build. `store.js` was removed — it imported `deletePattern`, `fetchEditorConfiguration`, and `savePattern` from `resolvers.js`, none of which existed. The store was scaffolding for an admin app that was never built. + +Pattern data in the editor build is managed via direct `apiFetch` calls (in `EditorSidePanel`) and by dispatching to the WordPress core `'core'` store (in `PatternPanelAdditionsPlugin`). No custom `'pattern-builder'` store exists. + +### 4.5 `AbstractPattern` (JS) + +Mirror of the PHP `Abstract_Pattern`. Key addition: a `getBlocks()` method that lazily parses `content` via `@wordpress/blocks`'s `parse()` and caches the result in `_blocks`. Used by `PatternPreview` to render a `BlockPreview`. + +--- + +## 5. Data Flow + +### 5.1 Normal Edit Flow + +``` +User opens Site Editor + → editor loads /wp/v2/blocks (GET) + → inject_theme_patterns filter appends tbell_pattern_block posts + masquerading as wp_block posts + → PatternPanelAdditions shows source/sync/association panels + +User edits pattern content, clicks Save + → editor sends PUT /wp/v2/blocks/{id} + → WordPress REST API dispatches to rest_pre_dispatch filters first + → handle_hijack_block_update intercepts (pre-dispatch, runs BEFORE the real handler) + → identifies post as tbell_pattern_block + → parses updated_pattern from body + → optionally: import images (downloads to theme/assets/images/) + → optionally: localize (inject PHP i18n calls) + → update_theme_pattern_file() → writes PHP file via WP Filesystem + → wp_update_post() → syncs DB post + → returns formatted response (post appearing as wp_block) + → real handler is skipped (pre_dispatch returned non-null) + +Page renders with pattern + → content is + → filter_pattern_block_attributes (pre_render_block) intercepts + → reconstructs + → do_blocks() recurses + → render_tbell_pattern_blocks (render_block) intercepts core/block with tbell_pattern_block ref + → renders post_content via do_blocks() +``` + +### 5.2 Convert Theme Pattern → User Pattern + +``` +User changes Source from "Theme" to "User" in sidebar panel + → dispatches editEntityRecord to core store + → core store sends PUT /wp/v2/blocks/{id} with {source: "user"} + → handle_hijack_block_update intercepts + → calls update_user_pattern() + → export_pattern_image_assets() (move theme assets → media library) + → wp_insert_post() as wp_block (new post) + → delete tbell_pattern_block post + → delete PHP pattern file +``` + +### 5.3 Convert User Pattern → Theme Pattern + +``` +User changes Source from "User" to "Theme" + → PUT /wp/v2/blocks/{id} with {source: "theme"} + → handle_hijack_block_update detects wp_block post + source=theme + → sets convert_user_pattern_to_theme_pattern = true + → calls update_theme_pattern() + → prepends theme slug to pattern name: theme-slug/pattern-name + → writes PHP file + → wp_update_post() as tbell_pattern_block (changes post_type) +``` + +--- + +## 6. The REST API Hijacking Strategy + +This is the architectural heart of the plugin. WordPress doesn't provide an extension point for "I want to make file-based patterns editable as if they were `wp_block` posts." The plugin achieves this by filtering the REST API at three points: + +| Filter | Hook | Purpose | +|--------|------|---------| +| `rest_pre_dispatch` | 2× | Intercept PUT (update) and DELETE before the real handler runs | +| `rest_request_after_callbacks` | 1× | Intercept GET responses to inject theme patterns | +| `rest_request_before_callbacks` | 1× | Intercept PUT/POST to convert `wp:block` refs to `wp:pattern` | + +**Why `rest_pre_dispatch` for mutations?** This hook runs _before_ the actual REST controller. By returning a response from `rest_pre_dispatch`, the real handler is never invoked. For updates/deletes of `tbell_pattern_block` posts, this is essential because the real `WP_REST_Blocks_Controller` doesn't know about this custom post type and would either fail or corrupt data. + +**Why `rest_request_after_callbacks` for reads?** This hook runs _after_ the real handler. The plugin can get the existing response data and append theme pattern entries to it. Injection is additive — it doesn't replace the real response. + +**The `format_tbell_pattern_block_response()` trick:** To format a `tbell_pattern_block` post as a proper `wp_block` REST response, the plugin temporarily sets `post->post_type = 'wp_block'`, then passes it to `WP_REST_Blocks_Controller::prepare_item_for_response()`. This is a dirty hack — mutating the post object in memory — but it produces a correctly formatted response without reimplementing the entire REST serialization logic. + +--- + +## 7. Pattern File Format + +Pattern files are PHP files with a PHPDoc-style header block. This is standard WordPress pattern file format, with one addition: the `Synced` header. + +```php + +

My Pattern

+``` + +The PHP body is executed with `ob_start()` + `include()`, so it can contain `` and similar. After localization processing, the body will contain PHP i18n calls. + +The `build_pattern_file_metadata()` method reconstructs this header from an `Abstract_Pattern` object when writing files. There is a TODO in `get_patterns()` about slugs not surviving the round-trip — this is acknowledged technical debt. + +--- + +## 8. Slug Encoding + +WordPress `post_name` columns don't support `/`. Theme pattern slugs are namespaced: `theme-slug/pattern-name`. The plugin encodes this as `theme-slug-x-x-pattern-name` for DB storage. + +```php +str_replace('/', '-x-x-', $slug) // encode for DB +str_replace('-x-x-', '/', $slug) // decode from DB +``` + +This is a sentinel-based encoding with no escaping. A pattern whose base name legitimately contains `-x-x-` will be silently corrupted on decode. In practice, this is unlikely, but it's not safe in principle. A URL-safe base64 or hex encoding of the full slug would be more correct. + +--- + +## 9. Image Import and Export + +### Import (user → theme assets) + +When saving a theme pattern, `import_pattern_image_assets()`: + +1. Finds URLs matching `src="HOME_URL/..."` and `"url":"HOME_URL/..."` via regex +2. Downloads them with `download_url()` +3. Saves to `{stylesheet_directory}/assets/images/{filename}` +4. Replaces URLs with `` + +There's a Docker workaround: if download fails on `localhost:PORT`, it retries on `localhost:80`. This is hardcoded dev-environment logic in production code. + +### Export (theme assets → media library) + +When converting a theme pattern to a user pattern, `export_pattern_image_assets()`: + +1. Finds URLs matching `src="HOME_URL/..."` and `"url":"HOME_URL/..."` +2. Tries to `copy()` from local file path first, falls back to `download_url()` +3. Uploads to media library via `wp_insert_attachment()` +4. Replaces PHP template tags with media library URLs + +**Issues:** Both methods only scan two URL patterns. Block JSON can encode image URLs in many other attributes (`href`, `backgroundUrl`, `url` inside deeply nested JSON, etc.). The regex approach is inherently incomplete for block markup. + +--- + +## 10. Localization System + +`Pattern_Builder_Localization::localize_pattern_content()` wraps translatable strings in PHP i18n calls: + +``` +parse_blocks(content) + → localize_blocks() [recursive] + → switch on blockName + → core/paragraph, core/heading, etc. → localize_text_block() + → core/button → localize_button_block() + → core/image → localize_image_block() + → ... + → serialize_blocks() + → str_replace('\u003c', '<', ...) + str_replace('\u003e', '>', ...) +``` + +**The `\u003c` hack:** `serialize_blocks()` JSON-encodes block attributes. PHP tags (`<` `>`) embedded in attribute values get Unicode-escaped to `\u003c` and `\u003e`. The post-processing replaces these back to literal `<>`. This works correctly because the block comment format treats attribute values as JSON inside `` comments — when the PHP file is executed, the PHP runs first and produces the translated string, then WordPress parses the result as block markup. + +**Text domain:** Uses `get_stylesheet()` (the active theme's slug). This is correct for the pattern's context but means patterns can't be localized to a different text domain. + +**Coverage:** The localization handles ~15 block types. Many blocks with translatable content are not handled (e.g., `core/navigation-link`, `core/site-title`, `core/post-title`, custom blocks). The test file explicitly marks `core/navigation-link` as intentionally not localized. This is acceptable as opt-in tooling, but should be documented clearly. + +--- + +## 11. Security Layer + +`Pattern_Builder_Security` is a dedicated static utility class for file operations. This is a good architectural decision — it centralises path validation instead of scattering it across the codebase. + +### Path Validation + +```php +validate_file_path($path, $allowed_dirs) + → for existing files: realpath() → check prefix against allowed_dirs + → for new files: wp_normalize_path() → check prefix + → also checks for literal '../' or '..\' in normalized path +``` + +The approach is correct for existing files (realpath resolves symlinks and traversals). For non-existing files (new patterns being written), it uses string prefix matching on the normalized path. A path like `theme_dir/../../evil` after `wp_normalize_path()` may still start with the theme directory string if wp_normalize_path doesn't resolve `..`. This is a potential but impractical bypass — pattern writes go to `{stylesheet_directory}/patterns/` with filenames from `sanitize_file_name(basename($pattern->name))`, which removes directory separators. + +### Capability Check Duplication + +`handle_hijack_block_update` checks `current_user_can('edit_tbell_pattern_blocks')` inline. The `write_permission_callback` (used for the custom `/pattern-builder/v1/` routes) also manually calls `wp_verify_nonce()`. For the hijacked `/wp/v2/blocks` routes, WordPress's built-in nonce validation already applies — the explicit nonce check in custom routes is redundant. This inconsistency is harmless but confusing. + +--- + +## 12. Test Coverage + +### PHP Tests + +**`test-pattern-builder-api.php`** (integration tests, WP_UnitTestCase): + +Good coverage of the full REST lifecycle: +- GET `/wp/v2/blocks` with synced and unsynced theme patterns +- GET `/wp/v2/block-patterns/patterns` (core pattern registry) +- GET `/pattern-builder/v1/patterns` +- PUT to update content, title, description +- PUT to convert theme → user pattern (verifies file deletion, DB post type change) +- PUT to convert user → theme pattern +- PUT to convert back and forth +- PUT to update restrictions (block types, post types, template types) +- DELETE +- Image-containing pattern conversion + +The test setup correctly redirects `stylesheet_directory` to a temp directory and cleans up after each test. A good, realistic integration test harness. + +**`test-pattern-localization.php`** (unit tests, WP_UnitTestCase): + +Thorough coverage of the localization engine: +- All major block types +- Single quotes, empty content, non-translatable blocks +- Details block duplicate closing tag regression test +- Search block partial/full attribute localization +- Query pagination, post excerpt + +### JS Tests + +**`tests/unit/util.test.js`**: +```js +it('should have unit tests', async () => { + expect(true).toBe(true); +}); +``` + +This is a placeholder. Zero actual JS test coverage. The `formatBlockMarkup` function (duplicated in JS and PHP) has no equivalence tests. The `AbstractPattern` class, `store.js`, `resolvers.js`, `syncedPatternFilter` — none are tested. + +--- + +## 13. Issues and Defects + +### 13.1 ✅ Fixed: `array_find()` requires PHP 8.4 + +`Pattern_Builder_Controller` uses `array_find()` in two places: + +```php +// get_pattern_filepath() +$pattern = array_find($patterns, function($p) use ($pattern) { + return $p->name === $pattern->name; +}); + +// remap_patterns() +$pattern = array_find($all_patterns, function($p) use ($pattern_slug) { + return sanitize_title($p->name) === sanitize_title($pattern_slug); +}); +``` + +`array_find()` was introduced in **PHP 8.4**. The plugin declares `Requires PHP: 7.2`. On any PHP version below 8.4, these calls will cause a fatal error. **Both sites and `get_pattern_filepath()` is a central function called on every pattern save.** + +**Fix applied:** Replaced with `array_filter()` + `reset()`. `Requires PHP` updated from 7.2 → 7.4 (the codebase was already using 7.4 features; 7.2 was inaccurate). + +### 13.2 ✅ Fixed: `store.js` imports non-existent functions + +```js +import { + deletePattern, + fetchEditorConfiguration, + savePattern, // ← undefined + fetchAllPatterns, +} from './resolvers'; +``` + +`resolvers.js` only exports `fetchAllPatterns`. The three missing exports mean the store's thunk actions (`deleteActivePattern`, `fetchEditorConfiguration`, `saveActivePattern`) would throw `TypeError: deletePattern is not a function` if ever called. + +**Fix applied:** `store.js` was deleted entirely. The admin page was also stripped to plain PHP, removing the need for an admin JS build altogether. See §4.2. + +### 13.3 ✅ Fixed: Capabilities assigned on every `init` + +```php +foreach ($roles as $role_name) { + $role = get_role($role_name); + if ($role) { + foreach ($capabilities as $capability) { + $role->add_cap($capability); // ← DB write if not already set + } + } +} +``` + +`WP_Role::add_cap()` calls `update_option()` if the capability isn't already stored. On the first run this is a DB write per capability per role. On subsequent runs it's a read+no-op. But checking it on every `init` (every request) is wasteful. **Fix applied:** Moved to `register_activation_hook`. A corresponding `register_deactivation_hook` removes the custom capabilities on deactivation. + +### 13.4 🟡 Open: DB upsert on every page load + +`register_patterns()` calls `create_tbell_pattern_block_post_for_pattern()` for every theme pattern file on every request. This executes at minimum: +- 1× `get_page_by_path()` (DB query) per pattern +- 1× `wp_insert_post()` or `wp_update_post()` per pattern if data changed +- 1× `wp_set_object_terms()` per pattern + +For a theme with 20 patterns, this is 40–60+ queries on every page load, every admin page, every REST request. Pattern data should be cached (transient) and invalidated only when pattern files change (using `filemtime()`). + +### 13.5 ✅ Fixed: `tbell_pattern_block` is `public: true` + +```php +$args = array( + 'public' => true, // ← exposes posts at frontend URLs + ... +); +``` + +**Fix applied:** Changed to `public: false` with explicit `show_in_rest: true` and `show_ui: true` preserved. Pattern posts no longer have public frontend URLs. + +### 13.6 ✅ Fixed: Rules of Hooks violation in `syncedPatternFilter` + +```js +export const syncedPatternFilter = (BlockEdit) => (props) => { + const { name, attributes } = props; + + if (name === 'core/pattern' && attributes.slug && attributes.content) { + const selectedPattern = useSelect( // ← Hook called inside conditional + ... + ); + } + return ; +}; +``` + +**Fix applied:** `useSelect` moved to the top of the component, called unconditionally. The conditional logic (whether the pattern matches) is now evaluated inside the selector callback, with an updated dependency array of `[name, attributes.slug]`. + +### 13.7 ✅ Fixed: Admin page was an empty React shell + +`PatternBuilder_Admin.js` rendered a welcome page with documentation links — a JS build artifact for what was purely a link list. **Fix applied:** Admin page converted to plain PHP rendering. The JS entry point, `AdminLandingPage.js`, `AdminLandingPage.scss`, and the webpack admin entry are all removed. + +### 13.8 🟠 Open: Slug encoding sentinel `-x-x-` is not escaped + +Any pattern slug containing `-x-x-` literally (e.g., `theme/pattern-x-x-name`) will be encoded to `theme-x-x-pattern-x-x-x-x-name` and decoded incorrectly as `theme/pattern/x/x/name`. A collision is unlikely in practice but should be addressed. Consider `str_replace('/', '__SLASH__', $slug)` with a more descriptive (and less likely to collide) sentinel, or use `base64url_encode`. + +### 13.9 🟠 Open: `get_block_patterns_from_theme_files()` ignores parent theme + +```php +$pattern_files = glob(get_stylesheet_directory() . '/patterns/*.php'); +``` + +Only child theme (stylesheet) patterns are indexed. If a parent theme provides patterns, they won't appear in the Pattern Builder. Both the companion plugin and Pattern Builder have this limitation. + +### 13.10 🟠 Open: No refresh after initial pattern fetch in EditorSidePanel + +```js +useEffect(() => { + fetchAllPatterns().then(setAllPatterns).catch(console.error); +}, []); +``` + +Patterns are fetched once on sidebar open. If a pattern is created or updated elsewhere in the editor, the sidebar list won't update. This creates a stale state problem in common multi-step workflows (create a pattern, go back to browse). + +### 13.11 🟠 Low: `formatBlockMarkup` is duplicated + +The block markup formatter exists in both `src/utils/formatters.js` and `includes/class-pattern-builder-controller.php`. They're meant to produce identical output but there are subtle differences (the JS version has a `formatBlockCommentJSON` function that doesn't exist in PHP; the PHP version has an `add_new_lines_to_block_markup` that eliminates blank lines differently). Any divergence will cause a diff on every save cycle if a pattern is round-tripped through both formatters. + +### 13.12 🟠 Low: Settings persisted in `localStorage` instead of user meta + +Localize/import-images settings are stored in `localStorage`. They're per-browser, not per-user. On a multisite or shared device, settings won't carry over. Appropriate for a power-user dev tool, but should be documented. + +### 13.13 🟠 Low: `PatternSaveMonitor` middleware cannot be unregistered + +```js +apiFetch.use(middleware); +// Note: apiFetch doesn't have a direct way to remove middleware +``` + +In development with HMR, `useEffect` will re-run on every hot reload, accumulating middleware instances. Each save will trigger `n` middleware calls where `n` is the HMR cycle count. The params will be duplicated in the URL (e.g., `?patternBuilderLocalize=true&patternBuilderLocalize=true&...`). In production this is a non-issue but it creates dev-time confusion. + +### 13.14 🟠 Low: `PatternCreatePanel` creates via `/wp/v2/blocks` but doesn't handle the `source: 'theme'` conversion + +```js +const createPatternCall = (pattern) => { + return apiFetch({ + path: '/wp/v2/blocks', + method: 'POST', + body: JSON.stringify(pattern), + ... + }); +}; +``` + +When `source: 'theme'` is set in the create form, this POST goes to the standard core blocks endpoint. But `handle_hijack_block_update` only fires on PUT, not POST. The `handle_block_to_pattern_conversion` filter fires on POST but only does block reference rewriting — it doesn't handle the `source` conversion. + +**Result:** Creating a pattern with Source: "Theme" in `PatternCreatePanel` creates a `wp_block` (user pattern), not a `tbell_pattern_block` (theme pattern). The source toggle is silently ignored on creation. The user would need to create the pattern, then change its source in the panel afterward. + +### 13.15 🟠 Low: `handle_hijack_block_update` reads `wp_pattern_inserter` inconsistently + +```php +if (isset($updated_pattern['wp_pattern_inserter'])) { + $pattern->inserter = $updated_pattern['wp_pattern_inserter'] === 'no' ? false : true; +} +``` + +But in `PatternAssociationsPanel`, the value dispatched is `'yes'` or `'no'`: +```js +changePatternInserter: value ? 'yes' : 'no' +``` + +And in the meta registration, the stored value is `'no'` (when hidden). The server-side check `=== 'no' ? false : true` would interpret `'yes'` as truthy (not `'no'`), so `'yes'` → `inserter: true`. That happens to work, but storing `'yes'` and checking `!== 'no'` is the implicit logic — it's fragile because any non-`'no'` string (including a bug) would be treated as "visible." + +--- + +## 14. Architectural Observations + +### 14.1 The Core Tension + +The plugin's fundamental challenge is that WordPress's block pattern system has two distinct identities for patterns: + +1. **File-based patterns** — registered via PHP headers, rendered on the fly +2. **Database patterns** (`wp_block`) — stored as posts, editable in the editor + +These two systems have completely different APIs, lifecycles, and capabilities. Pattern Builder bridges them by maintaining a **DB mirror** (`tbell_pattern_block`) that is kept in sync with the file system. This dual-write architecture is the right approach — the alternatives (making WordPress edit files directly, or abandoning the file format) are worse. But it creates the complexity of keeping two representations in sync, which requires careful handling on every create/update/delete/rename operation. + +### 14.2 The Hijacking Strategy Is the Right Trade-Off + +REST hijacking is messy but pragmatic. The alternative — implementing a completely custom pattern editor outside of the standard block editor's pattern context — would require far more code and wouldn't integrate seamlessly with the existing editor pattern panels. The approach works within WordPress's architecture rather than fighting it. + +The risk is WordPress core changes breaking the hooks. The specific hooks used (`rest_pre_dispatch`, `rest_request_after_callbacks`, `rest_request_before_callbacks`) are stable and have been part of WP for years. The `format_tbell_pattern_block_response()` technique (temporarily changing `post_type` in memory) is the most fragile piece and could break if `WP_REST_Blocks_Controller` performs additional type checking in a future version. + +### 14.3 The Admin Page Is a Plain PHP Page + +The admin page (Appearance → Pattern Builder) is a simple PHP-rendered page: a title, a description, and a list of documentation links. No React, no JS build artifact. + +A Redux store (`store.js`) and an admin JS entry point existed previously as scaffolding for an admin pattern manager that was never built. Both were removed. All pattern management functionality lives in the editor sidebar (Site Editor / Block Editor). + +### 14.4 The Companion Plugin Relationship Is Clean + +The mutual exclusion guard (`is_plugin_active()` check) and the architectural split (companion = read-only, Pattern Builder = read-write) is well-designed. The companion plugin is appropriate for production deployments where you want synced theme patterns but don't want the full editing toolchain exposed to editors. + +The code quality gap between the companion and Pattern Builder is noticeable (WPCS compliance, security wrapper, namespace usage). The companion could benefit from the same security hardening, even if it's read-only. + +### 14.5 PHP/JS Model Duplication + +The `Abstract_Pattern` class exists in both PHP (`includes/class-pattern-builder-abstract-pattern.php`) and JS (`src/objects/AbstractPattern.js`). The `formatBlockMarkup` function exists in both PHP and JS. This duplication is inherent to WordPress plugin architecture (server/client split), but it means bugs can exist in one language but not the other. The PHP localization tests cover the pattern model thoroughly; JS has essentially no tests. + +--- + +## 15. Summary Scorecard + +| Area | Score | Notes | +|------|-------|-------| +| Core concept | ✅ Solid | The DB-mirror + REST-hijack strategy is the right approach | +| PHP architecture | ✅ Good | Clean class separation, appropriate singleton | +| PHP security | ✅ Good | Security class is well-thought-out | +| PHP test coverage | ✅ Good | Integration tests cover the happy path and key edge cases | +| PHP code quality | ✅ Good | `array_find()` fixed; `public: false` CPT; activation hook for caps | +| JS architecture | ✅ Good | Dead store removed; hooks violation fixed; admin JS eliminated | +| JS test coverage | ❌ None | Unit tests are a placeholder | +| Performance | ⚠️ Open | DB upsert on every page load (TWE-369); activation hook caps fixed | +| Admin UI | ✅ Simplified | Plain PHP page; no JS overhead | +| Companion plugin | ✅ Good | Clean relationship; appropriate read-only subset | + +### Previously Fixed (see individual entries in §13) + +1. ✅ `array_find()` replaced with PHP 7.4-compatible alternative — `Requires PHP` updated to 7.4 +2. ✅ Dead `store.js` and admin JS build removed — admin page is now plain PHP +3. ✅ Rules of Hooks violation in `syncedPatternFilter` fixed +4. ✅ Capability assignment moved to `register_activation_hook` +5. ✅ `tbell_pattern_block` CPT changed to `public: false` +6. ✅ `wp_pattern_keywords` meta registered for REST + +### Open Items (tracked in Linear — Pattern Builder project) + +- **TWE-369** (medium): Cache pattern registration — transients keyed by `filemtime()` hashes +- **TWE-370** (medium): Fix `PatternCreatePanel` to handle `source: 'theme'` on creation +- **TWE-371** (low): Fix slug encoding sentinel collision risk +- **TWE-373** (low): Add parent theme pattern support +- **TWE-374** (low): Refresh sidebar pattern list after create/update diff --git a/includes/class-pattern-builder-abstract-pattern.php b/includes/class-pattern-builder-abstract-pattern.php index dcc4099..99199fe 100644 --- a/includes/class-pattern-builder-abstract-pattern.php +++ b/includes/class-pattern-builder-abstract-pattern.php @@ -1,28 +1,119 @@ id = $args['id'] ?? null; @@ -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, @@ -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( @@ -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' ); @@ -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(), @@ -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, ) ); } diff --git a/includes/class-pattern-builder-admin.php b/includes/class-pattern-builder-admin.php index 935de12..27bb7f1 100644 --- a/includes/class-pattern-builder-admin.php +++ b/includes/class-pattern-builder-admin.php @@ -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 '
'; - } - - /** - * 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' ); + ?> +
+

+ +

+ +

+
    +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
+
+ controller = new Pattern_Builder_Controller(); @@ -79,7 +100,7 @@ public function read_permission_callback() { * @return bool|WP_Error True if the user can modify patterns, WP_Error otherwise. */ public function write_permission_callback( $request ) { - // First check if user has the required capability + // Check if user has the required capability. if ( ! current_user_can( 'edit_tbell_pattern_blocks' ) ) { return new WP_Error( 'rest_forbidden', @@ -88,12 +109,12 @@ public function write_permission_callback( $request ) { ); } - // Verify the REST API nonce + // Verify the REST API nonce. $nonce = $request->get_header( 'X-WP-Nonce' ); if ( ! $nonce || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) { return new WP_Error( 'rest_cookie_invalid_nonce', - __( 'Cookie nonce is invalid', 'pattern-builder' ), + __( 'Cookie nonce is invalid.', 'pattern-builder' ), array( 'status' => 403 ) ); } @@ -101,8 +122,6 @@ public function write_permission_callback( $request ) { return true; } - // Callback functions ////////// - /** * Processes all theme patterns with current configuration settings. * @@ -111,15 +130,14 @@ public function write_permission_callback( $request ) { */ public function process_theme_patterns( WP_REST_Request $request ): WP_REST_Response { - $localize = sanitize_text_field( $request->get_param( 'localize' ) ) === 'true'; - $import_images = sanitize_text_field( $request->get_param( 'importImages' ) ) !== 'false'; + $localize = 'true' === sanitize_text_field( $request->get_param( 'localize' ) ); + $import_images = 'false' !== sanitize_text_field( $request->get_param( 'importImages' ) ); $options = array( 'localize' => $localize, 'import_images' => $import_images, ); - // Get all theme patterns $theme_patterns = $this->controller->get_block_patterns_from_theme_files(); $processed_count = 0; @@ -130,7 +148,7 @@ public function process_theme_patterns( WP_REST_Request $request ): WP_REST_Resp try { $this->controller->update_theme_pattern( $pattern, $options ); ++$processed_count; - } catch ( Exception $e ) { + } catch ( \Exception $e ) { ++$error_count; $errors[] = array( 'pattern' => $pattern->name, @@ -140,7 +158,7 @@ public function process_theme_patterns( WP_REST_Request $request ): WP_REST_Resp } $total_patterns = count( $theme_patterns ); - $success = $error_count === 0; + $success = 0 === $error_count; $response_data = array( 'success' => $success, @@ -171,14 +189,14 @@ public function process_theme_patterns( WP_REST_Request $request ): WP_REST_Resp * @param WP_REST_Request $request The REST request object. * @return WP_REST_Response */ - public function get_patterns( WP_REST_Request $request ): WP_REST_Response { + public function get_patterns( WP_REST_Request $request ): WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- required REST callback signature. $theme_patterns = $this->controller->get_block_patterns_from_theme_files(); $theme_patterns = array_map( function ( $pattern ) { $pattern_post = $this->controller->get_tbell_pattern_block_post_for_pattern( $pattern ); $pattern_from_post = Abstract_Pattern::from_post( $pattern_post ); // TODO: The slug doesn't survive the trip to post and back since it has to be normalized. - // so we just pull it form the original pattern and reset it here. Not sure if that is the best way to do this. + // For now we pull it from the original pattern and reset it here. $pattern_from_post->name = $pattern->name; return $pattern_from_post; }, @@ -195,30 +213,35 @@ function ( $pattern ) { return rest_ensure_response( $all_patterns ); } + /** + * Injects theme patterns into the /wp/v2/blocks REST responses. + * + * @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 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->post_type === 'tbell_pattern_block' ) { - // make sure the pattern has a pattern file + 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; // No pattern file found, return the original response + 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 ); } - } - - // Requesting all patterns. Inject all of the synced theme patterns. - elseif ( $request->get_route() === '/wp/v2/blocks' && $request->get_method() === 'GET' ) { - + } elseif ( '/wp/v2/blocks' === $request->get_route() && 'GET' === $request->get_method() ) { + // Requesting all patterns — inject all synced theme patterns. $data = $response->get_data(); $patterns = $this->controller->get_block_patterns_from_theme_files(); - // filter out patterns that should be exluded from the inserter + // Filter out patterns that should be excluded from the inserter. $patterns = array_filter( $patterns, function ( $pattern ) { @@ -237,10 +260,20 @@ function ( $pattern ) { return $response; } - public function format_tbell_pattern_block_response( $post, $request ) { + /** + * Formats a tbell_pattern_block post as a wp_block REST response. + * + * Temporarily sets post_type to 'wp_block' in memory so that WP_REST_Blocks_Controller + * can produce a correctly structured response without needing a custom serializer. + * + * @param \WP_Post $post The tbell_pattern_block post. + * @param WP_REST_Request $request The original REST request (used for context). + * @return array Formatted REST response data. + */ + public function format_tbell_pattern_block_response( $post, $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- $request is part of the public interface and may be used by callers or future extensions. $post->post_type = 'wp_block'; - // Create a mock request to pass to the controller + // Create a mock request to pass to the controller. $mock_request = new WP_REST_Request( 'GET', '/wp/v2/blocks/' . $post->ID ); $mock_request->set_param( 'context', 'edit' ); @@ -274,11 +307,11 @@ public function format_tbell_pattern_block_response( $post, $request ) { /** * Registers block patterns for the theme. * - * If the patterns are ALREADY registered, unregister them first. + * 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. */ - public function register_patterns() { + public function register_patterns(): void { $pattern_registry = WP_Block_Patterns_Registry::get_instance(); @@ -307,8 +340,7 @@ public function register_patterns() { 'templateTypes' => $pattern->templateTypes, ); - // NOTE: Setting the postTypes to an empty array will cause the pattern to not be available - // or perhaps a registration error... or some strange behavior. So it only optionally set here. + // Setting postTypes to an empty array causes registration errors; only set it when non-empty. if ( $pattern->postTypes ) { $pattern_data['postTypes'] = $pattern->postTypes; } @@ -322,15 +354,15 @@ public function register_patterns() { /** - * Filters delete calls and if the item being deleted is a 'tbell_pattern_block' (theme pattern) - * delete the related pattern php file as well. + * Filters delete calls and, if the item being deleted is a tbell_pattern_block (theme pattern), + * deletes the related pattern PHP file as well. * * @param mixed $response The response from the REST API. - * @param WP_REST_Server $server The REST server instance. + * @param mixed $server The REST server instance. * @param WP_REST_Request $request The REST request object. * @return mixed|WP_Error The response or WP_Error on failure. */ - function handle_hijack_block_delete( $response, $server, $request ) { + public function handle_hijack_block_delete( $response, $server, $request ) { $route = $request->get_route(); @@ -339,12 +371,12 @@ function handle_hijack_block_delete( $response, $server, $request ) { $id = intval( $matches[1] ); $post = get_post( $id ); - if ( $post && $post->post_type === 'tbell_pattern_block' && $request->get_method() === 'DELETE' ) { + if ( $post && 'tbell_pattern_block' === $post->post_type && 'DELETE' === $request->get_method() ) { $deleted = wp_delete_post( $id, true ); if ( ! $deleted ) { - return new WP_Error( 'pattern_delete_failed', 'Failed to delete pattern', array( 'status' => 500 ) ); + return new WP_Error( 'pattern_delete_failed', 'Failed to delete pattern.', array( 'status' => 500 ) ); } $abstract_pattern = Abstract_Pattern::from_post( $post ); @@ -356,21 +388,21 @@ function handle_hijack_block_delete( $response, $server, $request ) { } if ( ! $path ) { - return new WP_Error( 'pattern_not_found', 'Pattern not found', array( 'status' => 404 ) ); + return new WP_Error( 'pattern_not_found', 'Pattern not found.', array( 'status' => 404 ) ); } - // Use secure file delete operation + // Use secure file delete operation. $allowed_dirs = array( get_stylesheet_directory() . '/patterns', get_template_directory() . '/patterns', ); - $deleted = \Pattern_Builder_Security::safe_file_delete( $path, $allowed_dirs ); + $deleted = Pattern_Builder_Security::safe_file_delete( $path, $allowed_dirs ); if ( is_wp_error( $deleted ) ) { return $deleted; } - return new WP_REST_Response( array( 'message' => 'Pattern deleted successfully' ), 200 ); + return new WP_REST_Response( array( 'message' => 'Pattern deleted successfully.' ), 200 ); } } @@ -379,13 +411,17 @@ function handle_hijack_block_delete( $response, $server, $request ) { } /** + * Handles additional logic when a tbell_pattern_block (theme pattern) is updated via the REST API. * - * This filter handles additional logic when a tbell_pattern_block (theme pattern) is updated. - * It updates the pattern file as well as associated metadata for the pattern. - * Additionally it will optionally localize the content as well as import any media - * referenced by the pattern into the theme. + * Updates the pattern file and associated metadata. Optionally localizes the content and + * imports any media referenced by the pattern into the theme. + * + * @param mixed $response The response from the REST API. + * @param mixed $handler The handler object. + * @param WP_REST_Request $request The REST request object. + * @return mixed|WP_Error The response or WP_Error on failure. */ - function handle_hijack_block_update( $response, $handler, $request ) { + public function handle_hijack_block_update( $response, $handler, $request ) { $route = $request->get_route(); if ( preg_match( '#^/wp/v2/blocks/(\d+)$#', $route, $matches ) ) { @@ -393,12 +429,12 @@ function handle_hijack_block_update( $response, $handler, $request ) { $id = intval( $matches[1] ); $post = get_post( $id ); - if ( $post && $request->get_method() === 'PUT' ) { + if ( $post && 'PUT' === $request->get_method() ) { $updated_pattern = json_decode( $request->get_body(), true ); - // Validate JSON decode was successful - if ( json_last_error() !== JSON_ERROR_NONE ) { + // Validate JSON decode was successful. + if ( JSON_ERROR_NONE !== json_last_error() ) { return new WP_Error( 'invalid_json', __( 'Invalid JSON in request body.', 'pattern-builder' ), @@ -408,17 +444,16 @@ function handle_hijack_block_update( $response, $handler, $request ) { $convert_user_pattern_to_theme_pattern = false; - if ( $post->post_type === 'wp_block' ) { - - if ( isset( $updated_pattern['source'] ) && $updated_pattern['source'] === 'theme' ) { - // we are attempting to convert a USER pattern to a THEME pattern. + if ( 'wp_block' === $post->post_type ) { + if ( isset( $updated_pattern['source'] ) && 'theme' === $updated_pattern['source'] ) { + // Attempting to convert a USER pattern to a THEME pattern. $convert_user_pattern_to_theme_pattern = true; } } - if ( $post->post_type === 'tbell_pattern_block' || $convert_user_pattern_to_theme_pattern ) { + if ( 'tbell_pattern_block' === $post->post_type || $convert_user_pattern_to_theme_pattern ) { - // Check write permissions before allowing update + // Check write permissions before allowing update. if ( ! current_user_can( 'edit_tbell_pattern_blocks' ) ) { return new WP_Error( 'rest_forbidden', @@ -430,7 +465,7 @@ function handle_hijack_block_update( $response, $handler, $request ) { $pattern = Abstract_Pattern::from_post( $post ); if ( isset( $updated_pattern['content'] ) ) { - // remap tbell_pattern_blocks to patterns + // Remap tbell_pattern_blocks to patterns. $blocks = parse_blocks( $updated_pattern['content'] ); $blocks = $this->convert_blocks_to_patterns( $blocks ); $pattern->content = serialize_blocks( $blocks ); @@ -445,7 +480,7 @@ function handle_hijack_block_update( $response, $handler, $request ) { } if ( isset( $updated_pattern['wp_pattern_sync_status'] ) ) { - $pattern->synced = $updated_pattern['wp_pattern_sync_status'] !== 'unsynced'; + $pattern->synced = 'unsynced' !== $updated_pattern['wp_pattern_sync_status']; } if ( isset( $updated_pattern['wp_pattern_block_types'] ) ) { @@ -461,26 +496,26 @@ function handle_hijack_block_update( $response, $handler, $request ) { } if ( isset( $updated_pattern['wp_pattern_inserter'] ) ) { - $pattern->inserter = $updated_pattern['wp_pattern_inserter'] === 'no' ? false : true; + $pattern->inserter = 'no' !== $updated_pattern['wp_pattern_inserter']; } - if ( isset( $updated_pattern['source'] ) && $updated_pattern['source'] === 'user' ) { - // we are attempting to convert a THEME pattern to a USER pattern. + if ( isset( $updated_pattern['source'] ) && 'user' === $updated_pattern['source'] ) { + // Converting a THEME pattern to a USER pattern. $this->controller->update_user_pattern( $pattern ); } else { - // Check configuration options via query parameters + // Check configuration options via query parameters. $options = array(); $localize_param = sanitize_text_field( $request->get_param( 'patternBuilderLocalize' ) ); - if ( $localize_param === 'true' ) { + if ( 'true' === $localize_param ) { $options['localize'] = true; } $import_images_param = sanitize_text_field( $request->get_param( 'patternBuilderImportImages' ) ); - if ( $import_images_param === 'false' ) { + if ( 'false' === $import_images_param ) { $options['import_images'] = false; } else { - // Default to true if not explicitly disabled + // Default to true if not explicitly disabled. $options['import_images'] = true; } @@ -497,7 +532,7 @@ function handle_hijack_block_update( $response, $handler, $request ) { } /** - * When anything is saved any wp:block that references a theme pattern is converted to a wp:pattern block instead. + * When anything is saved, converts any wp:block blocks referencing a theme pattern to wp:pattern blocks instead. * * @param mixed $response The response from the REST API. * @param mixed $handler The handler object. @@ -505,20 +540,18 @@ function handle_hijack_block_update( $response, $handler, $request ) { * @return mixed The response, potentially modified. */ public function handle_block_to_pattern_conversion( $response, $handler, $request ) { - if ( $request->get_method() === 'PUT' || $request->get_method() === 'POST' ) { + if ( 'PUT' === $request->get_method() || 'POST' === $request->get_method() ) { $body = json_decode( $request->get_body(), true ); - // Validate JSON decode was successful - if ( json_last_error() !== JSON_ERROR_NONE ) { - return $response; // Return original response if JSON is invalid + // Return original response if JSON is invalid. + if ( JSON_ERROR_NONE !== json_last_error() ) { + return $response; } if ( isset( $body['content'] ) ) { - // parse the content string into blocks - $blocks = parse_blocks( $body['content'] ); - $blocks = $this->convert_blocks_to_patterns( $blocks ); - // convert the blocks back to a string + $blocks = parse_blocks( $body['content'] ); + $blocks = $this->convert_blocks_to_patterns( $blocks ); $body['content'] = serialize_blocks( $blocks ); $request->set_body( wp_json_encode( $body ) ); } @@ -526,11 +559,17 @@ public function handle_block_to_pattern_conversion( $response, $handler, $reques return $response; } + /** + * Recursively converts wp:block references pointing to tbell_pattern_block posts into wp:pattern blocks. + * + * @param array $blocks Array of parsed blocks. + * @return array Modified blocks array. + */ private function convert_blocks_to_patterns( $blocks ) { foreach ( $blocks as &$block ) { - if ( isset( $block['blockName'] ) && $block['blockName'] === 'core/block' ) { + if ( isset( $block['blockName'] ) && 'core/block' === $block['blockName'] ) { $post = get_post( $block['attrs']['ref'] ); - if ( $post->post_type === 'tbell_pattern_block' ) { + if ( $post && 'tbell_pattern_block' === $post->post_type ) { $slug = Pattern_Builder_Controller::format_pattern_slug_from_post( $post->post_name ); $block['blockName'] = 'core/pattern'; $block['attrs'] = isset( $block['attrs'] ) ? $block['attrs'] : array(); @@ -548,34 +587,33 @@ private function convert_blocks_to_patterns( $blocks ) { } /** - * Filters pattern block data to apply attributes to nested wp:block. + * Filters pattern block data to apply attributes to the nested wp:block reference. * + * @param mixed $pre_render The pre-render value (null to allow normal rendering). * @param array $parsed_block The parsed block data. - * @param array $source_block The original block data. - * @return array Modified block data. + * @return mixed Modified pre-render value or null. */ public function filter_pattern_block_attributes( $pre_render, $parsed_block ) { - // Only process wp:pattern blocks - if ( $parsed_block['blockName'] !== 'core/pattern' ) { + // Only process wp:pattern blocks. + if ( 'core/pattern' !== $parsed_block['blockName'] ) { return $pre_render; } - // Extract attributes from the pattern block $pattern_attrs = isset( $parsed_block['attrs'] ) ? $parsed_block['attrs'] : array(); + $slug = $pattern_attrs['slug'] ?? ''; - $slug = $pattern_attrs['slug'] ?? ''; - - // Remove attributes we don't want to pass down + // Remove attributes we don't want to pass down. unset( $pattern_attrs['slug'] ); - // If no attributes to apply, return as-is + // If no attributes to apply, return as-is. if ( empty( $pattern_attrs ) ) { return $pre_render; } $synced_pattern_id = self::$synced_theme_patterns[ $slug ] ?? null; - // if there is a synced_pattern_id then contruct the block with a reference to the synced pattern that also has the rest of the pattern's attributes and render it. + // If there is a synced_pattern_id, construct the block with a reference to the synced pattern + // that also carries the rest of the pattern's attributes, then render it. if ( $synced_pattern_id ) { $block_attributes = array_merge( array( 'ref' => $synced_pattern_id ), diff --git a/includes/class-pattern-builder-controller.php b/includes/class-pattern-builder-controller.php index b68a25c..5f877ed 100644 --- a/includes/class-pattern-builder-controller.php +++ b/includes/class-pattern-builder-controller.php @@ -1,4 +1,5 @@ format_pattern_slug_for_post( $pattern->name ); @@ -44,7 +57,7 @@ public function get_tbell_pattern_block_post_for_pattern( $pattern ) { $pattern_post = $query->have_posts() ? $query->posts[0] : null; - // Clean up + // Clean up after the query. wp_reset_postdata(); if ( $pattern_post ) { @@ -55,6 +68,12 @@ public function get_tbell_pattern_block_post_for_pattern( $pattern ) { return $this->create_tbell_pattern_block_post_for_pattern( $pattern ); } + /** + * Creates or updates the tbell_pattern_block post that mirrors a theme pattern file. + * + * @param Abstract_Pattern $pattern The pattern to upsert. + * @return \WP_Post The created or updated post. + */ public function create_tbell_pattern_block_post_for_pattern( $pattern ) { $existing_post = get_page_by_path( $this->format_pattern_slug_for_post( $pattern->name ), OBJECT, array( 'tbell_pattern_block' ) ); @@ -93,60 +112,49 @@ public function create_tbell_pattern_block_post_for_pattern( $pattern ) { delete_post_meta( $post_id, 'wp_pattern_keywords' ); } - if ( $pattern->inserter === false ) { + if ( false === $pattern->inserter ) { $meta['wp_pattern_inserter'] = 'no'; } else { delete_post_meta( $post_id, 'wp_pattern_inserter' ); } - if ( ! $post_id ) { - - $post_id = wp_insert_post( - array( - 'post_title' => $pattern->title, - 'post_name' => $this->format_pattern_slug_for_post( $pattern->name ), - 'post_content' => $pattern->content, - 'post_excerpt' => $pattern->description, - 'post_type' => 'tbell_pattern_block', - 'post_status' => 'publish', - 'ping_status' => 'closed', - 'comment_status' => 'closed', - 'meta_input' => $meta, - ), - true - ); - } else { - - $post_id = wp_insert_post( - array( - 'ID' => $post_id, - 'post_title' => $pattern->title, - 'post_name' => $this->format_pattern_slug_for_post( $pattern->name ), - 'post_content' => $pattern->content, - 'post_excerpt' => $pattern->description, - 'post_type' => 'tbell_pattern_block', - 'post_status' => 'publish', - 'ping_status' => 'closed', - 'comment_status' => 'closed', - 'meta_input' => $meta, - ), - true - ); + $post_data = array( + 'post_title' => $pattern->title, + 'post_name' => $this->format_pattern_slug_for_post( $pattern->name ), + 'post_content' => $pattern->content, + 'post_excerpt' => $pattern->description, + 'post_type' => 'tbell_pattern_block', + 'post_status' => 'publish', + 'ping_status' => 'closed', + 'comment_status' => 'closed', + 'meta_input' => $meta, + ); + if ( $post_id ) { + $post_data['ID'] = $post_id; } - // store categories + $post_id = wp_insert_post( $post_data, true ); + + // Store categories. wp_set_object_terms( $post_id, $pattern->categories, 'wp_pattern_category', false ); - // return the post by post id + // Return the post by post ID. $post = get_post( $post_id ); $post->post_name = $pattern->name; return $post; } + /** + * Updates a theme pattern — writes the PHP file and syncs the DB post. + * + * @param Abstract_Pattern $pattern The pattern to update. + * @param array $options Optional settings: 'localize' (bool), 'import_images' (bool). + * @return Abstract_Pattern|WP_Error + */ public function update_theme_pattern( Abstract_Pattern $pattern, $options = array() ) { - // Check if user has permission to modify theme patterns + // Check if user has permission to modify theme patterns. if ( ! current_user_can( 'edit_theme_options' ) ) { return new WP_Error( 'insufficient_permissions', @@ -155,28 +163,27 @@ public function update_theme_pattern( Abstract_Pattern $pattern, $options = arra ); } - // get the tbell_pattern_block post if it already exists $post = get_page_by_path( $this->format_pattern_slug_for_post( $pattern->name ), OBJECT, array( 'tbell_pattern_block', 'wp_block' ) ); - if ( $post && $post->post_type === 'wp_block' ) { - // this is being converted to theme patterns, change the slug to include the theme domain + if ( $post && 'wp_block' === $post->post_type ) { + // Being converted to a theme pattern; prefix the slug with the theme domain. $pattern->name = get_stylesheet() . '/' . $pattern->name; } - // Check if image importing is enabled (default to true for backward compatibility) - if ( ! isset( $options['import_images'] ) || $options['import_images'] === true ) { + // Import images unless explicitly disabled. + if ( ! isset( $options['import_images'] ) || true === $options['import_images'] ) { $pattern = $this->import_pattern_image_assets( $pattern ); } - // Check if localization is enabled - if ( isset( $options['localize'] ) && $options['localize'] === true ) { + // Localize if enabled. + if ( isset( $options['localize'] ) && true === $options['localize'] ) { $pattern = Pattern_Builder_Localization::localize_pattern_content( $pattern ); } - // update the pattern file + // Write the pattern file. $this->update_theme_pattern_file( $pattern ); - // rebuild the pattern from the file (so that the content has no PHP tags) + // Rebuild the pattern from the file (so that content has no PHP tags). $filepath = $this->get_pattern_filepath( $pattern ); if ( ! is_wp_error( $filepath ) && $filepath ) { $pattern = Abstract_Pattern::from_file( $filepath ); @@ -223,20 +230,33 @@ public function update_theme_pattern( Abstract_Pattern $pattern, $options = arra delete_post_meta( $post_id, 'wp_pattern_post_types' ); } - // store categories + // Store categories. wp_set_object_terms( $post_id, $pattern->categories, 'wp_pattern_category', false ); return $pattern; } + /** + * Exports pattern image assets from the theme directory to the WordPress media library. + * + * Used when converting a theme pattern to a user pattern. + * + * @param Abstract_Pattern $pattern The pattern whose images should be exported. + * @return Abstract_Pattern Updated pattern with media library URLs. + */ private function export_pattern_image_assets( $pattern ) { $home_url = home_url(); - // Helper function to download and save image + /** + * Downloads a URL and uploads it to the media library. + * + * @param string $url Source URL. + * @return string|WP_Error New media library URL, or WP_Error on failure. + */ $upload_image = function ( $url ) use ( $home_url ) { - // skip if the asset isn't an image + // Skip if the asset isn't an image. if ( ! preg_match( '/\.(jpg|jpeg|png|gif|webp|svg)$/i', $url ) ) { return new WP_Error( 'invalid_image_type', @@ -247,13 +267,10 @@ private function export_pattern_image_assets( $pattern ) { $download_file = false; - // convert the URL to a local file path + // Convert the URL to a local file path. $file_path = str_replace( $home_url, ABSPATH, $url ); if ( file_exists( $file_path ) ) { - $temp_file = wp_tempnam( basename( $file_path ) ); - - // copy the image to a temporary location if ( copy( $file_path, $temp_file ) ) { $download_file = $temp_file; } @@ -264,11 +281,9 @@ private function export_pattern_image_assets( $pattern ) { } if ( is_wp_error( $download_file ) ) { - // we're going to try again with a new URL - // we might be running this in a docker container - // and if that's the case let's try again on port 80 + // Try again with port 80 if we're inside a Docker container on localhost. $parsed_url = wp_parse_url( $url ); - if ( 'localhost' === $parsed_url['host'] && '80' !== $parsed_url['port'] ) { + if ( 'localhost' === $parsed_url['host'] && '80' !== ( $parsed_url['port'] ?? null ) ) { $download_file = download_url( str_replace( 'localhost:' . $parsed_url['port'], 'localhost:80', $url ) ); } } @@ -284,7 +299,6 @@ private function export_pattern_image_assets( $pattern ) { ); } - // upload to the media library $upload_dir = wp_upload_dir(); if ( ! is_dir( $upload_dir['path'] ) ) { wp_mkdir_p( $upload_dir['path'] ); @@ -292,13 +306,12 @@ private function export_pattern_image_assets( $pattern ) { $upload_file = $upload_dir['path'] . '/' . basename( $url ); - // check to see if the file is already in the uploads directory + // Return existing URL if the file is already in uploads. if ( file_exists( $upload_file ) ) { - $uploaded_file_url = $upload_dir['url'] . '/' . basename( $upload_file ); - return $uploaded_file_url; + return $upload_dir['url'] . '/' . basename( $upload_file ); } - // Move the downloaded file to the uploads directory + // Move the downloaded file to the uploads directory. global $wp_filesystem; if ( ! $wp_filesystem ) { WP_Filesystem(); @@ -314,7 +327,6 @@ private function export_pattern_image_assets( $pattern ) { ); } - // Get the file type and create an attachment $filetype = wp_check_filetype( basename( $upload_file ), null ); $attachment = array( 'guid' => $upload_dir['url'] . '/' . basename( $upload_file ), @@ -324,7 +336,6 @@ private function export_pattern_image_assets( $pattern ) { 'post_status' => 'inherit', ); - // Insert the attachment into the media library $attachment_id = wp_insert_attachment( $attachment, $upload_file ); if ( is_wp_error( $attachment_id ) ) { return new WP_Error( @@ -337,17 +348,14 @@ private function export_pattern_image_assets( $pattern ) { ); } - // Generate attachment metadata and update the attachment require_once ABSPATH . 'wp-admin/includes/image.php'; $metadata = wp_generate_attachment_metadata( $attachment_id, $upload_file ); wp_update_attachment_metadata( $attachment_id, $metadata ); - $url = wp_get_attachment_url( $attachment_id ); - - return $url; + return wp_get_attachment_url( $attachment_id ); }; - // First, handle HTML attributes (src and href) + // Handle HTML attributes (src and href). $pattern->content = preg_replace_callback( '/(src|href)="(' . preg_quote( $home_url, '/' ) . '[^"]+)"/', function ( $matches ) use ( $upload_image ) { @@ -355,16 +363,12 @@ function ( $matches ) use ( $upload_image ) { if ( $new_url && ! is_wp_error( $new_url ) ) { return $matches[1] . '="' . $new_url . '"'; } - // Image upload failed, but don't break the pattern - if ( is_wp_error( $new_url ) ) { - // Error occurred but we continue without logging - } return $matches[0]; }, $pattern->content ); - // Second, handle JSON-encoded URLs + // Handle JSON-encoded URLs. $pattern->content = preg_replace_callback( '/"url"\s*:\s*"(' . preg_quote( $home_url, '/' ) . '[^"]+)"/', function ( $matches ) use ( $upload_image ) { @@ -373,10 +377,6 @@ function ( $matches ) use ( $upload_image ) { if ( $new_url && ! is_wp_error( $new_url ) ) { return '"url":"' . $new_url . '"'; } - // Image upload failed, but don't break the pattern - if ( is_wp_error( $new_url ) ) { - // Error occurred but we continue without logging - } return $matches[0]; }, $pattern->content @@ -386,18 +386,26 @@ function ( $matches ) use ( $upload_image ) { } /** - * Import image assets for a pattern into the media library. + * Imports pattern image assets from the media library into the theme's assets directory. * - * @param Abstract_Pattern $pattern The pattern object. - * @return Abstract_Pattern Updated pattern object with new asset URLs. + * Used when saving a theme pattern — downloads URLs pointing to home_url and + * stores them as static theme assets, replacing the URLs with PHP template tags. + * + * @param Abstract_Pattern $pattern The pattern whose images should be imported. + * @return Abstract_Pattern Updated pattern with theme-relative asset paths. */ private function import_pattern_image_assets( $pattern ) { $home_url = home_url(); - // Helper function to download and save image - $download_and_save_image = function ( $url ) use ( $home_url ) { - // continue if the asset isn't an image + /** + * Downloads a URL and saves it to the theme's assets/images directory. + * + * @param string $url Source URL. + * @return string|false Theme-relative path on success, false on failure. + */ + $download_and_save_image = function ( $url ) { + // Skip if the asset isn't an image. if ( ! preg_match( '/\.(jpg|jpeg|png|gif|webp|svg)$/i', $url ) ) { return false; } @@ -405,11 +413,9 @@ private function import_pattern_image_assets( $pattern ) { $download_file = download_url( $url ); if ( is_wp_error( $download_file ) ) { - // we're going to try again with a new URL - // we might be running this in a docker container - // and if that's the case let's try again on port 80 + // Try again with port 80 if we're inside a Docker container on localhost. $parsed_url = wp_parse_url( $url ); - if ( 'localhost' === $parsed_url['host'] && '80' !== $parsed_url['port'] ) { + if ( 'localhost' === $parsed_url['host'] && '80' !== ( $parsed_url['port'] ?? null ) ) { $download_file = download_url( str_replace( 'localhost:' . $parsed_url['port'], 'localhost:80', $url ) ); } } @@ -426,16 +432,14 @@ private function import_pattern_image_assets( $pattern ) { wp_mkdir_p( $asset_dir ); } - // Use secure file move operation $allowed_dirs = array( '/tmp', get_stylesheet_directory() . '/assets', get_template_directory() . '/assets', ); - $result = \Pattern_Builder_Security::safe_file_move( $download_file, $destination_path, $allowed_dirs ); + $result = Pattern_Builder_Security::safe_file_move( $download_file, $destination_path, $allowed_dirs ); if ( is_wp_error( $result ) ) { - // Clean up the temp file if move failed if ( file_exists( $download_file ) ) { wp_delete_file( $download_file ); } @@ -445,7 +449,7 @@ private function import_pattern_image_assets( $pattern ) { return '/assets/images/' . $filename; }; - // First, handle HTML attributes (src and href) + // Handle HTML attributes (src and href). $pattern->content = preg_replace_callback( '/(src|href)="(' . preg_quote( $home_url, '/' ) . '[^"]+)"/', function ( $matches ) use ( $download_and_save_image ) { @@ -458,7 +462,7 @@ function ( $matches ) use ( $download_and_save_image ) { $pattern->content ); - // Second, handle JSON-encoded URLs + // Handle JSON-encoded URLs. $pattern->content = preg_replace_callback( '/"url"\s*:\s*"(' . preg_quote( $home_url, '/' ) . '[^"]+)"/', function ( $matches ) use ( $download_and_save_image ) { @@ -475,13 +479,13 @@ function ( $matches ) use ( $download_and_save_image ) { } /** - * Updates a user pattern. + * Updates a user pattern (wp_block post type). * * @param Abstract_Pattern $pattern The pattern to update. * @return Abstract_Pattern|WP_Error */ public function update_user_pattern( Abstract_Pattern $pattern ) { - // Check if user has permission to edit pattern blocks + // Check if user has permission to edit pattern blocks. if ( ! current_user_can( 'edit_tbell_pattern_blocks' ) ) { return new WP_Error( 'insufficient_permissions', @@ -489,19 +493,18 @@ public function update_user_pattern( Abstract_Pattern $pattern ) { array( 'status' => 403 ) ); } + $post = get_page_by_path( $pattern->name, OBJECT, 'wp_block' ); $convert_from_theme_pattern = false; if ( empty( $post ) ) { - // check if the pattern exists in the database as a tbell_pattern_block post - // this is for any user patterns that are being converted from theme patterns - // It will be converted to a wp_block post when it is updated + // Check if the pattern exists as a tbell_pattern_block; if so it's being converted. $slug = $this->format_pattern_slug_for_post( $pattern->name ); $post = get_page_by_path( $slug, OBJECT, 'tbell_pattern_block' ); $convert_from_theme_pattern = true; } - // upload any assets from the theme + // Export any theme assets to the media library. $pattern = $this->export_pattern_image_assets( $pattern ); if ( empty( $post ) ) { @@ -528,27 +531,32 @@ public function update_user_pattern( Abstract_Pattern $pattern ) { ); } - // ensure the 'synced' meta key is set + // Ensure the sync status meta key is accurate. if ( $pattern->synced ) { delete_post_meta( $post_id, 'wp_pattern_sync_status' ); } else { update_post_meta( $post_id, 'wp_pattern_sync_status', 'unsynced' ); } - // store categories + // Store categories. wp_set_object_terms( $post_id, $pattern->categories, 'wp_pattern_category', false ); - // if we are converting a theme pattern to a user pattern delete the theme pattern file + // If converting from a theme pattern, delete the theme pattern file. if ( $convert_from_theme_pattern ) { $path = $this->get_pattern_filepath( $pattern ); if ( ! is_wp_error( $path ) && $path ) { - $deleted = \Pattern_Builder_Security::safe_file_delete( $path ); + Pattern_Builder_Security::safe_file_delete( $path ); } } return $pattern; } + /** + * Returns all patterns found as PHP files in the active theme's /patterns/ directory. + * + * @return Abstract_Pattern[] + */ public function get_block_patterns_from_theme_files() { $pattern_files = glob( get_stylesheet_directory() . '/patterns/*.php' ); $patterns = array(); @@ -561,6 +569,11 @@ public function get_block_patterns_from_theme_files() { return $patterns; } + /** + * Returns all user patterns (wp_block posts) from the database. + * + * @return Abstract_Pattern[] + */ public function get_block_patterns_from_database(): array { $query = new WP_Query( array( 'post_type' => 'wp_block' ) ); $patterns = array(); @@ -572,8 +585,14 @@ public function get_block_patterns_from_database(): array { return $patterns; } + /** + * Deletes a user pattern (wp_block) from the database. + * + * @param Abstract_Pattern $pattern The pattern to delete. + * @return array|WP_Error Success message array or WP_Error on failure. + */ public function delete_user_pattern( Abstract_Pattern $pattern ) { - // Check if user has permission to delete pattern blocks + // Check if user has permission to delete pattern blocks. if ( ! current_user_can( 'delete_tbell_pattern_blocks' ) ) { return new WP_Error( 'insufficient_permissions', @@ -585,23 +604,23 @@ public function delete_user_pattern( Abstract_Pattern $pattern ) { $post = get_page_by_path( $pattern->name, OBJECT, 'wp_block' ); if ( empty( $post ) ) { - return new WP_Error( 'pattern_not_found', 'Pattern not found', array( 'status' => 404 ) ); + return new WP_Error( 'pattern_not_found', 'Pattern not found.', array( 'status' => 404 ) ); } $deleted = wp_delete_post( $post->ID, true ); if ( ! $deleted ) { - return new WP_Error( 'pattern_delete_failed', 'Failed to delete pattern', array( 'status' => 500 ) ); + return new WP_Error( 'pattern_delete_failed', 'Failed to delete pattern.', array( 'status' => 500 ) ); } - return array( 'message' => 'Pattern deleted successfully' ); + return array( 'message' => 'Pattern deleted successfully.' ); } /** - * Get the file path for a pattern. + * Gets the filesystem path for a pattern's PHP file. * * @param Abstract_Pattern $pattern The pattern object. - * @return string|WP_Error Pattern file path on success, WP_Error on failure. + * @return string|WP_Error Pattern file path on success, WP_Error if not found. */ public function get_pattern_filepath( $pattern ) { $path = $pattern->filePath ?? get_stylesheet_directory() . '/patterns/' . sanitize_file_name( basename( $pattern->name ) ) . '.php'; @@ -610,16 +629,17 @@ public function get_pattern_filepath( $pattern ) { return $path; } - $patterns = $this->get_block_patterns_from_theme_files(); - $pattern = array_find( + $patterns = $this->get_block_patterns_from_theme_files(); + $filtered = array_filter( $patterns, function ( $p ) use ( $pattern ) { return $p->name === $pattern->name; } ); + $matched_pattern = reset( $filtered ); - if ( $pattern && isset( $pattern->filePath ) ) { - return $pattern->filePath; + if ( $matched_pattern && isset( $matched_pattern->filePath ) ) { + return $matched_pattern->filePath; } return new WP_Error( @@ -629,8 +649,14 @@ function ( $p ) use ( $pattern ) { ); } + /** + * Deletes a theme pattern — removes the PHP file and the tbell_pattern_block post. + * + * @param Abstract_Pattern $pattern The pattern to delete. + * @return array|WP_Error Success message array or WP_Error on failure. + */ public function delete_theme_pattern( Abstract_Pattern $pattern ) { - // Check if user has permission to modify theme patterns + // Check if user has permission to modify theme patterns. if ( ! current_user_can( 'edit_theme_options' ) ) { return new WP_Error( 'insufficient_permissions', @@ -642,35 +668,41 @@ public function delete_theme_pattern( Abstract_Pattern $pattern ) { $path = $this->get_pattern_filepath( $pattern ); if ( is_wp_error( $path ) ) { - return $path; // Return the error from get_pattern_filepath + return $path; } + $allowed_dirs = array( + get_stylesheet_directory() . '/patterns', + get_template_directory() . '/patterns', + ); + $deleted = Pattern_Builder_Security::safe_file_delete( $path, $allowed_dirs ); - // Use secure file delete operation - $allowed_dirs = array( - get_stylesheet_directory() . '/patterns', - get_template_directory() . '/patterns', - ); - $deleted = \Pattern_Builder_Security::safe_file_delete( $path, $allowed_dirs ); - - if ( is_wp_error( $deleted ) ) { - return $deleted; - } + if ( is_wp_error( $deleted ) ) { + return $deleted; + } - $tbell_pattern_block_post = $this->get_tbell_pattern_block_post_for_pattern( $pattern ); - $deleted = wp_delete_post( $tbell_pattern_block_post->ID, true ); + $tbell_pattern_block_post = $this->get_tbell_pattern_block_post_for_pattern( $pattern ); + $deleted = wp_delete_post( $tbell_pattern_block_post->ID, true ); - if ( ! $deleted ) { - return new WP_Error( 'pattern_delete_failed', 'Failed to delete pattern', array( 'status' => 500 ) ); - } + if ( ! $deleted ) { + return new WP_Error( 'pattern_delete_failed', 'Failed to delete pattern.', array( 'status' => 500 ) ); + } - return array( 'message' => 'Pattern deleted successfully' ); + return array( 'message' => 'Pattern deleted successfully.' ); } + /** + * Writes a theme pattern's PHP file to disk. + * + * Creates the file if it doesn't exist. Content is formatted before writing. + * + * @param Abstract_Pattern $pattern The pattern to write. + * @return Abstract_Pattern|WP_Error + */ public function update_theme_pattern_file( Abstract_Pattern $pattern ) { $path = $this->get_pattern_filepath( $pattern ); - // If get_pattern_filepath returns an error, create a new path + // If get_pattern_filepath returns an error, construct a new path. if ( is_wp_error( $path ) ) { $filename = sanitize_file_name( basename( $pattern->name ) ); $path = get_stylesheet_directory() . '/patterns/' . $filename . '.php'; @@ -679,12 +711,11 @@ public function update_theme_pattern_file( Abstract_Pattern $pattern ) { $formatted_content = $this->format_block_markup( $pattern->content ); $file_content = $this->build_pattern_file_metadata( $pattern ) . $formatted_content; - // Use secure file write operation $allowed_dirs = array( get_stylesheet_directory() . '/patterns', get_template_directory() . '/patterns', ); - $response = \Pattern_Builder_Security::safe_file_write( $path, $file_content, $allowed_dirs ); + $response = Pattern_Builder_Security::safe_file_write( $path, $file_content, $allowed_dirs ); if ( is_wp_error( $response ) ) { return $response; @@ -694,10 +725,10 @@ public function update_theme_pattern_file( Abstract_Pattern $pattern ) { } /** - * Builds metadata for a pattern file. + * Builds the PHP header metadata block for a pattern file. * * @param Abstract_Pattern $pattern The pattern object. - * @return string + * @return string PHP header comment string. */ private function build_pattern_file_metadata( Abstract_Pattern $pattern ): string { @@ -722,13 +753,10 @@ private function build_pattern_file_metadata( Abstract_Pattern $pattern ): strin /** * Remaps wp:block blocks that reference theme patterns to wp:pattern blocks. * - * @param Abstract_Pattern $pattern The pattern to remap. + * @param Abstract_Pattern $pattern The pattern whose content should be remapped. * @return Abstract_Pattern */ public function remap_patterns( Abstract_Pattern $pattern ) { - // if this pattern's content contains wp:block blocks and they reference - // theme patterns, remap them to wp:pattern blocks. - $pattern->content = preg_replace_callback( '/wp:block\s+({.*})\s*\/?-->/sU', function ( $matches ) use ( $pattern ) { @@ -737,30 +765,28 @@ function ( $matches ) use ( $pattern ) { if ( isset( $attributes['ref'] ) ) { - // get the post of the pattern $pattern_post = get_post( $attributes['ref'], OBJECT ); - // if the post is a tbell_pattern_block post, we can convert it to a wp:pattern block - if ( $pattern_post && $pattern_post->post_type === 'tbell_pattern_block' ) { + if ( $pattern_post && 'tbell_pattern_block' === $pattern_post->post_type ) { $pattern_slug = $pattern_post->post_name; - // TODO: Optimize this - // NOTE: Because the name of the post is the slug, but the slug has /'s removed, we have to find the ACTUALY slug from the file. - $all_patterns = $this->get_block_patterns_from_theme_files(); - $pattern = array_find( + // TODO: Optimize this. + // NOTE: Because the name of the post is the slug, but the slug has /'s removed, + // we have to find the actual slug from the file. + $all_patterns = $this->get_block_patterns_from_theme_files(); + $filtered_matches = array_filter( $all_patterns, function ( $p ) use ( $pattern_slug ) { return sanitize_title( $p->name ) === sanitize_title( $pattern_slug ); } ); + $matched = reset( $filtered_matches ); - if ( $pattern ) { - + if ( $matched ) { unset( $attributes['ref'] ); - $attributes['slug'] = $pattern->name; - - return 'wp:pattern ' . json_encode( $attributes, JSON_UNESCAPED_SLASHES ) . ' /-->'; + $attributes['slug'] = $matched->name; + return 'wp:pattern ' . wp_json_encode( $attributes, JSON_UNESCAPED_SLASHES ) . ' /-->'; } } } @@ -774,11 +800,12 @@ function ( $p ) use ( $pattern_slug ) { } /** - * Formats block markup to be nicely readable. + * Formats block markup for readability. + * * This is a PHP port of the JavaScript formatBlockMarkup() function. * * @param string $block_markup The block markup to format. - * @return string The formatted block markup. + * @return string Formatted block markup. */ public function format_block_markup( $block_markup ) { $block_markup = $this->add_new_lines_to_block_markup( $block_markup ); @@ -787,13 +814,13 @@ public function format_block_markup( $block_markup ) { } /** - * Adds new lines to block markup for better readability. + * Adds newlines around block comment markers for readability. * - * @param string $block_markup The block markup to add new lines to. - * @return string The block markup with new lines added. + * @param string $block_markup The block markup. + * @return string Block markup with newlines added. */ private function add_new_lines_to_block_markup( $block_markup ) { - // Add newlines before and after each comment + // Add newlines before and after each comment. $block_markup = preg_replace_callback( '//s', function ( $matches ) { @@ -803,23 +830,23 @@ function ( $matches ) { $block_markup ); - // Fix spacing for self-closing blocks + // Fix spacing for self-closing blocks. $block_markup = str_replace( '/ -->', '/-->', $block_markup ); - // Normalize multiple newlines into a single one + // Normalize multiple newlines into a single one. $block_markup = preg_replace( '/\n{2,}/', "\n", $block_markup ); - // eliminate blank lines + // Eliminate blank lines. $block_markup = preg_replace( '/^\s*[\r\n]/m', '', $block_markup ); return $block_markup; } /** - * Indents block markup for better readability. + * Applies indentation to block markup based on nesting depth. * * @param string $block_markup The block markup to indent. - * @return string The indented block markup. + * @return string Indented block markup. */ private function indent_block_markup( $block_markup ) { $lines = explode( "\n", $block_markup ); @@ -829,7 +856,7 @@ private function indent_block_markup( $block_markup ) { $output = array(); foreach ( $lines as $line ) { - // Detect closing tags/comments (should reduce indent before rendering) + // Detect closing tags/comments — reduce indent before rendering. $is_closing_comment = preg_match( '/^$/', $line ); $is_closing_tag = preg_match( '/^<\/[\w:-]+>$/', $line ); @@ -839,17 +866,17 @@ private function indent_block_markup( $block_markup ) { $output[] = str_repeat( $indent_str, $indent_level ) . $line; - // Detect opening comment (not self-closing) + // Detect opening comment (not self-closing). $is_opening_comment = preg_match( '/^$/', $line ) && ! preg_match( '/\/\s*-->$/', $line ); - // Detect opening tag (not self-closing) + // Detect opening tag (not self-closing). $is_opening_tag = preg_match( '/^<([\w:-]+)(\s[^>]*)?>$/', $line ); - // Self-closing HTML tag + // Self-closing HTML tag. $is_self_closing_tag = preg_match( '/^<[^>]+\/>$/', $line ); - // Self-closing block markup + // Self-closing block markup. $is_self_closing_comment = preg_match( '/^$/', $line ); if ( ( $is_opening_comment || $is_opening_tag ) && ! $is_self_closing_tag && ! $is_self_closing_comment ) { diff --git a/includes/class-pattern-builder-editor.php b/includes/class-pattern-builder-editor.php index a1b2acd..f1aa4d2 100644 --- a/includes/class-pattern-builder-editor.php +++ b/includes/class-pattern-builder-editor.php @@ -4,6 +4,9 @@ class Pattern_Builder_Editor { + /** + * Constructor to initialize editor hooks. + */ public function __construct() { add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_block_editor_assets' ) ); } @@ -19,6 +22,7 @@ public function enqueue_block_editor_assets(): void { plugins_url( '../build/PatternBuilder_EditorTools.js', __FILE__ ), $asset_file['dependencies'], $asset_file['version'], + true ); wp_enqueue_style( diff --git a/includes/class-pattern-builder-localization.php b/includes/class-pattern-builder-localization.php index 4178a87..b9466c4 100644 --- a/includes/class-pattern-builder-localization.php +++ b/includes/class-pattern-builder-localization.php @@ -47,7 +47,7 @@ public static function localize_pattern_content( $pattern ) { private static function localize_blocks( $blocks ) { foreach ( $blocks as &$block ) { // Skip null blocks or blocks without a name. - if ( ! isset( $block['blockName'] ) || $block['blockName'] === null ) { + if ( ! isset( $block['blockName'] ) || null === $block['blockName'] ) { continue; } @@ -146,7 +146,7 @@ private static function localize_pullquote_block( $block ) { if ( ! empty( $block['innerHTML'] ) ) { $html = $block['innerHTML']; - // Localize paragraph content(s) within the blockquote + // Localize paragraph content(s) within the blockquote. $html = preg_replace_callback( '/]*>([^<]+)<\/p>/', function ( $matches ) { @@ -160,7 +160,7 @@ function ( $matches ) { $html ); - // Localize citation content + // Localize citation content. $html = preg_replace_callback( '/]*>([^<]+)<\/cite>/', function ( $matches ) { @@ -210,8 +210,8 @@ private static function localize_button_block( $block ) { * @return array Localized block. */ private static function localize_image_block( $block ) { - // Note: For attributes, we don't localize them directly in the attrs array - // because they get HTML-encoded when serialized. Instead, we handle them in innerHTML. + // Note: for attributes, we don't localize them directly in the attrs array, + // because they get HTML-encoded when serialized; instead, we handle them in innerHTML. // Localize caption if present. if ( ! empty( $block['innerHTML'] ) && strpos( $block['innerHTML'], ']*>([^<]+)<\/summary>/', function ( $matches ) { @@ -340,15 +340,15 @@ function ( $matches ) { $block['innerHTML'] ); - // Update innerContent if it exists and has been split + // Update innerContent if it exists and has been split. if ( ! empty( $block['innerContent'] ) && is_array( $block['innerContent'] ) ) { // For details blocks with inner blocks, innerContent typically has: - // [0] = opening part with summary, [1] = null (for inner blocks), [2] = closing + // [0] = opening part with summary, [1] = null (for inner blocks), [2] = closing . - // Find the opening part that contains the summary and update it with localized content + // Find the opening part that contains the summary and update it with localized content. foreach ( $block['innerContent'] as $index => $content ) { if ( is_string( $content ) && strpos( $content, ']*>([^<]+)<\/summary>/', function ( $matches ) { @@ -378,8 +378,8 @@ function ( $matches ) { */ private static function localize_search_block( $block ) { // For search blocks, we need to handle multiple text attributes: - // label, placeholder, and buttonText - // These blocks are self-closing and the attributes should be localized within the JSON. + // label, placeholder, and buttonText. + // These blocks are self-closing; attributes should be localized within the JSON. $localizable_attributes = array( 'label', 'placeholder', 'buttonText' ); @@ -413,11 +413,11 @@ private static function extract_text_content( $html ) { /** * Creates a localized string with proper escaping. * - * @param string $text Text to localize. - * @param string $function Localization function to use. + * @param string $text Text to localize. + * @param string $escape_function WordPress escape/localization function to use (e.g. 'wp_kses_post', 'esc_attr__'). * @return string Localized string in PHP format. */ - private static function create_localized_string( $text, $function = 'wp_kses_post' ) { + private static function create_localized_string( $text, $escape_function = 'wp_kses_post' ) { // Escape single quotes in the text. $escaped_text = str_replace( "'", "\\'", $text ); @@ -425,6 +425,6 @@ private static function create_localized_string( $text, $function = 'wp_kses_pos $text_domain = get_stylesheet(); // Create the PHP localization string. - return ""; + return ""; } } diff --git a/includes/class-pattern-builder-post-type.php b/includes/class-pattern-builder-post-type.php index c82be23..007e3ce 100644 --- a/includes/class-pattern-builder-post-type.php +++ b/includes/class-pattern-builder-post-type.php @@ -4,6 +4,9 @@ class Pattern_Builder_Post_Type { + /** + * Constructor to initialize post type hooks. + */ public function __construct() { add_action( 'init', array( $this, 'register_tbell_pattern_block_post_type' ) ); add_filter( 'render_block', array( $this, 'render_tbell_pattern_blocks' ), 10, 2 ); @@ -14,12 +17,12 @@ public function __construct() { * Adds a "content" attribute to the core/pattern block type. * This is used to store the pattern overrides for the block. * - * @param array $args The block type arguments. + * @param array $args The block type arguments. * @param string $block_type The block type name. * @return array */ public function add_content_attribute_to_core_pattern_block( $args, $block_type ) { - if ( $block_type === 'core/pattern' ) { + if ( 'core/pattern' === $block_type ) { $extra_attributes = array( 'content' => array( 'type' => 'object', @@ -33,7 +36,7 @@ public function add_content_attribute_to_core_pattern_block( $args, $block_type /** * Registers the tbell_pattern_block custom post type. */ - public function register_tbell_pattern_block_post_type() { + public function register_tbell_pattern_block_post_type(): void { $labels = array( 'name' => __( 'Pattern Builder Blocks', 'pattern-builder' ), 'singular_name' => __( 'Pattern Builder Block', 'pattern-builder' ), @@ -41,8 +44,7 @@ public function register_tbell_pattern_block_post_type() { $args = array( 'labels' => $labels, - - 'public' => true, + 'public' => false, 'show_ui' => true, 'show_in_menu' => false, 'show_in_rest' => true, @@ -53,7 +55,7 @@ public function register_tbell_pattern_block_post_type() { 'map_meta_cap' => true, ); - $result = register_post_type( 'tbell_pattern_block', $args ); + register_post_type( 'tbell_pattern_block', $args ); register_post_meta( 'tbell_pattern_block', @@ -105,10 +107,23 @@ public function register_tbell_pattern_block_post_type() { ) ); - /** - * Add custom capabilities for the tbell_pattern_block post type. - */ + register_post_meta( + 'tbell_pattern_block', + 'wp_pattern_keywords', + array( + 'show_in_rest' => true, + 'type' => 'string', + 'single' => true, + ) + ); + } + /** + * Assigns custom capabilities for the tbell_pattern_block post type to administrator and editor roles. + * + * Called once on plugin activation via register_activation_hook. + */ + public static function assign_capabilities(): void { $roles = array( 'administrator', 'editor' ); $capabilities = array( @@ -116,7 +131,6 @@ public function register_tbell_pattern_block_post_type() { 'edit_tbell_pattern_blocks', ); - // Assign capabilities to each role foreach ( $roles as $role_name ) { $role = get_role( $role_name ); if ( $role ) { @@ -129,22 +143,23 @@ public function register_tbell_pattern_block_post_type() { /** * Renders a "tbell_pattern_block" block pattern. - * This is a block pattern stored as a tbell_pattern_block post type instead of a wp_block post type. - * Which means that it is a "theme pattern" instead of a "user pattern". + * + * This is a block pattern stored as a tbell_pattern_block post type instead of a wp_block post type, + * meaning it is a "theme pattern" instead of a "user pattern". * * This borrows heavily from the core block rendering function. * * @param string $block_content The block content. - * @param array $block The block data. + * @param array $block The block data. * @return string */ public function render_tbell_pattern_blocks( $block_content, $block ) { - // store a reference to the block to prevent infinite recursion + // Store a reference to the block to prevent infinite recursion. static $seen_refs = array(); - // if we have a block pattern with no content we PROBABLY are trying to render - // a tbell_pattern_block (theme pattern) - if ( $block['blockName'] === 'core/block' && $block_content === '' ) { + // If we have a block pattern with no content we PROBABLY are trying to render + // a tbell_pattern_block (theme pattern). + if ( 'core/block' === $block['blockName'] && '' === $block_content ) { $attributes = $block['attrs'] ?? array(); @@ -157,7 +172,7 @@ public function render_tbell_pattern_blocks( $block_content, $block ) { return ''; } - // if we have already seen this block, return an empty string to prevent recursion + // If we have already seen this block, return an empty string to prevent recursion. if ( isset( $seen_refs[ $attributes['ref'] ] ) ) { return ''; } @@ -193,7 +208,7 @@ public function render_tbell_pattern_blocks( $block_content, $block ) { // Render the block content. $content = do_blocks( $content ); - // It is safe to render this block again. No infinite recursion worries. + // It is safe to render this block again — no infinite recursion risk. unset( $seen_refs[ $attributes['ref'] ] ); if ( $has_pattern_overrides ) { diff --git a/includes/class-pattern-builder-security.php b/includes/class-pattern-builder-security.php index 200d4cc..e1f3e75 100644 --- a/includes/class-pattern-builder-security.php +++ b/includes/class-pattern-builder-security.php @@ -254,6 +254,4 @@ public static function safe_file_move( $source, $destination, $allowed_dirs = ar return true; } - - } diff --git a/includes/class-pattern-builder.php b/includes/class-pattern-builder.php index 1ca86a4..e1c6ce5 100644 --- a/includes/class-pattern-builder.php +++ b/includes/class-pattern-builder.php @@ -12,6 +12,11 @@ */ class Pattern_Builder { + /** + * Singleton instance. + * + * @var Pattern_Builder|null + */ private static ?Pattern_Builder $instance = null; /** @@ -30,7 +35,7 @@ private function __construct() { * @return Pattern_Builder */ public static function get_instance(): Pattern_Builder { - if ( self::$instance === null ) { + if ( null === self::$instance ) { self::$instance = new self(); } diff --git a/index.php b/index.php index 57f5427..6220032 100644 --- a/index.php +++ b/index.php @@ -1,2 +1,2 @@ - + + + + + + + + + + + + diff --git a/src/PatternBuilder_Admin.js b/src/PatternBuilder_Admin.js deleted file mode 100644 index 7254990..0000000 --- a/src/PatternBuilder_Admin.js +++ /dev/null @@ -1,12 +0,0 @@ -import { createRoot } from '@wordpress/element'; -import { AdminLandingPage } from './components/AdminLandingPage'; - -window.addEventListener( - 'load', - function () { - const domNode = document.getElementById( 'pattern-builder-app' ); - const root = createRoot( domNode ); - root.render( ); - }, - false -); diff --git a/src/components/AdminLandingPage.js b/src/components/AdminLandingPage.js deleted file mode 100644 index aed3d8e..0000000 --- a/src/components/AdminLandingPage.js +++ /dev/null @@ -1,140 +0,0 @@ -import './AdminLandingPage.scss'; - -export const AdminLandingPage = () => { - return ( -
-
-

Pattern Builder

- by Twenty Bellows -
- -
-

Welcome to the Pattern Builder!

-

- This plugin adds functionality to the{ ' ' } - WordPress Editor to enhance the{ ' ' } - Pattern Building experience. You'll find all of the - functionality that the Pattern Builder Provides in the{ ' ' } - Site Editor and Block Editor. Patterns - themselves are already a part of WordPress and these tools - makes Patterns easer to build and use. -

-

- The following topics help you to better understand how to - take full advantage of Patterns in your site building. - - { ' ' } - (These links will take you off-site to the Pattern - Builder documentation.) - -

-

The Basics

- -

How to

- -
-
- ); -}; diff --git a/src/components/AdminLandingPage.scss b/src/components/AdminLandingPage.scss deleted file mode 100644 index 19af1ba..0000000 --- a/src/components/AdminLandingPage.scss +++ /dev/null @@ -1,45 +0,0 @@ -.pattern-builder__admin-landing-page { - color: #1C1C1D; - margin-left: -20px; - - a { - color: #1C1C1D; - text-underline-offset: 0px; - text-decoration-thickness: 0px; - transition: text-underline-offset 0.1s ease-in; - &:hover { - text-decoration-thickness: 2px; - text-underline-offset: 3px; - } - } - - .pattern-builder__admin-landing-page__header { - margin: 0; - color: #FFF; - background-color: #627F84; - padding: 150px 20px 40px 40px; - h1 { - color: #EDE6D7; - font-weight: 200; - font-size: 6rem; - line-height: 1; - margin: 0; - } - } - .pattern-builder__admin-landing-page__body { - padding: 40px; - max-width: 600px; - } - - ul{ - padding-left: 20px; - li { - list-style-type: disc; - } - } - - .pattern-builder__admin-landing-page__body__muted-text { - color: #5d5d5d; - font-size: .75rem; - } -} diff --git a/src/utils/index.js b/src/utils/index.js index 83ee58b..cc0c67b 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -2,16 +2,16 @@ * Utility exports */ -export { - getLocalStorageValue, - setLocalStorageValue, - removeLocalStorageValue, - getLocalizePatternsSetting, +export { + getLocalStorageValue, + setLocalStorageValue, + removeLocalStorageValue, + getLocalizePatternsSetting, setLocalizePatternsSetting, getImportImagesSetting, - setImportImagesSetting + setImportImagesSetting, } from './localStorage'; export { fetchAllPatterns } from './resolvers'; -export { PatternSaveMonitor } from './patternSaveMonitor'; \ No newline at end of file +export { PatternSaveMonitor } from './patternSaveMonitor'; diff --git a/src/utils/store.js b/src/utils/store.js deleted file mode 100644 index 43c0deb..0000000 --- a/src/utils/store.js +++ /dev/null @@ -1,189 +0,0 @@ -import { createReduxStore, register } from '@wordpress/data'; -import { - deletePattern, - fetchEditorConfiguration, - savePattern, - fetchAllPatterns, -} from './resolvers'; - -// Helper function to format patterns for WordPress editor settings -function formatPatternsForEditor( patterns ) { - return patterns.map( ( pattern ) => { - return { - ...pattern, - syncStatus: pattern.synced ? 'fully' : 'unsynced', - content: pattern.synced - ? `` - : pattern.content || '', - }; - } ); -} - -const SET_ACTIVE_PATTERN = 'SET_ACTIVE_PATTERN'; -const DELETE_ACTIVE_PATTERN = 'DELETE_ACTIVE_PATTERN'; -const SET_EDITOR_CONFIGURATION = 'SET_EDITOR_CONFIGURATION'; -const SET_ALL_PATTERNS = 'SET_ALL_PATTERNS'; -const SET_FILTER_OPTIONS = 'SET_FILTER_OPTIONS'; - -const initialState = { - activePattern: null, - editorConfiguration: {}, - allPatterns: [], - filterOptions: { - source: 'all', - synced: 'all', - category: 'all', - hidden: 'visible', - keyword: '', - }, -}; - -const reducer = ( state = initialState, action ) => { - switch ( action.type ) { - case SET_ACTIVE_PATTERN: - return { - ...state, - activePattern: action.value, - }; - case DELETE_ACTIVE_PATTERN: - return { - ...state, - activePattern: null, - }; - case SET_EDITOR_CONFIGURATION: - return { - ...state, - editorConfiguration: { - ...action.value, - __experimentalBlockPatterns: - state.editorConfiguration.__experimentalBlockPatterns || - [], - }, - }; - case SET_ALL_PATTERNS: - return { - ...state, - allPatterns: action.value, - editorConfiguration: { - ...state.editorConfiguration, - __experimentalBlockPatterns: formatPatternsForEditor( - action.value - ), - }, - }; - case SET_FILTER_OPTIONS: - return { - ...state, - filterOptions: { - ...state.filterOptions, - ...action.value, - }, - }; - default: - return state; - } -}; - -const actions = { - setActivePattern: ( value ) => ( { type: SET_ACTIVE_PATTERN, value } ), - deleteActivePattern: - ( patternToDelete ) => - async ( { dispatch, select } ) => { - if ( ! patternToDelete ) { - throw new Error( 'No pattern to delete.' ); - } - - await deletePattern( patternToDelete ); - dispatch( actions.setActivePattern( null ) ); - dispatch( actions.fetchAllPatterns() ); - }, - setEditorConfiguration: ( value ) => ( { - type: SET_EDITOR_CONFIGURATION, - value, - } ), - fetchEditorConfiguration: - () => - async ( { dispatch } ) => { - try { - const config = await fetchEditorConfiguration(); - dispatch( actions.setEditorConfiguration( config ) ); - } catch ( error ) { - console.error( 'Failed to load editor configuration:', error ); - } - }, - saveActivePattern: - ( updatedPattern ) => - async ( { dispatch } ) => { - if ( ! updatedPattern ) { - console.warn( 'No pattern provided to save.' ); - return; - } - - const savedPattern = await savePattern( updatedPattern ); - dispatch( actions.setActivePattern( savedPattern ) ); - - // Fetch all patterns to refresh the editor settings - await dispatch( actions.fetchAllPatterns() ); - - // Invalidate the WordPress core cache for this pattern - // This forces the site editor to refetch the updated pattern - if ( savedPattern.id ) { - wp.data - .dispatch( 'core' ) - .invalidateResolution( 'getEntityRecord', [ - 'postType', - 'tbell_pattern_block', - savedPattern.id, - ] ); - wp.data - .dispatch( 'core' ) - .invalidateResolution( 'getEntityRecord', [ - 'postType', - 'wp_block', - savedPattern.id, - ] ); - wp.data - .dispatch( 'core' ) - .invalidateResolution( 'getEntityRecords', [ - 'postType', - 'tbell_pattern_block', - ] ); - wp.data - .dispatch( 'core' ) - .invalidateResolution( 'getEntityRecords', [ - 'postType', - 'wp_block', - ] ); - } - - return savedPattern; - }, - fetchAllPatterns: - () => - async ( { dispatch } ) => { - try { - const patterns = await fetchAllPatterns(); - dispatch( { type: SET_ALL_PATTERNS, value: patterns } ); - } catch ( error ) { - console.error( 'Failed to fetch all patterns:', error ); - } - }, - setFilterOptions: ( value ) => ( { type: SET_FILTER_OPTIONS, value } ), -}; - -const selectors = { - getActivePattern: ( state ) => state.activePattern, - getEditorConfiguration: ( state ) => state.editorConfiguration, - getAllPatterns: ( state ) => state.allPatterns, - getFilterOptions: ( state ) => state.filterOptions, -}; - -const store = createReduxStore( 'pattern-builder', { - reducer, - actions, - selectors, -} ); - -register( store ); - -export default store; diff --git a/src/utils/syncedPatternFilter.js b/src/utils/syncedPatternFilter.js index 0bc9ab0..8e908b0 100644 --- a/src/utils/syncedPatternFilter.js +++ b/src/utils/syncedPatternFilter.js @@ -75,25 +75,36 @@ export const SyncedPatternRenderer = ( { attributes, clientId } ) => { * This filter checks if the block being edited is a core/pattern block with a slug and content. * If so, it renders the SyncedPatternRenderer component instead of the default BlockEdit. * + * Note: useSelect must be called unconditionally (Rules of Hooks). The conditional logic + * is applied to the hook's returned value, not to whether the hook is called. + * */ export const syncedPatternFilter = ( BlockEdit ) => ( props ) => { const { name, attributes } = props; - if ( name === 'core/pattern' && attributes.slug && attributes.content ) { - const selectedPattern = useSelect( - ( select ) => - select( blockEditorStore ).__experimentalGetParsedPattern( - attributes.slug - ), - [ props.attributes.slug ] - ); - if ( - selectedPattern?.blocks?.length === 1 && - selectedPattern.blocks[ 0 ].name === 'core/block' - ) { - return ; - } + // useSelect is always called — never inside a conditional. + const selectedPattern = useSelect( + ( select ) => { + if ( name !== 'core/pattern' || ! attributes.slug ) { + return null; + } + return select( blockEditorStore ).__experimentalGetParsedPattern( + attributes.slug + ); + }, + [ name, attributes.slug ] + ); + + if ( + name === 'core/pattern' && + attributes.slug && + attributes.content && + selectedPattern?.blocks?.length === 1 && + selectedPattern.blocks[ 0 ].name === 'core/block' + ) { + return ; } + return ; }; diff --git a/webpack.config.js b/webpack.config.js index 12e215c..fbf7281 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -7,7 +7,6 @@ defaultConfig[ 0 ] = { ...{ entry: { PatternBuilder_EditorTools: './src/PatternBuilder_EditorTools.js', - PatternBuilder_Admin: './src/PatternBuilder_Admin.js', }, }, };