diff --git a/.distignore b/.distignore
new file mode 100644
index 0000000..f92cc22
--- /dev/null
+++ b/.distignore
@@ -0,0 +1,36 @@
+/.claude
+/.distignore
+/.editorconfig
+/.git
+/.github
+/.gitignore
+/.phpunit.result.cache
+/.prettierrc
+/.vscode
+/.wordpress-org
+/.wp-env.json
+/.wp-env.override.json
+/.wp-env.override.json.example
+/CLAUDE.md
+/bin
+/bootstrap.php
+/composer.json
+/composer.lock
+/dev-assets
+/docs
+/node_modules
+/package.json
+/package-lock.json
+/phpcs.xml.dist
+/phpunit-watcher.yml
+/phpunit.xml
+/plugin-test-blueprint.json
+/readme.md
+/release
+/scripts
+/src
+/svn
+/tests
+/vendor
+/webpack.config.js
+*.zip
diff --git a/.wordpress-org/icon-128x128.png b/.wordpress-org/icon-128x128.png
new file mode 100644
index 0000000..915c540
Binary files /dev/null and b/.wordpress-org/icon-128x128.png differ
diff --git a/.wordpress-org/icon-256x256.png b/.wordpress-org/icon-256x256.png
new file mode 100644
index 0000000..21d7a5c
Binary files /dev/null and b/.wordpress-org/icon-256x256.png differ
diff --git a/.wordpress-org/icon.svg b/.wordpress-org/icon.svg
new file mode 100644
index 0000000..84c1f92
--- /dev/null
+++ b/.wordpress-org/icon.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/CLAUDE.md b/CLAUDE.md
index 484a006..018126a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,39 +4,31 @@ This file provides guidance to Claude Code and other AI coding agents when worki
## Project Overview
-**Pattern Builder** is a WordPress plugin developed by [Twenty Bellows](https://twentybellows.com). It allows WordPress users to create, edit, organize, and manage block patterns directly in the admin interface — unifying theme patterns (PHP files) and user-created patterns (custom post type) in a single, intuitive UI with visual editing, code editing, live preview, and export capabilities.
+**Pattern Builder** is a WordPress plugin developed by [Twenty Bellows](https://twentybellows.com). It allows WordPress users to create, edit, organize, and manage block patterns directly in the admin interface — unifying theme patterns (PHP files) and user-created patterns (`wp_block` posts) in a single, intuitive UI with visual editing, live preview, metadata management, and conversion between the two.
-- **Version:** 1.0.4
+- **Version:** 2.0.0
- **Repository:** https://github.com/twenty-bellows/pattern-builder
- **Issue Tracker:** GitHub Issues — https://github.com/twenty-bellows/pattern-builder/issues
- **Plugin URI:** https://www.twentybellows.com/pattern-builder/
- **License:** GPL-2.0-or-later
-- **WordPress Requires:** 6.6+
-- **PHP Requires:** 7.2+
-
-## Development Environment
+- **WordPress Requires:** 6.8+
+- **PHP Requires:** 7.4+
## 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:
+Version 2.0 removed the 1.x DB-mirror + REST-hijacking design entirely. Theme pattern files are the single source of truth; nothing is mirrored into the database and no core REST route is intercepted.
-**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.
+**Theme patterns are file-backed REST entities.** A rowless post type `pb_pattern` (registered like core's `wp_template` — zero DB rows) hangs `Pattern_Builder_REST_Patterns_Controller` off core routing at `/pattern-builder/v1/patterns`. Theme patterns have string IDs (their namespaced name, e.g. `my-theme/hero`), templates-style. Reads come from the pattern files (child + parent theme); writes go back to the files (`Pattern_File_Store`). Because the type is `show_in_rest`, the block editor auto-creates a matching client-side entity from `/wp/v2/types`, which gives theme patterns entity-powered editing (undo, dirty tracking, save flow) for free.
-**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.
+**Editing surfaces — always the WordPress editor, never a custom one.** The post editor opens any pattern in place via `onNavigateToEntityRecord` (both `wp_block` and `pb_pattern`). User patterns are otherwise edited by the Site Editor natively (its `/wp_block/:postId` route) — in place from within the Site Editor, deep-linked from everywhere else. Theme patterns cannot open in the Site Editor's canvas (core hard-codes the entity types its canvas binds and keeps route registration private), so from the Site Editor and the browse screen they open Appearance → Pattern Builder's edit mode (`&pattern={id}`), which boots core's own edit-post editor (`wp.editPost.initializeEditor`) against the `pb_pattern` entity — the genuine post-editor chrome, with a validated `back` URL whose Back button returns to the originating screen. The Appearance page's browse mode is a Site-Editor-style library: a category rail with counts, a grid of uniform 1:1 pattern cards, and a details sidebar for the selected pattern carrying the same panels the editor shows (staged on the entity, persisted by its Save button; Edit opens the pattern's editor).
-**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
+**Synced patterns via the `core/pattern` content runtime.** Synced theme patterns (`Synced: yes` file header) work exactly like Synced Patterns for Themes 2.0: `core/pattern` gets a `content` attribute + `pattern/overrides` context and a render callback that attaches the pattern's blocks as inner blocks (`Pattern_Block`); `Pattern_Resolver` composes editor-facing content; a synthesized `--synced-instance` companion entry puts a reference in the inserter. Inserted copies are plain `` — no post ID anywhere.
-**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.
+**Companion plugin coexistence.** The runtime classes (`Pattern_Block`, `Pattern_Resolver`, `Block_Markup`, `Inner_HTML_Processor`, `Synced_Patterns`, `Editor_Support`, and the `src/runtime/` JS) are vendored from [`synced-patterns-for-themes`](https://github.com/Twenty-Bellows/synced-patterns-for-themes) and must stay logic-identical to it. Pattern Builder always registers the full stack; when both plugins are installed, the companion sees `PATTERN_BUILDER_VERSION` at `plugins_loaded` and stays entirely unloaded — one check in one place, no coordination anywhere else. Deactivate Pattern Builder and the companion takes over again with identical rendering (both read the same `Synced: yes` header; keeping the vendored runtime in sync at release time is what makes the hand-off invisible). Pattern Builder also clears the companion's transient after file writes so it never wakes to a stale cache.
-**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
+**Migration from 1.x.** `Pattern_Builder_Migration` runs once on upgrade: it rewrites `wp:block` refs pointing at the old `tbell_pattern_block` mirror posts to `wp:pattern` slugs (in post content and theme files), then deletes the mirror posts and the old capabilities.
-**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.
+**Webpack entries:** `PatternBuilder_EditorTools.js` (management: sidebar, panels, save monitor — all block-editor screens), `PatternBuilder_Runtime.js` (the vendored content runtime — enqueued only when this plugin owns it), `PatternBuilder_Admin.js` (the browse grid, plus the edit-mode boot of core's edit-post editor).
---
@@ -44,7 +36,7 @@ A full architectural analysis is in [`docs/architecture.md`](docs/architecture.m
### Prerequisites
- Node.js (v18+ recommended)
-- PHP 7.2+ with Composer
+- PHP 7.4+ with Composer
- Docker (for `wp-env` local WordPress environment and PHP integration tests)
### Environment Notes
@@ -80,6 +72,13 @@ A full architectural analysis is in [`docs/architecture.md`](docs/architecture.m
- `npm run plugin-test-env` - Start WP Playground for testing
- `npm run plugin-test` - Full build, zip, and test workflow
+### Releasing to WordPress.org (same workflow as synced-patterns-for-themes)
+- `npm run plugin-ship:dry-run` - Stage everything (SVN sync, assets, tag) and stop before the commit
+- `npm run plugin-ship` - Ship the release to the WordPress.org SVN (asks for confirmation; SVN prompts for wp.org credentials)
+- `npm run plugin-ship:reset` - Put the `svn/` working copy back the way wp.org has it
+- The ship set is defined by `.distignore`; wp.org assets (icon) live in `.wordpress-org/`
+- Preflight requires the version to agree in `pattern-builder.php` (header), `package.json`, and readme.txt's `Stable tag`
+
## Architecture Overview
### Plugin Structure
@@ -88,31 +87,39 @@ The plugin follows a component-based OOP architecture with clear separation of c
1. **Main Entry Point**: `pattern-builder.php` initializes the plugin
2. **Core Class**: `Pattern_Builder` (singleton in `includes/class-pattern-builder.php`) bootstraps all plugin components
3. **Component Classes** (`includes/`):
- - `Pattern_Builder_API` - REST API endpoints under `/pattern-builder/v1/`
- - `Pattern_Builder_Admin` - Admin UI under Appearance → Pattern Builder
- - `Pattern_Builder_Editor` - Block editor integration
- - `Pattern_Builder_Post_Type` - Custom post type for pattern storage
- - `Pattern_Builder_Security` - Security/nonce helpers
+ - `Pattern_Builder_Entity` - Rowless `pb_pattern` post type registration
+ - `Pattern_Builder_REST_Patterns_Controller` - String-ID REST controller for theme patterns
+ - `Pattern_File_Store` - Reads/writes pattern files; image import/export; conversions
+ - `Pattern_Builder_API` - The `/pattern-builder/v1/process-theme` endpoint
+ - `Pattern_Builder_Admin` - Appearance → Pattern Builder: browse grid + core-editor boot
+ - `Pattern_Builder_Editor` - Block editor asset integration
+ - `Pattern_Builder_Migration` - One-time 1.x → 2.0 upgrade
+ - `Pattern_Builder_Security` - File-path validation and safe filesystem helpers
- `Pattern_Builder_Localization` - i18n support
+ - Vendored runtime (kept identical to synced-patterns-for-themes): `Pattern_Block`, `Pattern_Resolver`, `Block_Markup`, `Inner_HTML_Processor`, `Synced_Patterns`, `Editor_Support`
### Frontend Architecture
-- **Build System**: Webpack via `@wordpress/scripts` with two entry points:
- - `src/PatternBuilder_EditorTools.js` - Editor-specific functionality (Gutenberg sidebar panels)
- - `src/PatternBuilder_Admin.js` - Admin interface (Appearance → Pattern Builder page)
+- **Build System**: Webpack via `@wordpress/scripts` with three entry points:
+ - `src/PatternBuilder_EditorTools.js` - Editor tools (sidebar, document panels, save monitor)
+ - `src/PatternBuilder_Runtime.js` - The vendored core/pattern content runtime
+ - `src/PatternBuilder_Admin.js` - The Appearance → Pattern Builder page (browse grid + edit-post boot)
- **React Components** in `src/components/`:
- `PatternBrowserPanel` - Main pattern browsing interface
- `PatternCreatePanel` - Pattern creation flow
- `PatternPreview` - Pattern preview rendering
- `BlockBindingsPanel` - Block bindings configuration panel
- - `PatternAssociationsPanel`, `PatternSyncedStatusPanel`, `PatternPanelAdditions`, `PatternSourcePanel` - Editor sidebar panels
+ - `PatternAssociationsPanel`, `PatternSyncedStatusPanel`, `PatternMetadataPanel`, `PatternPanelAdditions`, `PatternSourcePanel` - Editor sidebar panels (also reused by the browse page's details sidebar)
+ - `PatternCard`, `PatternDetailsPanel` - The browse grid's square cards and details sidebar
+ - `EditPatternToolbarButton` - "Edit Pattern" in the toolbar of synced theme pattern instances
- `EditorSidePanel` - Editor sidebar container
- - `AdminLandingPage` - Main admin page component
- `PatternList` - Pattern list/grid view
- `PatternBuilderConfiguration` - Plugin settings UI
-- **State Management**: WordPress data stores via `src/utils/store.js`
+- **Admin app** in `src/admin/`: `App`, `PatternBrowser`, `editor-boot`
+- **Vendored runtime** in `src/runtime/` (keep identical to synced-patterns-for-themes `src/`)
+- **State Management**: core data stores (`core`, `core/editor`, `core/block-editor`) — no custom store
### Pattern Handling
-- Supports both **theme patterns** (PHP files in `patterns/`) and **user patterns** (custom post type `pattern_builder`)
+- Supports both **theme patterns** (PHP files in `patterns/`, child and parent theme) and **user patterns** (core `wp_block` posts)
- Abstract pattern class (`src/objects/AbstractPattern.js`) provides unified interface
- Pattern syncing capabilities between theme files and database
diff --git a/bin/ship-reset.sh b/bin/ship-reset.sh
new file mode 100755
index 0000000..956e029
--- /dev/null
+++ b/bin/ship-reset.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+#
+# Put the SVN working copy back the way wp.org has it.
+#
+# Reverts every scheduled change and deletes anything unversioned, which is
+# what you want after a --dry-run or a run that stopped part way through.
+#
+# Usage: npm run plugin-ship:reset
+
+set -euo pipefail
+
+readonly ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+readonly SVN_DIR="${ROOT}/svn"
+
+[ -d "${SVN_DIR}/.svn" ] || { echo "No SVN working copy at ${SVN_DIR}; nothing to reset." >&2; exit 0; }
+
+if [ -z "$(svn status "$SVN_DIR")" ]; then
+ echo 'SVN working copy is already clean.'
+ exit 0
+fi
+
+echo 'Reverting scheduled changes...'
+svn revert --recursive --quiet "$SVN_DIR"
+
+# Reverting unschedules adds but leaves the files on disk; clear those out too.
+echo 'Removing unversioned files...'
+svn status "$SVN_DIR" | sed -n -E 's/^\?[[:space:]]+//p' | while IFS= read -r extra; do
+ [ -n "$extra" ] && rm -rf "$extra"
+done
+
+if [ -n "$(svn status "$SVN_DIR")" ]; then
+ echo
+ svn status "$SVN_DIR"
+ echo
+ echo 'Some changes remain; inspect them by hand.' >&2
+ exit 1
+fi
+
+echo 'Clean.'
diff --git a/bin/ship.sh b/bin/ship.sh
new file mode 100755
index 0000000..51f7535
--- /dev/null
+++ b/bin/ship.sh
@@ -0,0 +1,248 @@
+#!/usr/bin/env bash
+#
+# Ship a release to the WordPress.org plugin directory.
+#
+# Syncs the plugin files into the SVN working copy at ./svn, refreshes the
+# wp.org assets, creates the version tag, and commits all three in one go.
+#
+# Usage: npm run plugin-ship [-- ]
+#
+# --dry-run Do everything except the commit, then stop and report.
+# --yes Skip the confirmation prompt (for unattended runs).
+# --skip-build Reuse the existing build/ instead of rebuilding.
+# --allow-dirty Ship even though the git working tree has changes.
+# --help Show this message.
+#
+# The set of files that ship is defined by .distignore.
+# The wp.org assets (icon, banner, screenshots) live in .wordpress-org/.
+
+set -euo pipefail
+
+readonly SLUG='pattern-builder'
+# SHIP_SVN_URL overrides the target repository (for rehearsing against a local svn repo).
+readonly SVN_URL="${SHIP_SVN_URL:-https://plugins.svn.wordpress.org/${SLUG}}"
+
+readonly ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+readonly SVN_DIR="${ROOT}/svn"
+readonly ASSETS_SRC="${ROOT}/.wordpress-org"
+
+DRY_RUN=0
+ASSUME_YES=0
+SKIP_BUILD=0
+ALLOW_DIRTY=0
+
+# --- output helpers ----------------------------------------------------------
+
+if [ -t 1 ]; then
+ C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'
+ C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'
+else
+ C_RESET=''; C_BOLD=''; C_DIM=''; C_RED=''; C_GREEN=''; C_YELLOW=''
+fi
+
+step() { printf '\n%s==>%s %s%s%s\n' "$C_BOLD$C_GREEN" "$C_RESET" "$C_BOLD" "$*" "$C_RESET"; }
+info() { printf ' %s\n' "$*"; }
+dim() { printf ' %s%s%s\n' "$C_DIM" "$*" "$C_RESET"; }
+warn() { printf '%s!! %s%s\n' "$C_YELLOW" "$*" "$C_RESET" >&2; }
+die() { printf '\n%sxx %s%s\n\n' "$C_RED" "$*" "$C_RESET" >&2; exit 1; }
+
+usage() { sed -n '2,/^set -euo/p' "${BASH_SOURCE[0]}" | sed -e 's/^# \{0,1\}//' -e '/^set -euo/d'; }
+
+# --- arguments ---------------------------------------------------------------
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --dry-run) DRY_RUN=1 ;;
+ --yes|-y) ASSUME_YES=1 ;;
+ --skip-build) SKIP_BUILD=1 ;;
+ --allow-dirty) ALLOW_DIRTY=1 ;;
+ --help|-h) usage; exit 0 ;;
+ *) die "Unknown option: $1 (try --help)" ;;
+ esac
+ shift
+done
+
+cd "$ROOT"
+
+# --- 1. preflight ------------------------------------------------------------
+
+step 'Preflight'
+
+for cmd in svn rsync npm node git; do
+ command -v "$cmd" >/dev/null 2>&1 || die "Required command not found: ${cmd}"
+done
+dim "tooling ok: svn, rsync, npm, node, git"
+
+# Version must agree in all three places wp.org and WordPress read it from.
+pkg_version="$(node -p "require('./package.json').version")"
+hdr_version="$(sed -n -E 's/^[[:space:]]*\*[[:space:]]*Version:[[:space:]]*(.+[^[:space:]])[[:space:]]*$/\1/p' "${SLUG}.php" | head -n1)"
+txt_version="$(sed -n -E 's/^Stable tag:[[:space:]]*(.+[^[:space:]])[[:space:]]*$/\1/p' readme.txt | head -n1)"
+
+[ -n "$pkg_version" ] || die 'Could not read version from package.json'
+[ -n "$hdr_version" ] || die "Could not read Version: from ${SLUG}.php"
+[ -n "$txt_version" ] || die 'Could not read Stable tag: from readme.txt'
+
+if [ "$pkg_version" != "$hdr_version" ] || [ "$pkg_version" != "$txt_version" ]; then
+ printf '\n' >&2
+ printf ' package.json version %s\n' "$pkg_version" >&2
+ printf ' %s.php Version: %s\n' "$SLUG" "$hdr_version" >&2
+ printf ' readme.txt Stable tag: %s\n' "$txt_version" >&2
+ die 'Version mismatch. Make all three agree before shipping.'
+fi
+
+readonly VERSION="$pkg_version"
+dim "version ${VERSION} (package.json, plugin header, readme.txt all agree)"
+
+# The release should correspond to a commit, so it can be reproduced later.
+if [ -n "$(git status --porcelain)" ]; then
+ if [ "$ALLOW_DIRTY" -eq 1 ]; then
+ warn 'git working tree is dirty; shipping anyway (--allow-dirty)'
+ else
+ printf '\n' >&2
+ git status --short >&2
+ die 'git working tree is not clean. Commit first, or pass --allow-dirty.'
+ fi
+fi
+GIT_SHA="$(git rev-parse --short HEAD)"
+dim "git ${GIT_SHA} on $(git rev-parse --abbrev-ref HEAD)"
+
+[ -d "$ASSETS_SRC" ] || die "Missing assets directory: ${ASSETS_SRC}"
+[ -n "$(find "$ASSETS_SRC" -type f -print -quit)" ] || die "No files in ${ASSETS_SRC}"
+dim "assets source: .wordpress-org/ ($(find "$ASSETS_SRC" -type f | wc -l | tr -d ' ') files)"
+
+# --- 2. svn working copy -----------------------------------------------------
+
+step 'Preparing SVN working copy'
+
+if [ ! -d "${SVN_DIR}/.svn" ]; then
+ info "No checkout at svn/ yet, fetching ${SVN_URL}"
+ svn checkout "$SVN_URL" "$SVN_DIR"
+else
+ actual_url="$(svn info --show-item url "$SVN_DIR" | tr -d '[:space:]')"
+ [ "$actual_url" = "$SVN_URL" ] || die "svn/ points at ${actual_url}, expected ${SVN_URL}"
+fi
+
+# A dirty working copy means a previous run left something behind; refuse to
+# build a release on top of it rather than committing whatever is lying around.
+if [ -n "$(svn status "$SVN_DIR")" ]; then
+ printf '\n' >&2
+ svn status "$SVN_DIR" >&2
+ printf '\n Reset it with:\n %s\n' "npm run plugin-ship:reset" >&2
+ die 'The SVN working copy has uncommitted changes.'
+fi
+
+info 'Updating from wp.org'
+svn update --quiet "$SVN_DIR"
+dim "at r$(svn info --show-item revision "$SVN_DIR" | tr -d '[:space:]')"
+
+if svn ls "${SVN_URL}/tags/${VERSION}" >/dev/null 2>&1; then
+ die "Tag ${VERSION} already exists on wp.org. Bump the version to ship again."
+fi
+dim "tags/${VERSION} is free"
+
+# --- 3. build ----------------------------------------------------------------
+
+if [ "$SKIP_BUILD" -eq 1 ]; then
+ step 'Build (skipped)'
+ warn 'Shipping the existing build/ without rebuilding'
+else
+ step 'Building'
+ npm run build
+fi
+
+for bundle in PatternBuilder_Admin PatternBuilder_EditorTools PatternBuilder_Runtime; do
+ [ -f "${ROOT}/build/${bundle}.js" ] || die "build/${bundle}.js is missing; the build did not produce output."
+done
+
+# --- 4. sync -----------------------------------------------------------------
+
+step 'Syncing files into svn/trunk'
+
+# Two hops on purpose. rsync protects excluded files on the receiving side from
+# --delete, so syncing the repo straight into trunk would strand any file that
+# used to ship and has since been added to .distignore. Assembling the release
+# in a clean staging directory first means the second hop excludes nothing but
+# .svn, so --delete can prune trunk down to exactly what was staged.
+STAGING="$(mktemp -d)"
+trap 'rm -rf "$STAGING"' EXIT
+
+rsync -a --delete \
+ --exclude='.svn' \
+ --exclude-from="${ROOT}/.distignore" \
+ "${ROOT}/" "${STAGING}/"
+
+rsync -a --delete \
+ --exclude='.svn' \
+ "${STAGING}/" "${SVN_DIR}/trunk/"
+
+info "$(find "${SVN_DIR}/trunk" -type f -not -path '*/.svn/*' | wc -l | tr -d ' ') files in trunk"
+
+step 'Syncing wp.org assets'
+
+rsync -a --delete \
+ --exclude='.svn' \
+ "${ASSETS_SRC}/" "${SVN_DIR}/assets/"
+
+info "$(find "${SVN_DIR}/assets" -type f -not -path '*/.svn/*' | wc -l | tr -d ' ') files in assets"
+
+# Teach svn about anything rsync added or removed.
+reconcile() {
+ local dir="$1"
+
+ # --force adds unversioned children without complaining about the rest.
+ svn add --force --quiet "$dir"
+
+ # Files rsync deleted show up as missing (!) and must be removed explicitly.
+ svn status "$dir" | sed -n -E 's/^![[:space:]]+//p' | while IFS= read -r missing; do
+ [ -n "$missing" ] && svn rm --quiet --force "$missing"
+ done
+}
+
+step 'Reconciling adds and deletes'
+reconcile "${SVN_DIR}/trunk"
+reconcile "${SVN_DIR}/assets"
+dim 'done'
+
+# --- 5. tag ------------------------------------------------------------------
+
+step "Tagging ${VERSION}"
+
+[ -e "${SVN_DIR}/tags/${VERSION}" ] && die "svn/tags/${VERSION} already exists locally."
+svn copy --quiet "${SVN_DIR}/trunk" "${SVN_DIR}/tags/${VERSION}"
+dim "trunk copied to tags/${VERSION}"
+
+# --- 6. review ---------------------------------------------------------------
+
+step 'Pending changes'
+
+# Suppress the tag's per-file noise; the tag copy is one logical change.
+( cd "$SVN_DIR" && svn status | grep -v "tags/${VERSION}/" ) || true
+printf '\n'
+info "plus the whole of tags/${VERSION} (copied from trunk)"
+
+readonly COMMIT_MSG="Release ${VERSION} (git ${GIT_SHA})"
+
+if [ "$DRY_RUN" -eq 1 ]; then
+ step 'Dry run: stopping before commit'
+ info "Would commit with message: ${COMMIT_MSG}"
+ printf '\n Undo everything staged above with:\n %s\n\n' "npm run plugin-ship:reset"
+ exit 0
+fi
+
+# --- 7. commit ---------------------------------------------------------------
+
+if [ "$ASSUME_YES" -eq 0 ]; then
+ printf '\n%sThis publishes %s to wordpress.org and cannot be undone.%s\n' "$C_BOLD" "$VERSION" "$C_RESET"
+ printf 'Type %syes%s to commit: ' "$C_BOLD" "$C_RESET"
+ read -r reply
+ [ "$reply" = 'yes' ] || die 'Aborted. Nothing was committed; run with --dry-run notes to reset.'
+fi
+
+step 'Committing to wp.org'
+info 'SVN will ask for your wordpress.org credentials if they are not cached.'
+
+svn commit "$SVN_DIR" -m "$COMMIT_MSG"
+
+step "Shipped ${VERSION}"
+info "https://wordpress.org/plugins/${SLUG}/"
+dim 'wp.org rebuilds the download zip from the Stable tag; it can take a few minutes.'
diff --git a/bootstrap.php b/bootstrap.php
index a5e2592..0a36974 100644
--- a/bootstrap.php
+++ b/bootstrap.php
@@ -32,3 +32,6 @@ function _manually_load_plugin() {
// Start up the WP testing environment.
require "{$_tests_dir}/includes/bootstrap.php";
+
+// Shared base class for the vendored pattern-runtime tests.
+require __DIR__ . '/tests/php/class-pattern-test-case.php';
diff --git a/dev-assets/themes/simple-theme/templates/index.html b/dev-assets/themes/simple-theme/templates/index.html
index 332fb7a..c5b9902 100644
--- a/dev-assets/themes/simple-theme/templates/index.html
+++ b/dev-assets/themes/simple-theme/templates/index.html
@@ -1,6 +1,6 @@
-
-
+
+
-
\ No newline at end of file
+
diff --git a/docs/architecture-2.0.md b/docs/architecture-2.0.md
new file mode 100644
index 0000000..5377bb7
--- /dev/null
+++ b/docs/architecture-2.0.md
@@ -0,0 +1,119 @@
+# Pattern Builder 2.0 — Architecture
+
+> **Version:** 2.0.0
+> Replaces the 1.x design analyzed in [`architecture.md`](architecture.md)
+> (DB mirror + REST hijacking — both removed).
+
+## The idea
+
+Theme pattern files are the single source of truth. Nothing is mirrored into
+the database, no core REST route is intercepted, and a synced pattern is
+referenced by its slug — never by a post ID.
+
+Two problems shaped 1.x, and 2.0 solves each with a mechanism core already
+uses for something else:
+
+1. **"The editor can only edit things with a post ID."** False since
+ templates: core's `wp_template` is a *registered post type with zero rows*
+ whose REST controller serves file-backed entities with string IDs
+ (`theme//slug`). Pattern Builder does the same: a rowless `pb_pattern`
+ post type whose controller (`Pattern_Builder_REST_Patterns_Controller`)
+ serves theme patterns at `/pattern-builder/v1/patterns/{theme}/{name}`,
+ reading from and writing to the pattern files. Because the type is
+ `show_in_rest`, the block editor auto-creates a matching client-side
+ entity from `/wp/v2/types` — undo, dirty tracking, and the save flow all
+ come from core's entity layer.
+
+2. **"A synced pattern needs a post to reference."** False since the
+ companion plugin's 2.0: `core/pattern` gets a `content` attribute and
+ `pattern/overrides` context — the exact shape `core/block` already has —
+ and a render callback that attaches the pattern's blocks as inner blocks
+ so core's own `core/pattern-overrides` binding source resolves overrides.
+ An inserted synced pattern is ``.
+
+## The pieces
+
+### PHP (`includes/`)
+
+| Piece | Job |
+|---|---|
+| `Pattern_Builder_Entity` | Registers the rowless `pb_pattern` post type (REST namespace `pattern-builder/v1`, base `patterns`). |
+| `Pattern_Builder_REST_Patterns_Controller` | String-ID CRUD for theme patterns; the collection also lists user patterns (`wp_block`, numeric IDs) so one request paints the whole library. Creation accepts `fromWpBlock` (user→theme conversion); an update with `source: "user"` converts theme→user. |
+| `Pattern_File_Store` | The file pipeline: parent+child theme scanning, header round-trip (Title, Slug, Description, Categories, Keywords, Block Types, Post Types, Template Types, Viewport Width, Inserter, Synced), image import (theme assets) / export (media library), formatting, conversions, cache flushing. |
+| `Pattern_Builder_API` | The one non-entity route: `POST /pattern-builder/v1/process-theme` (bulk localize / import-images). |
+| `Pattern_Builder_Admin` | Appearance → Pattern Builder: the browse grid, and an edit mode that boots core's edit-post editor with real block-editor settings. |
+| `Pattern_Builder_Editor` | Enqueues the management bundle on block-editor screens. |
+| `Pattern_Builder_Migration` | One-time 1.x upgrade: rewrites `wp:block {"ref":N}` (mirror-post refs) to `wp:pattern {"slug":…}` in post content and theme files *while the mirror rows still exist as the ID→slug map*, then deletes the rows and the 1.x capabilities. |
+| `Pattern_Builder_Security` / `Pattern_Builder_Localization` | Path-validated filesystem helpers; pattern localization. |
+| Vendored runtime | `Pattern_Block`, `Pattern_Resolver`, `Block_Markup`, `Inner_HTML_Processor`, `Synced_Patterns`, `Editor_Support` — copied from Synced Patterns for Themes 2.0 and kept logic-identical. |
+
+### JavaScript (`src/`)
+
+Three webpack bundles:
+
+- **`PatternBuilder_EditorTools`** (every block-editor screen): the Pattern
+ Builder sidebar (browse / create / configure), the document panels for
+ `pb_pattern` and `wp_block` (Source with conversion, Synced Status,
+ Metadata, Associations, Bindings), and the save monitor that appends the
+ localize / import-images flags to `/pattern-builder/v1/` writes.
+- **`PatternBuilder_Runtime`** (only when this plugin owns the runtime): the
+ vendored editor modules — `content` attribute declaration, instance
+ editing (`SyncedPatternEdit`), client-side expansion, and the
+ `core/pattern-overrides` `setValues` amendment (marked with
+ `patternHostAmended` so two providers never double-wrap).
+- **`PatternBuilder_Admin`** (the plugin's own page): the browse grid
+ (`registerCoreBlocks()` runs at boot for its previews), and — when the URL
+ carries `&pattern={id}` — the edit-mode boot: `wp.editPost.initializeEditor`
+ pointed at the `pb_pattern` entity, plus a `history.replaceState` guard
+ (the editor believes it lives at post.php and rewrites the address bar; a
+ string id would 404 there) and a `MainDashboardButton` fill whose Back
+ button returns to the server-validated `back` URL.
+
+### Editing surfaces
+
+Always the WordPress editor — the plugin ships no editor UI of its own.
+
+- **Post editor:** in-context — the sidebar's Edit button resolves the entity
+ first, then swaps it into the canvas via `onNavigateToEntityRecord`
+ (`wp_block` and `pb_pattern` alike).
+- **User patterns everywhere else:** the Site Editor edits `wp_block`
+ natively (`/wp_block/:postId` route) — in place from within the Site
+ Editor, deep-linked from the browse grid and any other screen.
+- **Theme patterns everywhere else:** the Site Editor's canvas cannot bind a
+ `pb_pattern` — core hard-codes the entity types its canvas resolves
+ (template, template part, navigation, user pattern, attachment) and its
+ route registration is a private API. So the Site Editor and the browse
+ grid open Appearance → Pattern Builder's edit mode
+ (`themes.php?page=pattern-builder&pattern={id}&back={origin}`), which
+ boots core's own edit-post editor — the genuine post-editor chrome — and
+ its Back button returns to the originating screen. If core ever opens
+ site-editor routes to plugins, this URL is the only thing that changes.
+
+## Coexistence with Synced Patterns for Themes
+
+One check in one place: at `plugins_loaded` the companion looks for
+`PATTERN_BUILDER_VERSION` (2.0+) and, when Pattern Builder is active, stays
+entirely unloaded — not even its class files are required. Pattern Builder
+always registers the full stack (its vendored runtime plus the management
+layer) and never has to coordinate. The vendored runtime is kept
+logic-identical to the companion's at release time, which is what makes the
+hand-off invisible in both directions. Pattern Builder also clears the
+companion's week-long transient after file writes, so the companion never
+wakes to a stale cache after Pattern Builder is deactivated.
+
+The product story: build with Pattern Builder in development, ship the theme
+with Synced Patterns for Themes in production — swapping one for the other
+changes nothing about how the site renders.
+
+## What 1.x behaviors changed
+
+- Theme patterns now appear in the core inserter per their own `Inserter:`
+ header (1.x hid all of them and re-listed them as fake `wp_block`s).
+- `Viewport Width` round-trips (1.x read it and dropped it on save).
+- Parent-theme patterns are included (1.x scanned only the child theme).
+- The mirror CPT's silent revisions are gone — files are the history, and
+ version control is the developer workflow.
+- The unauthenticated delete and edit-context read paths that rode
+ `rest_pre_dispatch` no longer exist as a class of bug: there is no
+ pre-dispatch interception at all, and every route has a permission
+ callback (`edit_posts` to read, `edit_theme_options` to write).
diff --git a/docs/architecture.md b/docs/architecture.md
index d3551fb..dc37bd0 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,9 +1,15 @@
-# Pattern Builder — Architecture Deep Dive
+# Pattern Builder — Architecture Deep Dive (1.x — HISTORICAL)
> **Author:** Kedalion (architectural review)
> **Date:** 2026-03-25
> **Version reviewed:** 1.0.4
+> ⚠️ **This document describes Pattern Builder 1.x and is kept for historical
+> context only.** Version 2.0 removed the architecture analyzed here — the
+> `tbell_pattern_block` mirror CPT, the `/wp/v2/blocks` REST interception, and
+> the per-request registry rewriting no longer exist. The current architecture
+> is documented in [`architecture-2.0.md`](architecture-2.0.md).
+
---
## Table of Contents
diff --git a/includes/class-block-markup.php b/includes/class-block-markup.php
new file mode 100644
index 0000000..c6c9bdd
--- /dev/null
+++ b/includes/class-block-markup.php
@@ -0,0 +1,179 @@
+get_registered( $block_name );
+
+ if ( null === $block_type || ! is_array( $block_type->attributes ) ) {
+ return null;
+ }
+
+ return $block_type->attributes;
+ }
+
+ /**
+ * Returns the markup an attribute can be written into.
+ *
+ * Blocks with inner blocks are skipped: their saved markup is split across
+ * `innerContent`, and no block that supports pattern content has any.
+ *
+ * @param array $block A parsed block.
+ * @return string|null The block's markup, or null if it cannot be written to.
+ */
+ private static function get_markup( array $block ): ?string {
+ if ( ! empty( $block['innerBlocks'] ) ) {
+ return null;
+ }
+
+ return isset( $block['innerHTML'] ) && is_string( $block['innerHTML'] ) ? $block['innerHTML'] : null;
+ }
+
+ /**
+ * Splits an attribute schema's selector into usable tag names.
+ *
+ * Selectors in block schemas are a comma-separated list of tag names, apart
+ * from a handful that use CSS combinators. The HTML API cannot match those,
+ * so — as in core — they are dropped and the attribute is left alone.
+ *
+ * @param string $selector An attribute schema's selector.
+ * @return string[] Tag names.
+ */
+ private static function parse_selectors( string $selector ): array {
+ $tags = array();
+
+ foreach ( explode( ',', $selector ) as $candidate ) {
+ $candidate = trim( $candidate );
+
+ if ( 1 === preg_match( '/^[a-zA-Z][a-zA-Z0-9-]*$/', $candidate ) ) {
+ $tags[] = $candidate;
+ }
+ }
+
+ return $tags;
+ }
+
+ /**
+ * Sets an HTML attribute on the first tag matching one of the selectors.
+ *
+ * @param string $markup The block's markup.
+ * @param string[] $selectors Tag names to look for, in order.
+ * @param string $name HTML attribute name.
+ * @param string $value HTML attribute value.
+ * @return string|null The updated markup, or null if nothing matched.
+ */
+ private static function set_html_attribute( string $markup, array $selectors, string $name, string $value ): ?string {
+ foreach ( $selectors as $tag ) {
+ $processor = new WP_HTML_Tag_Processor( $markup );
+
+ if ( $processor->next_tag( array( 'tag_name' => $tag ) ) ) {
+ $processor->set_attribute( $name, $value );
+
+ return $processor->get_updated_html();
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/includes/class-editor-support.php b/includes/class-editor-support.php
new file mode 100644
index 0000000..10258a3
--- /dev/null
+++ b/includes/class-editor-support.php
@@ -0,0 +1,255 @@
+file = $file;
+ }
+
+ /**
+ * Registers the editor hooks.
+ *
+ * @return void
+ */
+ public function register(): void {
+ add_filter( 'rest_request_after_callbacks', array( $this, 'resolve_patterns_response' ), 10, 3 );
+ add_filter( 'get_block_templates', array( $this, 'resolve_templates' ) );
+ add_filter( 'get_block_template', array( $this, 'resolve_template' ) );
+ add_filter( 'get_block_file_template', array( $this, 'resolve_template' ) );
+ add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_editor_assets' ) );
+ }
+
+ /**
+ * Composes the patterns the editor lists, previews and inserts.
+ *
+ * Core has already flattened the response by the time this runs, so the
+ * content is recomposed from the pattern registry rather than patched.
+ *
+ * A synced pattern also gains a companion entry here. The inserter hands
+ * over a pattern's blocks, so a pattern cannot offer a reference to itself;
+ * the companion carries the same title and categories with a single
+ * reference block as its content, and the pattern itself steps out of the
+ * inserter in its place. The companion exists only in this response — it is
+ * never registered, because nothing but the inserter ever asks for it, and
+ * the block it inserts names the real pattern.
+ *
+ * @param WP_REST_Response|mixed $response Result to send to the client.
+ * @param array|mixed $handler Route handler used for the request.
+ * @param WP_REST_Request|mixed $request Request used to generate the response.
+ * @return WP_REST_Response|mixed The response.
+ */
+ public function resolve_patterns_response( $response, $handler, $request ) {
+ if ( ! $response instanceof WP_REST_Response
+ || ! $request instanceof WP_REST_Request
+ || self::PATTERNS_ROUTE !== $request->get_route()
+ ) {
+ return $response;
+ }
+
+ $patterns = $response->get_data();
+
+ if ( ! is_array( $patterns ) ) {
+ return $response;
+ }
+
+ $registry = WP_Block_Patterns_Registry::get_instance();
+ $companions = array();
+ $changed = false;
+
+ foreach ( $patterns as $index => $pattern ) {
+ if ( ! isset( $pattern['name'], $pattern['content'] ) || ! $registry->is_registered( $pattern['name'] ) ) {
+ continue;
+ }
+
+ $registered = $registry->get_registered( $pattern['name'] );
+ $markup = $registered['content'] ?? '';
+ $resolved = Pattern_Resolver::resolve( $markup );
+
+ if ( $resolved !== $markup ) {
+ // Let core finish the job for any plain pattern blocks left over.
+ $patterns[ $index ]['content'] = serialize_blocks( resolve_pattern_blocks( parse_blocks( $resolved ) ) );
+
+ $changed = true;
+ }
+
+ $companion = $this->build_companion_pattern( $patterns[ $index ] );
+
+ if ( null !== $companion ) {
+ $patterns[ $index ]['inserter'] = false;
+ $companions[] = $companion;
+ $changed = true;
+ }
+ }
+
+ if ( $changed ) {
+ $response->set_data( array_merge( $patterns, $companions ) );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Builds the entry that offers a synced pattern to the inserter.
+ *
+ * @param array $pattern A prepared pattern from the REST response.
+ * @return array|null The companion entry, or null if the pattern needs none.
+ */
+ private function build_companion_pattern( array $pattern ): ?array {
+ if ( ! Synced_Patterns::is_synced( $pattern['name'] ) ) {
+ return null;
+ }
+
+ // A pattern already kept out of the inserter is only used from markup.
+ if ( isset( $pattern['inserter'] ) && ! $pattern['inserter'] ) {
+ return null;
+ }
+
+ $companion = $pattern;
+ $companion['name'] = Synced_Patterns::get_inserter_slug( $pattern['name'] );
+ $companion['content'] = Synced_Patterns::get_reference_markup( $pattern['name'] );
+ $companion['inserter'] = true;
+
+ return $companion;
+ }
+
+ /**
+ * Composes the patterns used by a list of templates.
+ *
+ * @param WP_Block_Template[]|mixed $templates Templates being returned.
+ * @return WP_Block_Template[]|mixed The templates.
+ */
+ public function resolve_templates( $templates ) {
+ if ( ! is_array( $templates ) ) {
+ return $templates;
+ }
+
+ foreach ( $templates as $index => $template ) {
+ $templates[ $index ] = $this->resolve_template( $template );
+ }
+
+ return $templates;
+ }
+
+ /**
+ * Composes the patterns used by a single template or template part.
+ *
+ * Only for the editor: on the front end `Pattern_Block` renders the
+ * template's pattern blocks with their content already in context.
+ *
+ * @param WP_Block_Template|mixed $template Template being returned.
+ * @return WP_Block_Template|mixed The template.
+ */
+ public function resolve_template( $template ) {
+ if ( ! $template instanceof WP_Block_Template || ! self::is_editor_request() ) {
+ return $template;
+ }
+
+ $template->content = Pattern_Resolver::resolve( $template->content );
+
+ return $template;
+ }
+
+ /**
+ * Determines whether the current request is loading content for the editor.
+ *
+ * @return bool Whether template content should be composed for this request.
+ */
+ public static function is_editor_request(): bool {
+ /**
+ * Filters whether template content is composed for the current request.
+ *
+ * Templates only need composing for the block editor, which loads them
+ * over the REST API. The front end renders their pattern blocks
+ * directly, with the content already in block context.
+ *
+ * @since 2.0.0
+ *
+ * @param bool $is_editor_request Whether this request loads content for the editor.
+ */
+ return (bool) apply_filters(
+ 'pattern_builder_is_editor_request',
+ wp_is_serving_rest_request()
+ );
+ }
+
+ /**
+ * Enqueues the editor script.
+ *
+ * @return void
+ */
+ public function enqueue_editor_assets(): void {
+ $asset_file = plugin_dir_path( $this->file ) . 'build/PatternBuilder_Runtime.asset.php';
+
+ if ( ! file_exists( $asset_file ) ) {
+ return;
+ }
+
+ $asset = require $asset_file;
+
+ wp_enqueue_script(
+ 'pattern-builder-runtime',
+ plugins_url( 'build/PatternBuilder_Runtime.js', $this->file ),
+ $asset['dependencies'],
+ $asset['version'],
+ array( 'in_footer' => true )
+ );
+
+ /*
+ * The editor script has translatable strings of its own, and without
+ * this WordPress never hands it the translations it downloads.
+ */
+ wp_set_script_translations(
+ 'pattern-builder-runtime',
+ 'pattern-builder'
+ );
+
+ wp_add_inline_script(
+ 'pattern-builder-runtime',
+ sprintf(
+ 'window.patternBuilder = Object.assign( window.patternBuilder || {}, %s );',
+ wp_json_encode( array( 'syncedPatterns' => Synced_Patterns::get_slugs() ) )
+ ),
+ 'before'
+ );
+ }
+}
diff --git a/includes/class-inner-html-processor.php b/includes/class-inner-html-processor.php
new file mode 100644
index 0000000..d608c57
--- /dev/null
+++ b/includes/class-inner-html-processor.php
@@ -0,0 +1,112 @@
+next_tag( array( 'tag_name' => $tag ) ) && $processor->set_inner_html( $replacement ) ) {
+ return $processor->get_updated_html();
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Replaces the content between the current tag and its matching closer.
+ *
+ * @param string $html HTML to put inside the element.
+ * @return bool Whether the content was replaced.
+ */
+ private function set_inner_html( string $html ): bool {
+ if ( $this->is_tag_closer() || ! $this->expects_closer() ) {
+ return false;
+ }
+
+ $depth = $this->get_current_depth();
+ $tag_name = $this->get_tag();
+
+ $opener = $this->mark_current_token();
+
+ if ( null === $opener ) {
+ return false;
+ }
+
+ $start = $opener->start + $opener->length;
+
+ // Walk out of the element. The token left behind is its closer.
+ while ( $this->next_token() && $this->get_current_depth() >= $depth ) {
+ continue;
+ }
+
+ if ( ! $this->is_tag_closer() || $tag_name !== $this->get_tag() ) {
+ return false;
+ }
+
+ $closer = $this->mark_current_token();
+
+ if ( null === $closer ) {
+ return false;
+ }
+
+ $this->lexical_updates[] = new WP_HTML_Text_Replacement( $start, $closer->start - $start, $html );
+
+ return true;
+ }
+
+ /**
+ * Returns the span the current token occupies in the source HTML.
+ *
+ * @return WP_HTML_Span|null The span, or null for a token that isn't in the source.
+ */
+ private function mark_current_token(): ?WP_HTML_Span {
+ if ( ! $this->set_bookmark( self::BOOKMARK ) ) {
+ return null;
+ }
+
+ // `WP_HTML_Processor::set_bookmark()` prefixes the name it is given.
+ $span = $this->bookmarks[ '_' . self::BOOKMARK ] ?? null;
+
+ return $span instanceof WP_HTML_Span ? $span : null;
+ }
+}
diff --git a/includes/class-pattern-block.php b/includes/class-pattern-block.php
new file mode 100644
index 0000000..8173299
--- /dev/null
+++ b/includes/class-pattern-block.php
@@ -0,0 +1,152 @@
+ 'object' );
+
+ if ( ! isset( $args['provides_context'] ) || ! is_array( $args['provides_context'] ) ) {
+ $args['provides_context'] = array();
+ }
+
+ $args['provides_context'][ self::OVERRIDES_CONTEXT ] = self::CONTENT_ATTRIBUTE;
+
+ $args['render_callback'] = array( $this, 'render' );
+
+ return $args;
+ }
+
+ /**
+ * Renders a `core/pattern` block.
+ *
+ * Behaves like core's `render_block_core_pattern()` — same recursion guard,
+ * same auto-embedding — but attaches the pattern's blocks as inner blocks
+ * instead of calling `do_blocks()` on them. That is what core's
+ * `render_block_core_block()` does, and it is what makes the block's
+ * provided context reach the blocks inside the pattern.
+ *
+ * @param array $attributes Block attributes.
+ * @param string $content Block save content. Unused: `core/pattern` is a void block.
+ * @param WP_Block $block The block instance.
+ * @return string Rendered pattern.
+ */
+ public function render( $attributes, $content, $block ): string {
+ static $seen_slugs = array();
+
+ if ( ! $block instanceof WP_Block || empty( $attributes['slug'] ) || ! is_string( $attributes['slug'] ) ) {
+ return '';
+ }
+
+ $slug = $attributes['slug'];
+ $registry = WP_Block_Patterns_Registry::get_instance();
+
+ if ( ! $registry->is_registered( $slug ) ) {
+ return '';
+ }
+
+ if ( isset( $seen_slugs[ $slug ] ) ) {
+ /*
+ * WP_DEBUG_DISPLAY must only be honored when WP_DEBUG. This precedent
+ * is set in `wp_debug_mode()`.
+ */
+ if ( ! WP_DEBUG || ! WP_DEBUG_DISPLAY ) {
+ return '';
+ }
+
+ return sprintf(
+ /* translators: %s: A pattern's slug. */
+ __( '[block rendering halted for pattern "%s"]', 'pattern-builder' ),
+ $slug
+ );
+ }
+
+ $pattern = $registry->get_registered( $slug );
+ $inner_blocks = parse_blocks( $pattern['content'] );
+
+ if ( empty( $inner_blocks ) ) {
+ return '';
+ }
+
+ $seen_slugs[ $slug ] = true;
+
+ $block->parsed_block['innerBlocks'] = $inner_blocks;
+ $block->parsed_block['innerContent'] = array_fill( 0, count( $inner_blocks ), null );
+ $block->refresh_context_dependents();
+
+ // `dynamic => false` renders the inner blocks without calling this callback again.
+ $rendered = $block->render( array( 'dynamic' => false ) );
+
+ unset( $seen_slugs[ $slug ] );
+
+ global $wp_embed;
+ if ( $wp_embed instanceof WP_Embed ) {
+ $rendered = $wp_embed->autoembed( $rendered );
+ }
+
+ return $rendered;
+ }
+}
diff --git a/includes/class-pattern-builder-abstract-pattern.php b/includes/class-pattern-builder-abstract-pattern.php
index 99199fe..e4140fc 100644
--- a/includes/class-pattern-builder-abstract-pattern.php
+++ b/includes/class-pattern-builder-abstract-pattern.php
@@ -12,9 +12,12 @@
class Abstract_Pattern {
/**
- * Post ID (tbell_pattern_block or wp_block).
+ * Pattern identity.
*
- * @var int|null
+ * Theme patterns are identified by their namespaced name (e.g.
+ * "theme-slug/pattern-name"); user patterns by their wp_block post ID.
+ *
+ * @var string|int|null
*/
public $id;
@@ -102,6 +105,13 @@ class Abstract_Pattern {
*/
public $inserter;
+ /**
+ * Intended viewport width when previewing the pattern, in pixels.
+ *
+ * @var int|null
+ */
+ public $viewportWidth; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase
+
/**
* Absolute filesystem path to the pattern PHP file (theme patterns only).
*
@@ -115,8 +125,6 @@ class Abstract_Pattern {
* @param array $args Pattern arguments.
*/
public function __construct( $args = array() ) {
- $this->id = $args['id'] ?? null;
-
$this->title = $args['title'];
$this->name = $args['name'] ?? sanitize_title( $args['title'] );
@@ -135,7 +143,11 @@ public function __construct( $args = array() ) {
$this->templateTypes = $args['templateTypes'] ?? array(); // phpcs:ignore WordPress.NamingConventions.ValidVariableName
$this->postTypes = $args['postTypes'] ?? array(); // phpcs:ignore WordPress.NamingConventions.ValidVariableName
+ $this->viewportWidth = isset( $args['viewportWidth'] ) && '' !== $args['viewportWidth'] ? (int) $args['viewportWidth'] : null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName
+
$this->filePath = $args['filePath'] ?? null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName
+
+ $this->id = $args['id'] ?? ( 'theme' === $this->source ? $this->name : null );
}
/**
@@ -150,6 +162,20 @@ private static function render_pattern( $pattern_file ) {
return ob_get_clean();
}
+ /**
+ * Splits a comma-separated header value into a trimmed array.
+ *
+ * @param string $value Raw header value.
+ * @return array List of trimmed, non-empty values.
+ */
+ private static function split_header_list( $value ) {
+ if ( '' === trim( (string) $value ) ) {
+ return array();
+ }
+
+ return array_values( array_filter( array_map( 'trim', explode( ',', $value ) ), 'strlen' ) );
+ }
+
/**
* Creates an Abstract_Pattern from a theme pattern PHP file.
*
@@ -174,55 +200,28 @@ public static function from_file( $pattern_file ) {
)
);
- $new = new self(
+ return new self(
array(
'name' => $pattern_data['slug'],
'title' => $pattern_data['title'],
'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' => self::split_header_list( $pattern_data['categories'] ),
+ 'keywords' => self::split_header_list( $pattern_data['keywords'] ),
+ 'blockTypes' => self::split_header_list( $pattern_data['blockTypes'] ),
+ 'postTypes' => self::split_header_list( $pattern_data['postTypes'] ),
+ 'templateTypes' => self::split_header_list( $pattern_data['templateTypes'] ),
+ 'viewportWidth' => $pattern_data['viewportWidth'],
'source' => 'theme',
- 'synced' => 'yes' === $pattern_data['synced'],
- 'inserter' => 'no' !== $pattern_data['inserter'],
+ 'synced' => in_array( strtolower( trim( $pattern_data['synced'] ) ), array( 'yes', 'true', '1', 'on' ), true ),
+ 'inserter' => 'no' !== strtolower( trim( $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(
- 'name' => $pattern['name'],
- 'title' => $pattern['title'],
- 'description' => $pattern['description'],
- 'content' => $pattern['content'],
- 'categories' => $pattern['categories'],
- 'keywords' => $pattern['keywords'],
- 'source' => 'theme',
- 'synced' => false,
- 'blockTypes' => $pattern['blockTypes'],
- 'templateTypes' => $pattern['templateTypes'],
- 'postTypes' => $pattern['postTypes'],
- 'inserter' => $pattern['inserter'],
- 'filePath' => $pattern['filePath'],
- )
- );
- }
-
- /**
- * Creates an Abstract_Pattern from a WP_Post object (wp_block or tbell_pattern_block).
+ * Creates an Abstract_Pattern from a wp_block post.
*
* @param \WP_Post $post The post object.
* @return self
@@ -234,28 +233,21 @@ public static function from_post( $post ) {
function ( $category ) {
return $category->slug;
},
- $categories
+ is_array( $categories ) ? $categories : array()
);
- $slug = Pattern_Builder_Controller::format_pattern_slug_from_post( $post->post_name );
-
return new self(
array(
- 'id' => $post->ID,
- 'name' => $slug,
- 'title' => $post->post_title,
- 'description' => $post->post_excerpt,
- 'content' => $post->post_content,
- '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(),
- 'templateTypes' => isset( $metadata['wp_pattern_template_types'][0] ) ? explode( ',', $metadata['wp_pattern_template_types'][0] ) : array(),
- 'postTypes' => isset( $metadata['wp_pattern_post_types'][0] ) ? explode( ',', $metadata['wp_pattern_post_types'][0] ) : array(),
-
- 'keywords' => isset( $metadata['wp_pattern_keywords'][0] ) ? explode( ',', $metadata['wp_pattern_keywords'][0] ) : array(),
- 'categories' => $categories,
- 'inserter' => isset( $metadata['wp_pattern_inserter'][0] ) ? ( 'no' !== $metadata['wp_pattern_inserter'][0] ) : true,
+ 'id' => $post->ID,
+ 'name' => $post->post_name,
+ 'title' => $post->post_title,
+ 'description' => $post->post_excerpt,
+ 'content' => $post->post_content,
+ 'source' => 'user',
+ 'synced' => ( $metadata['wp_pattern_sync_status'][0] ?? 'synced' ) !== 'unsynced',
+ 'keywords' => isset( $metadata['wp_pattern_keywords'][0] ) ? array_map( 'trim', explode( ',', $metadata['wp_pattern_keywords'][0] ) ) : array(),
+ 'categories' => $categories,
+ 'inserter' => true,
)
);
}
diff --git a/includes/class-pattern-builder-admin.php b/includes/class-pattern-builder-admin.php
index 27bb7f1..6f35ec1 100644
--- a/includes/class-pattern-builder-admin.php
+++ b/includes/class-pattern-builder-admin.php
@@ -2,52 +2,193 @@
namespace TwentyBellows\PatternBuilder;
+use WP_Block_Editor_Context;
+
+/**
+ * The Appearance → Pattern Builder screen.
+ *
+ * Two modes, decided by the URL's `pattern` parameter:
+ *
+ * - Browse (no parameter): the pattern grid — search, filter, create.
+ * - Edit (`&pattern={id}`): the WordPress editor itself. The page boots
+ * core's `@wordpress/edit-post` editor (the one that powers post.php)
+ * bound to the `pb_pattern` entity, so theme pattern edits save straight
+ * to the pattern files with the full core editing experience.
+ */
class Pattern_Builder_Admin {
- private const PAGE_SLUG = 'pattern-builder';
- private const PAGE_TITLE = 'Pattern Builder';
+ private const PAGE_SLUG = 'pattern-builder';
+
+ /**
+ * The admin page's hook suffix, once registered.
+ *
+ * @var string|false
+ */
+ private $page_hook = false;
/**
* Constructor to initialize admin hooks.
*/
public function __construct() {
add_action( 'admin_menu', array( $this, 'create_admin_menu' ) );
+ add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
}
/**
* Creates the admin menu for the Pattern Builder.
*/
public function create_admin_menu(): void {
- add_theme_page(
+ $this->page_hook = add_theme_page(
_x( 'Pattern Builder', 'UI String', 'pattern-builder' ),
_x( 'Pattern Builder', 'UI String', 'pattern-builder' ),
'edit_theme_options',
self::PAGE_SLUG,
array( $this, 'render_admin_menu_page' )
);
+
+ if ( $this->page_hook ) {
+ add_action( 'load-' . $this->page_hook, array( $this, 'setup_screen' ) );
+ }
+ }
+
+ /**
+ * Marks the edit-mode screen as a block editor screen, as core's own
+ * editor pages do — admin body classes and asset behavior key off it.
+ */
+ public function setup_screen(): void {
+ if ( $this->get_requested_pattern() ) {
+ get_current_screen()->is_block_editor( true );
+ }
+ }
+
+ /**
+ * The pattern id the page was asked to edit, if any.
+ *
+ * @return string The pattern id, or an empty string on the browse screen.
+ */
+ private function get_requested_pattern(): string {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ return isset( $_GET['pattern'] ) ? sanitize_text_field( wp_unslash( $_GET['pattern'] ) ) : '';
}
/**
- * Renders the admin menu page as a plain PHP page (no React build required).
+ * Enqueues the pattern browser / editor boot on the plugin's own page.
+ *
+ * @param string $hook_suffix The current admin page.
+ */
+ public function enqueue_assets( $hook_suffix ): void {
+ if ( ! $this->page_hook || $hook_suffix !== $this->page_hook ) {
+ return;
+ }
+
+ $asset_path = plugin_dir_path( __FILE__ ) . '../build/PatternBuilder_Admin.asset.php';
+
+ if ( ! file_exists( $asset_path ) ) {
+ return;
+ }
+
+ $asset = include $asset_path;
+ $pattern = $this->get_requested_pattern();
+
+ // The block editor's client-side registry needs the server's block
+ // definitions and categories, exactly as core's editor screens set up.
+ wp_add_inline_script(
+ 'wp-blocks',
+ 'wp.blocks.unstable__bootstrapServerSideBlockDefinitions(' . wp_json_encode( get_block_editor_server_block_settings() ) . ');',
+ 'after'
+ );
+
+ $editor_context = new WP_Block_Editor_Context( array( 'name' => 'pattern-builder/editor' ) );
+
+ wp_add_inline_script(
+ 'wp-blocks',
+ sprintf( 'wp.blocks.setCategories( %s );', wp_json_encode( get_block_categories( $editor_context ) ) ),
+ 'after'
+ );
+
+ wp_enqueue_script(
+ 'pattern-builder-admin',
+ plugins_url( '../build/PatternBuilder_Admin.js', __FILE__ ),
+ $asset['dependencies'],
+ $asset['version'],
+ true
+ );
+
+ wp_set_script_translations( 'pattern-builder-admin', 'pattern-builder' );
+
+ if ( $pattern ) {
+ // The full editor skin — the same stylesheet stack post.php loads.
+ wp_enqueue_style( 'wp-edit-post' );
+ }
+
+ $css_path = plugin_dir_path( __FILE__ ) . '../build/PatternBuilder_Admin.css';
+ if ( file_exists( $css_path ) ) {
+ wp_enqueue_style(
+ 'pattern-builder-admin',
+ plugins_url( '../build/PatternBuilder_Admin.css', __FILE__ ),
+ array( 'wp-components', 'wp-block-editor', 'wp-edit-blocks' ),
+ $asset['version']
+ );
+ } else {
+ wp_enqueue_style( 'wp-edit-blocks' );
+ }
+
+ wp_enqueue_style( 'wp-format-library' );
+ wp_enqueue_media();
+
+ $settings = get_block_editor_settings(
+ array_merge(
+ get_default_block_editor_settings(),
+ array( 'styles' => get_block_editor_theme_styles() )
+ ),
+ $editor_context
+ );
+
+ $browse_url = admin_url( 'themes.php?page=' . self::PAGE_SLUG );
+
+ /*
+ * Where the editor's back button returns to: the screen the user
+ * came from (the Site Editor, the browse screen, …), validated the
+ * way core validates redirect targets, falling back to browse.
+ */
+ $back_url = isset( $_GET['back'] ) ? wp_validate_redirect( sanitize_url( wp_unslash( $_GET['back'] ) ), '' ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+
+ wp_add_inline_script(
+ 'pattern-builder-admin',
+ sprintf(
+ 'window.patternBuilderAdmin = %s;',
+ wp_json_encode(
+ array(
+ 'editorSettings' => $settings,
+ 'pattern' => $pattern ? $pattern : null,
+ 'adminUrl' => $browse_url,
+ 'backUrl' => $back_url ? $back_url : $browse_url,
+ )
+ )
+ ),
+ 'before'
+ );
+
+ /*
+ * Let every block-editor integration load — this plugin's own editor
+ * tools and pattern runtime included, along with any third-party
+ * blocks' editor assets.
+ */
+ do_action( 'enqueue_block_editor_assets' );
+ }
+
+ /**
+ * Renders the mount point for the pattern browser or editor.
*/
public function render_admin_menu_page(): void {
- ?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- get_requested_pattern() ) {
+ // The div core's editor takes over — mirrors post.php's markup.
+ echo '
';
+ echo '';
+ echo '
';
+ return;
+ }
+
+ echo '';
}
}
diff --git a/includes/class-pattern-builder-api.php b/includes/class-pattern-builder-api.php
index 44b8e39..deff605 100644
--- a/includes/class-pattern-builder-api.php
+++ b/includes/class-pattern-builder-api.php
@@ -1,78 +1,45 @@
controller = new Pattern_Builder_Controller();
+ $this->store = new Pattern_File_Store();
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
- add_action( 'init', array( $this, 'register_patterns' ), 9 );
-
- add_filter( 'rest_request_after_callbacks', array( $this, 'inject_theme_patterns' ), 10, 3 );
-
- add_filter( 'rest_pre_dispatch', array( $this, 'handle_hijack_block_update' ), 10, 3 );
- add_filter( 'rest_pre_dispatch', array( $this, 'handle_hijack_block_delete' ), 10, 3 );
-
- add_filter( 'rest_request_before_callbacks', array( $this, 'handle_block_to_pattern_conversion' ), 10, 3 );
-
- add_filter( 'pre_render_block', array( $this, 'filter_pattern_block_attributes' ), 10, 2 );
}
-
/**
- * Registers REST API routes for the Pattern Builder.
+ * Registers the plugin's non-entity REST routes.
+ *
+ * @return void
*/
- public function register_routes(): void {
-
+ public function register_routes() {
register_rest_route(
- self::$base_route,
- '/patterns',
- array(
- 'methods' => 'GET',
- 'callback' => array( $this, 'get_patterns' ),
- 'permission_callback' => array( $this, 'read_permission_callback' ),
- )
- );
-
- register_rest_route(
- self::$base_route,
+ 'pattern-builder/v1',
'/process-theme',
array(
'methods' => 'POST',
@@ -83,38 +50,26 @@ public function register_routes(): void {
}
/**
- * Permission callback for read operations.
- * Allows access to users who can read pattern blocks.
+ * Permission callback for state-changing endpoints.
*
- * @return bool True if the user can read patterns, false otherwise.
- */
- public function read_permission_callback() {
- return current_user_can( 'edit_posts' );
- }
-
- /**
- * Permission callback for write operations (PUT, POST, DELETE).
- * Restricts access to users with pattern editing capabilities.
- *
- * @param WP_REST_Request $request The REST request object.
- * @return bool|WP_Error True if the user can modify patterns, WP_Error otherwise.
+ * @param \WP_REST_Request $request The request.
+ * @return true|WP_Error
*/
public function write_permission_callback( $request ) {
- // Check if user has the required capability.
- if ( ! current_user_can( 'edit_tbell_pattern_blocks' ) ) {
+ if ( ! current_user_can( 'edit_theme_options' ) ) {
return new WP_Error(
'rest_forbidden',
- __( 'You do not have permission to modify patterns.', 'pattern-builder' ),
- array( 'status' => 403 )
+ __( 'Sorry, you are not allowed to manage theme patterns.', 'pattern-builder' ),
+ array( 'status' => rest_authorization_required_code() )
);
}
- // 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' ),
+ 'rest_invalid_nonce',
+ __( 'Invalid or missing nonce.', 'pattern-builder' ),
array( 'status' => 403 )
);
}
@@ -123,507 +78,68 @@ public function write_permission_callback( $request ) {
}
/**
- * Processes all theme patterns with current configuration settings.
+ * Re-writes every theme pattern file, applying localization and image
+ * import options across the whole theme at once.
*
- * @param WP_REST_Request $request The REST request object.
+ * @param \WP_REST_Request $request The request.
* @return WP_REST_Response
*/
- public function process_theme_patterns( WP_REST_Request $request ): WP_REST_Response {
-
- $localize = 'true' === sanitize_text_field( $request->get_param( 'localize' ) );
- $import_images = 'false' !== sanitize_text_field( $request->get_param( 'importImages' ) );
+ public function process_theme_patterns( $request ) {
+ $options = array();
- $options = array(
- 'localize' => $localize,
- 'import_images' => $import_images,
- );
+ if ( 'true' === $request->get_param( 'localize' ) ) {
+ $options['localize'] = true;
+ }
- $theme_patterns = $this->controller->get_block_patterns_from_theme_files();
+ if ( 'false' === $request->get_param( 'importImages' ) ) {
+ $options['import_images'] = false;
+ }
- $processed_count = 0;
- $error_count = 0;
- $errors = array();
+ $patterns = $this->store->get_theme_patterns();
+ $processed = 0;
+ $errors = array();
- foreach ( $theme_patterns as $pattern ) {
+ foreach ( $patterns as $pattern ) {
try {
- $this->controller->update_theme_pattern( $pattern, $options );
- ++$processed_count;
- } catch ( \Exception $e ) {
- ++$error_count;
+ $result = $this->store->update_theme_pattern( $pattern, $options );
+
+ if ( is_wp_error( $result ) ) {
+ $errors[] = array(
+ 'pattern' => $pattern->name,
+ 'error' => $result->get_error_message(),
+ );
+ continue;
+ }
+
+ ++$processed;
+ } catch ( \Throwable $error ) {
$errors[] = array(
'pattern' => $pattern->name,
- 'error' => $e->getMessage(),
+ 'error' => $error->getMessage(),
);
}
}
- $total_patterns = count( $theme_patterns );
- $success = 0 === $error_count;
-
- $response_data = array(
- 'success' => $success,
+ $response = array(
+ 'success' => empty( $errors ),
'message' => sprintf(
- /* translators: 1: Number of patterns processed, 2: Total number of patterns */
- __( 'Processed %1$d of %2$d theme patterns successfully.', 'pattern-builder' ),
- $processed_count,
- $total_patterns
+ /* translators: 1: number of processed patterns, 2: total patterns. */
+ __( 'Processed %1$d of %2$d theme patterns.', 'pattern-builder' ),
+ $processed,
+ count( $patterns )
),
'stats' => array(
- 'total' => $total_patterns,
- 'processed' => $processed_count,
- 'errors' => $error_count,
+ 'total' => count( $patterns ),
+ 'processed' => $processed,
+ 'errors' => count( $errors ),
),
'settings' => $options,
);
- if ( ! empty( $errors ) ) {
- $response_data['errors'] = $errors;
- }
-
- return rest_ensure_response( $response_data );
- }
-
- /**
- * Retrieves all block patterns.
- *
- * @param WP_REST_Request $request The REST request object.
- * @return 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.
- // For now we pull it from the original pattern and reset it here.
- $pattern_from_post->name = $pattern->name;
- return $pattern_from_post;
- },
- $theme_patterns
- );
-
- $user_patterns = $this->controller->get_block_patterns_from_database();
-
- // TODO: We also need to get patterns from other potential sources such as plugins and core.
- // However, these are not editable.
-
- $all_patterns = array_merge( $theme_patterns, $user_patterns );
-
- 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.
- if ( preg_match( '#/wp/v2/blocks/(?P\d+)#', $request->get_route(), $matches ) ) {
- $block_id = intval( $matches['id'] );
- $tbell_pattern_block = get_post( $block_id );
- if ( $tbell_pattern_block && 'tbell_pattern_block' === $tbell_pattern_block->post_type ) {
- // Make sure the pattern has a pattern file.
- $pattern_file_path = $this->controller->get_pattern_filepath( Abstract_Pattern::from_post( $tbell_pattern_block ) );
- if ( is_wp_error( $pattern_file_path ) || ! $pattern_file_path ) {
- return $response;
- }
- $tbell_pattern_block->post_name = $this->controller->format_pattern_slug_from_post( $tbell_pattern_block->post_name );
- $data = $this->format_tbell_pattern_block_response( $tbell_pattern_block, $request );
- $response = new WP_REST_Response( $data );
- }
- } 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 excluded from the inserter.
- $patterns = array_filter(
- $patterns,
- function ( $pattern ) {
- return $pattern->inserter;
- }
- );
-
- foreach ( $patterns as $pattern ) {
- $post = $this->controller->get_tbell_pattern_block_post_for_pattern( $pattern );
- $data[] = $this->format_tbell_pattern_block_response( $post, $request );
- }
-
- $response->set_data( $data );
- }
-
- return $response;
- }
-
- /**
- * 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.
- $mock_request = new WP_REST_Request( 'GET', '/wp/v2/blocks/' . $post->ID );
- $mock_request->set_param( 'context', 'edit' );
-
- $controller = new WP_REST_Blocks_Controller( 'wp_block' );
- $response = $controller->prepare_item_for_response( $post, $mock_request );
-
- $data = $controller->prepare_response_for_collection( $response );
-
- $meta = get_post_meta( $post->ID );
-
- if ( isset( $meta ) ) {
- if ( isset( $meta['wp_pattern_block_types'] ) ) {
- $data['wp_pattern_block_types'] = array_map( 'trim', explode( ',', $meta['wp_pattern_block_types'][0] ) );
- }
- if ( isset( $meta['wp_pattern_post_types'] ) ) {
- $data['wp_pattern_post_types'] = array_map( 'trim', explode( ',', $meta['wp_pattern_post_types'][0] ) );
- }
- if ( isset( $meta['wp_pattern_template_types'] ) ) {
- $data['wp_pattern_template_types'] = array_map( 'trim', explode( ',', $meta['wp_pattern_template_types'][0] ) );
- }
- if ( isset( $meta['wp_pattern_inserter'] ) ) {
- $data['wp_pattern_inserter'] = $meta['wp_pattern_inserter'][0];
- }
- }
-
- $data['source'] = 'theme';
-
- return $data;
- }
-
- /**
- * Registers block patterns for the theme.
- *
- * 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(): void {
-
- $pattern_registry = WP_Block_Patterns_Registry::get_instance();
-
- $patterns = $this->controller->get_block_patterns_from_theme_files();
-
- foreach ( $patterns as $pattern ) {
-
- $post = $this->controller->create_tbell_pattern_block_post_for_pattern( $pattern );
-
- if ( $pattern_registry->is_registered( $pattern->name ) ) {
- $pattern_registry->unregister( $pattern->name );
- }
-
- $pattern_content = $pattern->content;
- if ( $pattern->synced ) {
- self::$synced_theme_patterns[ $pattern->name ] = $post->ID;
- $pattern_content = '';
- }
-
- $pattern_data = array(
- 'title' => $pattern->title,
- 'inserter' => false,
- 'content' => $pattern_content,
- 'source' => 'theme',
- 'blockTypes' => $pattern->blockTypes,
- 'templateTypes' => $pattern->templateTypes,
- );
-
- // Setting postTypes to an empty array causes registration errors; only set it when non-empty.
- if ( $pattern->postTypes ) {
- $pattern_data['postTypes'] = $pattern->postTypes;
- }
-
- $pattern_registry->register(
- $pattern->name,
- $pattern_data
- );
- }
- }
-
-
- /**
- * 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 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.
- */
- public function handle_hijack_block_delete( $response, $server, $request ) {
-
- $route = $request->get_route();
-
- if ( preg_match( '#^/wp/v2/blocks/(\d+)$#', $route, $matches ) ) {
-
- $id = intval( $matches[1] );
- $post = get_post( $id );
-
- 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 ) );
- }
-
- $abstract_pattern = Abstract_Pattern::from_post( $post );
-
- $path = $this->controller->get_pattern_filepath( $abstract_pattern );
-
- if ( is_wp_error( $path ) ) {
- return $path;
- }
-
- if ( ! $path ) {
- return new WP_Error( 'pattern_not_found', 'Pattern not found.', array( 'status' => 404 ) );
- }
-
- // 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;
- }
-
- return new WP_REST_Response( array( 'message' => 'Pattern deleted successfully.' ), 200 );
-
- }
- }
-
- return $response;
- }
-
- /**
- * Handles additional logic when a tbell_pattern_block (theme pattern) is updated via the REST API.
- *
- * 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.
- */
- public function handle_hijack_block_update( $response, $handler, $request ) {
- $route = $request->get_route();
-
- if ( preg_match( '#^/wp/v2/blocks/(\d+)$#', $route, $matches ) ) {
-
- $id = intval( $matches[1] );
- $post = get_post( $id );
-
- if ( $post && 'PUT' === $request->get_method() ) {
-
- $updated_pattern = json_decode( $request->get_body(), true );
-
- // 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' ),
- array( 'status' => 400 )
- );
- }
-
- $convert_user_pattern_to_theme_pattern = false;
-
- 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 ( 'tbell_pattern_block' === $post->post_type || $convert_user_pattern_to_theme_pattern ) {
-
- // Check write permissions before allowing update.
- if ( ! current_user_can( 'edit_tbell_pattern_blocks' ) ) {
- return new WP_Error(
- 'rest_forbidden',
- __( 'You do not have permission to edit patterns.', 'pattern-builder' ),
- array( 'status' => 403 )
- );
- }
-
- $pattern = Abstract_Pattern::from_post( $post );
-
- if ( isset( $updated_pattern['content'] ) ) {
- // Remap tbell_pattern_blocks to patterns.
- $blocks = parse_blocks( $updated_pattern['content'] );
- $blocks = $this->convert_blocks_to_patterns( $blocks );
- $pattern->content = serialize_blocks( $blocks );
- }
-
- if ( isset( $updated_pattern['title'] ) ) {
- $pattern->title = $updated_pattern['title'];
- }
-
- if ( isset( $updated_pattern['excerpt'] ) ) {
- $pattern->description = $updated_pattern['excerpt'];
- }
-
- if ( isset( $updated_pattern['wp_pattern_sync_status'] ) ) {
- $pattern->synced = 'unsynced' !== $updated_pattern['wp_pattern_sync_status'];
- }
-
- if ( isset( $updated_pattern['wp_pattern_block_types'] ) ) {
- $pattern->blockTypes = $updated_pattern['wp_pattern_block_types'];
- }
-
- if ( isset( $updated_pattern['wp_pattern_post_types'] ) ) {
- $pattern->postTypes = $updated_pattern['wp_pattern_post_types'];
- }
-
- if ( isset( $updated_pattern['wp_pattern_template_types'] ) ) {
- $pattern->templateTypes = $updated_pattern['wp_pattern_template_types'];
- }
-
- if ( isset( $updated_pattern['wp_pattern_inserter'] ) ) {
- $pattern->inserter = 'no' !== $updated_pattern['wp_pattern_inserter'];
- }
-
- 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.
- $options = array();
-
- $localize_param = sanitize_text_field( $request->get_param( 'patternBuilderLocalize' ) );
- if ( 'true' === $localize_param ) {
- $options['localize'] = true;
- }
-
- $import_images_param = sanitize_text_field( $request->get_param( 'patternBuilderImportImages' ) );
- if ( 'false' === $import_images_param ) {
- $options['import_images'] = false;
- } else {
- // Default to true if not explicitly disabled.
- $options['import_images'] = true;
- }
-
- $this->controller->update_theme_pattern( $pattern, $options );
- }
-
- $post = get_post( $pattern->id );
- $formatted_response = $this->format_tbell_pattern_block_response( $post, $request );
- $response = new WP_REST_Response( $formatted_response, 200 );
- }
- }
- }
- return $response;
- }
-
- /**
- * 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.
- * @param WP_REST_Request $request The REST request object.
- * @return mixed The response, potentially modified.
- */
- public function handle_block_to_pattern_conversion( $response, $handler, $request ) {
- if ( 'PUT' === $request->get_method() || 'POST' === $request->get_method() ) {
-
- $body = json_decode( $request->get_body(), true );
-
- // Return original response if JSON is invalid.
- if ( JSON_ERROR_NONE !== json_last_error() ) {
- return $response;
- }
-
- if ( isset( $body['content'] ) ) {
- $blocks = parse_blocks( $body['content'] );
- $blocks = $this->convert_blocks_to_patterns( $blocks );
- $body['content'] = serialize_blocks( $blocks );
- $request->set_body( wp_json_encode( $body ) );
- }
- }
- 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'] ) && 'core/block' === $block['blockName'] ) {
- $post = get_post( $block['attrs']['ref'] );
- 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();
- $block['attrs']['slug'] = $slug;
- if ( ! empty( $post->post_title ) ) {
- $block['attrs']['title'] = $post->post_title;
- }
- unset( $block['attrs']['ref'] );
- }
- } elseif ( isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
- $block['innerBlocks'] = $this->convert_blocks_to_patterns( $block['innerBlocks'] );
- }
- }
- return $blocks;
- }
-
- /**
- * 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.
- * @return mixed Modified pre-render value or null.
- */
- public function filter_pattern_block_attributes( $pre_render, $parsed_block ) {
- // Only process wp:pattern blocks.
- if ( 'core/pattern' !== $parsed_block['blockName'] ) {
- return $pre_render;
- }
-
- $pattern_attrs = isset( $parsed_block['attrs'] ) ? $parsed_block['attrs'] : array();
- $slug = $pattern_attrs['slug'] ?? '';
-
- // Remove attributes we don't want to pass down.
- unset( $pattern_attrs['slug'] );
-
- // 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, 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 ),
- $pattern_attrs
- );
- $block_attributes = wp_json_encode( $block_attributes );
- $block_string = "";
- return do_blocks( $block_string );
+ if ( $errors ) {
+ $response['errors'] = $errors;
}
- return $pre_render;
+ return new WP_REST_Response( $response, 200 );
}
}
diff --git a/includes/class-pattern-builder-editor.php b/includes/class-pattern-builder-editor.php
index f1aa4d2..3810e2a 100644
--- a/includes/class-pattern-builder-editor.php
+++ b/includes/class-pattern-builder-editor.php
@@ -15,7 +15,13 @@ public function __construct() {
* Enqueues assets for the block editor.
*/
public function enqueue_block_editor_assets(): void {
- $asset_file = include plugin_dir_path( __FILE__ ) . '../build/PatternBuilder_EditorTools.asset.php';
+ $asset_path = plugin_dir_path( __FILE__ ) . '../build/PatternBuilder_EditorTools.asset.php';
+
+ if ( ! file_exists( $asset_path ) ) {
+ return;
+ }
+
+ $asset_file = include $asset_path;
wp_enqueue_script(
'pattern-builder',
@@ -25,11 +31,30 @@ public function enqueue_block_editor_assets(): void {
true
);
- wp_enqueue_style(
- 'pattern-builder-editor-style',
- plugins_url( '../build/PatternBuilder_EditorTools.css', __FILE__ ),
- array(),
- $asset_file['version']
+ wp_set_script_translations( 'pattern-builder', 'pattern-builder' );
+
+ wp_add_inline_script(
+ 'pattern-builder',
+ sprintf(
+ 'window.patternBuilderSettings = %s;',
+ wp_json_encode(
+ array(
+ 'adminEditorUrl' => admin_url( 'themes.php?page=pattern-builder' ),
+ )
+ )
+ ),
+ 'before'
);
+
+ $css_path = plugin_dir_path( __FILE__ ) . '../build/PatternBuilder_EditorTools.css';
+
+ if ( file_exists( $css_path ) ) {
+ wp_enqueue_style(
+ 'pattern-builder-editor-style',
+ plugins_url( '../build/PatternBuilder_EditorTools.css', __FILE__ ),
+ array(),
+ $asset_file['version']
+ );
+ }
}
}
diff --git a/includes/class-pattern-builder-entity.php b/includes/class-pattern-builder-entity.php
new file mode 100644
index 0000000..6469a95
--- /dev/null
+++ b/includes/class-pattern-builder-entity.php
@@ -0,0 +1,74 @@
+ array(
+ 'name' => __( 'Theme Patterns', 'pattern-builder' ),
+ 'singular_name' => __( 'Theme Pattern', 'pattern-builder' ),
+ ),
+ 'description' => __( 'File-based theme patterns managed by Pattern Builder.', 'pattern-builder' ),
+ 'public' => false,
+ 'show_ui' => false,
+ 'show_in_menu' => false,
+ 'show_in_rest' => true,
+ 'rest_namespace' => 'pattern-builder/v1',
+ 'rest_base' => 'patterns',
+ 'rest_controller_class' => Pattern_Builder_REST_Patterns_Controller::class,
+ // Registers the REST routes after the built-in post type routes, like wp_template.
+ 'late_route_registration' => true,
+ 'capability_type' => array( 'pb_pattern', 'pb_patterns' ),
+ 'capabilities' => array(
+ 'create_posts' => 'edit_theme_options',
+ 'delete_posts' => 'edit_theme_options',
+ 'delete_others_posts' => 'edit_theme_options',
+ 'delete_private_posts' => 'edit_theme_options',
+ 'delete_published_posts' => 'edit_theme_options',
+ 'edit_posts' => 'edit_theme_options',
+ 'edit_others_posts' => 'edit_theme_options',
+ 'edit_private_posts' => 'edit_theme_options',
+ 'edit_published_posts' => 'edit_theme_options',
+ 'publish_posts' => 'edit_theme_options',
+ 'read' => 'edit_theme_options',
+ 'read_private_posts' => 'edit_theme_options',
+ ),
+ 'map_meta_cap' => true,
+ 'supports' => array( 'title', 'editor' ),
+ )
+ );
+ }
+}
diff --git a/includes/class-pattern-builder-migration.php b/includes/class-pattern-builder-migration.php
new file mode 100644
index 0000000..a897984
--- /dev/null
+++ b/includes/class-pattern-builder-migration.php
@@ -0,0 +1,339 @@
+` references pointing at those posts.
+ * Version 2 has no mirror posts, so those references would render nothing.
+ *
+ * The migration runs in strict order — the mirror rows are the only map from
+ * ref ID back to pattern slug, so they must still exist while references are
+ * rewritten:
+ *
+ * 1. Rewrite `wp:block` refs that point at mirror posts to
+ * `` — in theme pattern files and in post
+ * content.
+ * 2. Delete the mirror posts (pure derived cache; the pattern files hold the
+ * content).
+ * 3. Remove the custom capabilities v1 granted on activation.
+ *
+ * The routine is idempotent: with no mirror rows left it does nothing.
+ */
+class Pattern_Builder_Migration {
+
+ /**
+ * Option storing the plugin version the database was last migrated to.
+ */
+ const VERSION_OPTION = 'pattern_builder_version';
+
+ /**
+ * Option storing the last migration's report.
+ */
+ const REPORT_OPTION = 'pattern_builder_migration_report';
+
+ /**
+ * Capabilities v1 granted (including the misspelled grants it checked).
+ */
+ const V1_CAPABILITIES = array(
+ 'read_tbell_pattern_block',
+ 'edit_tbell_pattern_blocks',
+ 'delete_tbell_pattern_block',
+ 'delete_tbell_pattern_blocks',
+ );
+
+ /**
+ * Constructor: hooks the upgrade check.
+ */
+ public function __construct() {
+ add_action( 'admin_init', array( $this, 'maybe_migrate' ) );
+ add_action( 'admin_notices', array( $this, 'render_report_notice' ) );
+ }
+
+ /**
+ * Runs the migration once per version.
+ *
+ * @return void
+ */
+ public function maybe_migrate() {
+ $stored = get_option( self::VERSION_OPTION, '0' );
+
+ if ( version_compare( $stored, '2.0.0', '>=' ) ) {
+ return;
+ }
+
+ if ( ! current_user_can( 'edit_theme_options' ) ) {
+ // Wait for a user who could have used v1's editing tools.
+ return;
+ }
+
+ $report = $this->migrate();
+
+ update_option( self::REPORT_OPTION, $report, false );
+ update_option( self::VERSION_OPTION, PATTERN_BUILDER_VERSION );
+ }
+
+ /**
+ * Performs the v1 → v2 migration.
+ *
+ * @return array Report of what was rewritten and removed.
+ */
+ public function migrate() {
+ global $wpdb;
+
+ $report = array(
+ 'rewritten_files' => array(),
+ 'rewritten_posts' => array(),
+ 'deleted_mirrors' => 0,
+ 'time' => time(),
+ );
+
+ // The mirror rows are the ID → slug map; read them before anything else.
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $mirrors = $wpdb->get_results(
+ "SELECT ID, post_name FROM {$wpdb->posts} WHERE post_type = 'tbell_pattern_block'"
+ );
+
+ $ref_map = array();
+
+ foreach ( $mirrors as $mirror ) {
+ // v1 encoded '/' as '-x-x-' to fit post_name.
+ $ref_map[ (int) $mirror->ID ] = str_replace( '-x-x-', '/', $mirror->post_name );
+ }
+
+ if ( $ref_map ) {
+ $report['rewritten_files'] = $this->rewrite_theme_files( $ref_map );
+ $report['rewritten_posts'] = $this->rewrite_post_content( $ref_map );
+
+ foreach ( array_keys( $ref_map ) as $mirror_id ) {
+ if ( wp_delete_post( $mirror_id, true ) ) {
+ ++$report['deleted_mirrors'];
+ }
+ }
+ }
+
+ $this->remove_v1_capabilities();
+ $this->flush_v1_transients();
+
+ return $report;
+ }
+
+ /**
+ * Rewrites mirror-post refs inside theme pattern files.
+ *
+ * Operates on the raw file bytes — pattern files contain PHP, so they are
+ * never run through the block parser here.
+ *
+ * @param array $ref_map Mirror post ID => pattern slug.
+ * @return string[] Paths of the files that changed.
+ */
+ private function rewrite_theme_files( array $ref_map ) {
+ $rewritten = array();
+ $directories = array( get_stylesheet_directory() . '/patterns' );
+
+ if ( get_template_directory() !== get_stylesheet_directory() ) {
+ $directories[] = get_template_directory() . '/patterns';
+ }
+
+ foreach ( array_filter( $directories, 'is_dir' ) as $directory ) {
+ $files = glob( $directory . '/*.php' );
+
+ if ( ! is_array( $files ) ) {
+ continue;
+ }
+
+ foreach ( $files as $file ) {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $contents = file_get_contents( $file );
+
+ if ( false === $contents || false === strpos( $contents, 'wp:block' ) ) {
+ continue;
+ }
+
+ $updated = $this->rewrite_refs( $contents, $ref_map );
+
+ if ( $updated === $contents ) {
+ continue;
+ }
+
+ $result = Pattern_Builder_Security::safe_file_write(
+ $file,
+ $updated,
+ array(
+ get_stylesheet_directory() . '/patterns',
+ get_template_directory() . '/patterns',
+ )
+ );
+
+ if ( ! is_wp_error( $result ) ) {
+ $rewritten[] = $file;
+ }
+ }
+ }
+
+ return $rewritten;
+ }
+
+ /**
+ * Rewrites mirror-post refs inside stored post content.
+ *
+ * @param array $ref_map Mirror post ID => pattern slug.
+ * @return int[] IDs of the posts that changed.
+ */
+ private function rewrite_post_content( array $ref_map ) {
+ global $wpdb;
+
+ $rewritten = array();
+ $batch = 100;
+ $last_id = 0;
+
+ while ( true ) {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT ID, post_content FROM {$wpdb->posts}
+ WHERE ID > %d
+ AND post_content LIKE %s
+ AND post_type NOT IN ( 'revision', 'tbell_pattern_block' )
+ ORDER BY ID ASC
+ LIMIT %d",
+ $last_id,
+ '%/s',
+ function ( $matches ) use ( $ref_map ) {
+ $attributes = json_decode( $matches[1], true );
+
+ if ( ! is_array( $attributes ) || ! isset( $attributes['ref'] ) ) {
+ return $matches[0];
+ }
+
+ $ref = (int) $attributes['ref'];
+
+ if ( ! isset( $ref_map[ $ref ] ) ) {
+ return $matches[0];
+ }
+
+ unset( $attributes['ref'] );
+
+ // `slug` leads so the serialized block reads naturally.
+ $attributes = array_merge( array( 'slug' => $ref_map[ $ref ] ), $attributes );
+
+ return '';
+ },
+ $content
+ );
+ }
+
+ /**
+ * Removes the capabilities v1 granted to roles on activation.
+ *
+ * @return void
+ */
+ private function remove_v1_capabilities() {
+ foreach ( wp_roles()->role_objects as $role ) {
+ foreach ( self::V1_CAPABILITIES as $capability ) {
+ if ( $role->has_cap( $capability ) ) {
+ $role->remove_cap( $capability );
+ }
+ }
+ }
+ }
+
+ /**
+ * Deletes transients other Pattern Builder / companion versions left behind.
+ *
+ * @return void
+ */
+ private function flush_v1_transients() {
+ Synced_Patterns::flush();
+
+ $theme = wp_get_theme();
+ if ( $theme->exists() ) {
+ $theme->delete_pattern_cache();
+ }
+ }
+
+ /**
+ * Shows a one-time summary of what the upgrade changed.
+ *
+ * @return void
+ */
+ public function render_report_notice() {
+ if ( ! current_user_can( 'edit_theme_options' ) ) {
+ return;
+ }
+
+ $report = get_option( self::REPORT_OPTION );
+
+ if ( ! is_array( $report ) || ! empty( $report['acknowledged'] ) ) {
+ return;
+ }
+
+ $changed = $report['deleted_mirrors'] || $report['rewritten_files'] || $report['rewritten_posts'];
+
+ // Only ever show the notice once.
+ $report['acknowledged'] = true;
+ update_option( self::REPORT_OPTION, $report, false );
+
+ if ( ! $changed ) {
+ return;
+ }
+
+ printf(
+ '
%s %s
',
+ esc_html__( 'Pattern Builder 2.0:', 'pattern-builder' ),
+ esc_html(
+ sprintf(
+ /* translators: 1: number of removed mirror posts, 2: number of rewritten posts, 3: number of rewritten files. */
+ __( 'Cleaned up %1$d pattern mirror posts and rewrote pattern references in %2$d posts and %3$d theme files. Theme pattern files are now the single source of truth.', 'pattern-builder' ),
+ (int) $report['deleted_mirrors'],
+ count( (array) $report['rewritten_posts'] ),
+ count( (array) $report['rewritten_files'] )
+ )
+ )
+ );
+ }
+}
diff --git a/includes/class-pattern-builder-post-type.php b/includes/class-pattern-builder-post-type.php
deleted file mode 100644
index 007e3ce..0000000
--- a/includes/class-pattern-builder-post-type.php
+++ /dev/null
@@ -1,222 +0,0 @@
- array(
- 'type' => 'object',
- ),
- );
- $args['attributes'] = array_merge( $args['attributes'], $extra_attributes );
- }
- return $args;
- }
-
- /**
- * Registers the tbell_pattern_block custom 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' ),
- );
-
- $args = array(
- 'labels' => $labels,
- 'public' => false,
- 'show_ui' => true,
- 'show_in_menu' => false,
- 'show_in_rest' => true,
- 'rest_base' => 'tbell_pattern_blocks',
- 'supports' => array( 'title', 'editor', 'revisions' ),
- 'hierarchical' => false,
- 'capability_type' => 'tbell_pattern_block',
- 'map_meta_cap' => true,
- );
-
- register_post_type( 'tbell_pattern_block', $args );
-
- register_post_meta(
- 'tbell_pattern_block',
- 'wp_pattern_sync_status',
- array(
- 'show_in_rest' => true,
- 'type' => 'string',
- 'single' => true,
- )
- );
-
- register_post_meta(
- 'tbell_pattern_block',
- 'wp_pattern_block_types',
- array(
- 'show_in_rest' => true,
- 'type' => 'string',
- 'single' => true,
- )
- );
-
- register_post_meta(
- 'tbell_pattern_block',
- 'wp_pattern_template_types',
- array(
- 'show_in_rest' => true,
- 'type' => 'string',
- 'single' => true,
- )
- );
-
- register_post_meta(
- 'tbell_pattern_block',
- 'wp_pattern_inserter',
- array(
- 'show_in_rest' => true,
- 'type' => 'string',
- 'single' => true,
- )
- );
-
- register_post_meta(
- 'tbell_pattern_block',
- 'wp_pattern_post_types',
- array(
- 'show_in_rest' => true,
- 'type' => 'string',
- 'single' => true,
- )
- );
-
- 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(
- 'delete_tbell_pattern_block',
- 'edit_tbell_pattern_blocks',
- );
-
- foreach ( $roles as $role_name ) {
- $role = get_role( $role_name );
- if ( $role ) {
- foreach ( $capabilities as $capability ) {
- $role->add_cap( $capability );
- }
- }
- }
- }
-
- /**
- * 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,
- * 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.
- * @return string
- */
- public function render_tbell_pattern_blocks( $block_content, $block ) {
- // 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 ( 'core/block' === $block['blockName'] && '' === $block_content ) {
-
- $attributes = $block['attrs'] ?? array();
-
- if ( empty( $attributes['ref'] ) ) {
- return '';
- }
-
- $post = get_post( $attributes['ref'] );
- if ( ! $post || 'tbell_pattern_block' !== $post->post_type ) {
- return '';
- }
-
- // If we have already seen this block, return an empty string to prevent recursion.
- if ( isset( $seen_refs[ $attributes['ref'] ] ) ) {
- return '';
- }
-
- if ( 'publish' !== $post->post_status || ! empty( $post->post_password ) ) {
- return '';
- }
-
- $seen_refs[ $attributes['ref'] ] = true;
-
- // Handle embeds for reusable blocks.
- global $wp_embed;
- $content = $wp_embed->run_shortcode( $post->post_content );
- $content = $wp_embed->autoembed( $content );
-
- /**
- * We set the `pattern/overrides` context through the `render_block_context`
- * filter so that it is available when a pattern's inner blocks are
- * rendering via do_blocks given it only receives the inner content.
- */
- $has_pattern_overrides = isset( $attributes['content'] ) && null !== get_block_bindings_source( 'core/pattern-overrides' );
- if ( $has_pattern_overrides ) {
- $filter_block_context = static function ( $context ) use ( $attributes ) {
- $context['pattern/overrides'] = $attributes['content'];
- return $context;
- };
- add_filter( 'render_block_context', $filter_block_context, 1 );
- }
-
- // Apply Block Hooks.
- $content = apply_block_hooks_to_content_from_post_object( $content, $post );
-
- // Render the block content.
- $content = do_blocks( $content );
-
- // It is safe to render this block again — no infinite recursion risk.
- unset( $seen_refs[ $attributes['ref'] ] );
-
- if ( $has_pattern_overrides ) {
- remove_filter( 'render_block_context', $filter_block_context, 1 );
- }
-
- return $content;
- }
- return $block_content;
- }
-}
diff --git a/includes/class-pattern-builder-rest-patterns-controller.php b/includes/class-pattern-builder-rest-patterns-controller.php
new file mode 100644
index 0000000..3437c99
--- /dev/null
+++ b/includes/class-pattern-builder-rest-patterns-controller.php
@@ -0,0 +1,630 @@
+post_type = $post_type;
+ $obj = get_post_type_object( $post_type );
+ $this->rest_base = ! empty( $obj->rest_base ) ? $obj->rest_base : 'patterns';
+ $this->namespace = ! empty( $obj->rest_namespace ) ? $obj->rest_namespace : 'pattern-builder/v1';
+ $this->store = new Pattern_File_Store();
+ }
+
+ /**
+ * Registers the controller's routes.
+ *
+ * @return void
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => array(),
+ ),
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'create_item' ),
+ 'permission_callback' => array( $this, 'update_items_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/(?P[\/\w%-]+)',
+ array(
+ 'args' => array(
+ 'id' => array(
+ 'description' => __( 'The namespaced name of the theme pattern.', 'pattern-builder' ),
+ 'type' => 'string',
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::EDITABLE,
+ 'callback' => array( $this, 'update_item' ),
+ 'permission_callback' => array( $this, 'update_items_permissions_check' ),
+ 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'delete_item' ),
+ 'permission_callback' => array( $this, 'update_items_permissions_check' ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks whether the user can read patterns.
+ *
+ * @param WP_REST_Request $request The request.
+ * @return true|WP_Error
+ */
+ public function get_items_permissions_check( $request ) {
+ if ( current_user_can( 'edit_posts' ) ) {
+ return true;
+ }
+
+ return new WP_Error(
+ 'rest_cannot_read_patterns',
+ __( 'Sorry, you are not allowed to view patterns.', 'pattern-builder' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ /**
+ * Checks whether the user can create, update, or delete theme patterns.
+ *
+ * @param WP_REST_Request $request The request.
+ * @return true|WP_Error
+ */
+ public function update_items_permissions_check( $request ) {
+ if ( current_user_can( 'edit_theme_options' ) ) {
+ return true;
+ }
+
+ return new WP_Error(
+ 'rest_cannot_manage_patterns',
+ __( 'Sorry, you are not allowed to manage theme patterns.', 'pattern-builder' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ /**
+ * Lists every pattern: theme file patterns and user patterns.
+ *
+ * @param WP_REST_Request $request The request.
+ * @return WP_REST_Response
+ */
+ public function get_items( $request ) {
+ $items = array();
+
+ foreach ( $this->store->get_theme_patterns() as $pattern ) {
+ $items[] = $this->prepare_pattern_for_response( $pattern, $request );
+ }
+
+ foreach ( $this->store->get_user_patterns() as $pattern ) {
+ $items[] = $this->prepare_pattern_for_response( $pattern, $request );
+ }
+
+ return rest_ensure_response( $items );
+ }
+
+ /**
+ * Returns a single theme pattern.
+ *
+ * @param WP_REST_Request $request The request.
+ * @return WP_REST_Response|WP_Error
+ */
+ public function get_item( $request ) {
+ $pattern = $this->find_pattern( $request['id'] );
+
+ if ( null === $pattern ) {
+ return $this->not_found_error();
+ }
+
+ return rest_ensure_response( $this->prepare_pattern_for_response( $pattern, $request ) );
+ }
+
+ /**
+ * Creates a theme pattern.
+ *
+ * When `fromWpBlock` carries a wp_block post ID, that user pattern is
+ * converted: its content (with theme edits from the request applied) is
+ * written to a pattern file and the post is deleted.
+ *
+ * @param WP_REST_Request $request The request.
+ * @return WP_REST_Response|WP_Error
+ */
+ public function create_item( $request ) {
+ $from_post = null;
+
+ if ( ! empty( $request['fromWpBlock'] ) ) {
+ $from_post = get_post( (int) $request['fromWpBlock'] );
+
+ if ( ! $from_post || 'wp_block' !== $from_post->post_type ) {
+ return new WP_Error(
+ 'pattern_builder_wp_block_not_found',
+ __( 'No user pattern was found to convert.', 'pattern-builder' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $pattern = Abstract_Pattern::from_post( $from_post );
+ } else {
+ if ( ! is_string( $request['title'] ) || '' === trim( $request['title'] ) ) {
+ return new WP_Error(
+ 'pattern_builder_missing_title',
+ __( 'A pattern needs a title.', 'pattern-builder' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $pattern = new Abstract_Pattern( array( 'title' => $request['title'] ) );
+ }
+
+ $pattern = $this->apply_request_to_pattern( $pattern, $request );
+
+ if ( false === strpos( $pattern->name, '/' ) ) {
+ $pattern->name = get_stylesheet() . '/' . $pattern->name;
+ }
+
+ $pattern->source = 'theme';
+ $pattern->id = $pattern->name;
+ $pattern->filePath = null;
+
+ if ( null !== $this->store->find_theme_pattern( $pattern->name ) ) {
+ return new WP_Error(
+ 'pattern_builder_pattern_exists',
+ __( 'A theme pattern with this name already exists.', 'pattern-builder' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( $from_post ) {
+ $saved = $this->store->convert_user_pattern_to_theme( $from_post, $pattern, $this->get_save_options( $request ) );
+ } else {
+ $saved = $this->store->update_theme_pattern( $pattern, $this->get_save_options( $request ) );
+ }
+
+ if ( is_wp_error( $saved ) ) {
+ return $this->as_rest_error( $saved );
+ }
+
+ $response = rest_ensure_response( $this->prepare_pattern_for_response( $saved, $request ) );
+ $response->set_status( 201 );
+
+ return $response;
+ }
+
+ /**
+ * Updates a theme pattern, writing its file.
+ *
+ * A request whose `source` is `user` converts the theme pattern into a
+ * user pattern instead: the file is deleted and a wp_block post created.
+ * The response then describes the new user pattern (numeric `id`).
+ *
+ * @param WP_REST_Request $request The request.
+ * @return WP_REST_Response|WP_Error
+ */
+ public function update_item( $request ) {
+ $pattern = $this->find_pattern( $request['id'] );
+
+ if ( null === $pattern ) {
+ return $this->not_found_error();
+ }
+
+ $pattern = $this->apply_request_to_pattern( $pattern, $request );
+
+ if ( 'user' === $request['source'] ) {
+ $converted = $this->store->convert_theme_pattern_to_user( $pattern );
+
+ if ( is_wp_error( $converted ) ) {
+ return $this->as_rest_error( $converted );
+ }
+
+ return rest_ensure_response( $this->prepare_pattern_for_response( $converted, $request ) );
+ }
+
+ $saved = $this->store->update_theme_pattern( $pattern, $this->get_save_options( $request ) );
+
+ if ( is_wp_error( $saved ) ) {
+ return $this->as_rest_error( $saved );
+ }
+
+ return rest_ensure_response( $this->prepare_pattern_for_response( $saved, $request ) );
+ }
+
+ /**
+ * Deletes a theme pattern's file.
+ *
+ * @param WP_REST_Request $request The request.
+ * @return WP_REST_Response|WP_Error
+ */
+ public function delete_item( $request ) {
+ $pattern = $this->find_pattern( $request['id'] );
+
+ if ( null === $pattern ) {
+ return $this->not_found_error();
+ }
+
+ $previous = $this->prepare_pattern_for_response( $pattern, $request );
+ $deleted = $this->store->delete_theme_pattern( $pattern );
+
+ if ( is_wp_error( $deleted ) ) {
+ return $this->as_rest_error( $deleted );
+ }
+
+ return rest_ensure_response(
+ array(
+ 'deleted' => true,
+ 'previous' => $previous,
+ )
+ );
+ }
+
+ /**
+ * Finds a theme pattern for a route's `id` parameter.
+ *
+ * @param string $id The namespaced pattern name.
+ * @return Abstract_Pattern|null
+ */
+ protected function find_pattern( $id ) {
+ if ( ! is_string( $id ) || '' === $id ) {
+ return null;
+ }
+
+ return $this->store->find_theme_pattern( rawurldecode( $id ) );
+ }
+
+ /**
+ * Overlays a request's parameters onto a pattern.
+ *
+ * @param Abstract_Pattern $pattern The pattern to update.
+ * @param WP_REST_Request $request The request.
+ * @return Abstract_Pattern
+ */
+ protected function apply_request_to_pattern( Abstract_Pattern $pattern, WP_REST_Request $request ) {
+ $title = $request['title'];
+ if ( is_array( $title ) && isset( $title['raw'] ) ) {
+ $title = $title['raw'];
+ }
+ if ( is_string( $title ) && '' !== trim( $title ) ) {
+ $pattern->title = $title;
+ }
+
+ $content = $request['content'];
+ if ( is_array( $content ) && isset( $content['raw'] ) ) {
+ $content = $content['raw'];
+ }
+ if ( is_string( $content ) ) {
+ $pattern->content = $content;
+ }
+
+ if ( is_string( $request['name'] ) && '' !== $request['name'] ) {
+ $pattern->name = $request['name'];
+ }
+
+ if ( is_string( $request['description'] ) ) {
+ $pattern->description = $request['description'];
+ }
+
+ foreach ( array( 'categories', 'keywords', 'blockTypes', 'postTypes', 'templateTypes' ) as $list_field ) {
+ if ( is_array( $request[ $list_field ] ) ) {
+ $pattern->{$list_field} = array_values( array_filter( array_map( 'strval', $request[ $list_field ] ), 'strlen' ) );
+ }
+ }
+
+ if ( null !== $request['inserter'] ) {
+ $pattern->inserter = rest_sanitize_boolean( $request['inserter'] );
+ }
+
+ if ( null !== $request['synced'] ) {
+ $pattern->synced = rest_sanitize_boolean( $request['synced'] );
+ }
+
+ if ( null !== $request['viewportWidth'] ) {
+ $width = (int) $request['viewportWidth'];
+ $pattern->viewportWidth = $width > 0 ? $width : null;
+ }
+
+ return $pattern;
+ }
+
+ /**
+ * Reads the save options this plugin's editor tools append to requests.
+ *
+ * @param WP_REST_Request $request The request.
+ * @return array Options for Pattern_File_Store::update_theme_pattern().
+ */
+ protected function get_save_options( WP_REST_Request $request ) {
+ $options = array();
+
+ if ( 'true' === $request->get_param( 'patternBuilderLocalize' ) ) {
+ $options['localize'] = true;
+ }
+
+ if ( 'false' === $request->get_param( 'patternBuilderImportImages' ) ) {
+ $options['import_images'] = false;
+ }
+
+ return $options;
+ }
+
+ /**
+ * Shapes a pattern the way the editor's entity layer expects.
+ *
+ * @param Abstract_Pattern $pattern The pattern.
+ * @param WP_REST_Request $request The request.
+ * @return array The response data.
+ */
+ protected function prepare_pattern_for_response( Abstract_Pattern $pattern, $request ) {
+ $is_theme = 'theme' === $pattern->source;
+
+ $data = array(
+ 'id' => $is_theme ? $pattern->name : $pattern->id,
+ 'name' => $pattern->name,
+ 'slug' => $is_theme ? $pattern->name : basename( (string) $pattern->name ),
+ 'type' => $is_theme ? $this->post_type : 'wp_block',
+ 'status' => 'publish',
+ 'title' => array(
+ 'raw' => $pattern->title,
+ 'rendered' => $pattern->title,
+ ),
+ 'content' => array(
+ 'raw' => $pattern->content,
+ 'block_version' => block_version( $pattern->content ),
+ ),
+ 'description' => $pattern->description,
+ 'categories' => array_values( $pattern->categories ),
+ 'keywords' => array_values( $pattern->keywords ),
+ 'blockTypes' => array_values( $pattern->blockTypes ),
+ 'postTypes' => array_values( $pattern->postTypes ),
+ 'templateTypes' => array_values( $pattern->templateTypes ),
+ 'inserter' => (bool) $pattern->inserter,
+ 'synced' => (bool) $pattern->synced,
+ 'viewportWidth' => $pattern->viewportWidth,
+ 'source' => $pattern->source,
+ );
+
+ if ( $is_theme && current_user_can( 'edit_theme_options' ) ) {
+ $data['filePath'] = $pattern->filePath;
+ }
+
+ if ( $is_theme ) {
+ $self = rest_url( $this->namespace . '/' . $this->rest_base . '/' . $pattern->name );
+
+ $data['_links'] = array(
+ 'self' => array(
+ array( 'href' => $self ),
+ ),
+ 'collection' => array(
+ array( 'href' => rest_url( $this->namespace . '/' . $this->rest_base ) ),
+ ),
+ );
+
+ /*
+ * Action links, as core's posts controller advertises them. The
+ * editor's save button reads `wp:action-publish` off the record;
+ * without it, it assumes the user can only "Submit for Review".
+ */
+ if ( current_user_can( 'edit_theme_options' ) ) {
+ $data['_links']['wp:action-publish'] = array(
+ array( 'href' => $self ),
+ );
+ }
+
+ if ( current_user_can( 'unfiltered_html' ) ) {
+ $data['_links']['wp:action-unfiltered-html'] = array(
+ array( 'href' => $self ),
+ );
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Turns a store error into a proper REST error response.
+ *
+ * @param WP_Error $error The error.
+ * @return WP_Error The error, with a status the REST server understands.
+ */
+ protected function as_rest_error( WP_Error $error ) {
+ $data = $error->get_error_data();
+
+ if ( ! is_array( $data ) || empty( $data['status'] ) ) {
+ $error->add_data( array( 'status' => 500 ) );
+ }
+
+ return $error;
+ }
+
+ /**
+ * The error for a pattern the theme does not have.
+ *
+ * @return WP_Error
+ */
+ protected function not_found_error() {
+ return new WP_Error(
+ 'pattern_builder_pattern_not_found',
+ __( 'No theme pattern with that name exists.', 'pattern-builder' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ /**
+ * Retrieves the pattern schema.
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $this->schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => $this->post_type,
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'description' => __( 'Pattern identity: the namespaced name for theme patterns, the post ID for user patterns.', 'pattern-builder' ),
+ 'type' => array( 'string', 'integer' ),
+ 'readonly' => true,
+ ),
+ 'name' => array(
+ 'description' => __( 'Namespaced pattern name.', 'pattern-builder' ),
+ 'type' => 'string',
+ ),
+ 'slug' => array(
+ 'description' => __( 'Pattern slug.', 'pattern-builder' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ 'type' => array(
+ 'description' => __( 'Entity type of the pattern.', 'pattern-builder' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ 'status' => array(
+ 'description' => __( 'Pattern status.', 'pattern-builder' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ 'title' => array(
+ 'description' => __( 'Pattern title.', 'pattern-builder' ),
+ 'type' => array( 'string', 'object' ),
+ 'properties' => array(
+ 'raw' => array( 'type' => 'string' ),
+ 'rendered' => array(
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ ),
+ ),
+ 'content' => array(
+ 'description' => __( 'Pattern block markup.', 'pattern-builder' ),
+ 'type' => array( 'string', 'object' ),
+ 'properties' => array(
+ 'raw' => array( 'type' => 'string' ),
+ 'block_version' => array(
+ 'type' => 'integer',
+ 'readonly' => true,
+ ),
+ ),
+ ),
+ 'description' => array(
+ 'description' => __( 'Pattern description.', 'pattern-builder' ),
+ 'type' => 'string',
+ ),
+ 'categories' => array(
+ 'description' => __( 'Pattern category slugs.', 'pattern-builder' ),
+ 'type' => 'array',
+ 'items' => array( 'type' => 'string' ),
+ ),
+ 'keywords' => array(
+ 'description' => __( 'Pattern keywords.', 'pattern-builder' ),
+ 'type' => 'array',
+ 'items' => array( 'type' => 'string' ),
+ ),
+ 'blockTypes' => array(
+ 'description' => __( 'Block types this pattern is offered for.', 'pattern-builder' ),
+ 'type' => 'array',
+ 'items' => array( 'type' => 'string' ),
+ ),
+ 'postTypes' => array(
+ 'description' => __( 'Post types this pattern is limited to.', 'pattern-builder' ),
+ 'type' => 'array',
+ 'items' => array( 'type' => 'string' ),
+ ),
+ 'templateTypes' => array(
+ 'description' => __( 'Template types this pattern is offered for.', 'pattern-builder' ),
+ 'type' => 'array',
+ 'items' => array( 'type' => 'string' ),
+ ),
+ 'inserter' => array(
+ 'description' => __( 'Whether the pattern is offered by the block inserter.', 'pattern-builder' ),
+ 'type' => 'boolean',
+ ),
+ 'synced' => array(
+ 'description' => __( 'Whether inserted copies of the pattern stay linked to it.', 'pattern-builder' ),
+ 'type' => 'boolean',
+ ),
+ 'viewportWidth' => array(
+ 'description' => __( 'Intended viewport width when previewing the pattern, in pixels.', 'pattern-builder' ),
+ 'type' => array( 'integer', 'null' ),
+ ),
+ 'source' => array(
+ 'description' => __( 'Where the pattern lives: a theme file or the database.', 'pattern-builder' ),
+ 'type' => 'string',
+ 'enum' => array( 'theme', 'user' ),
+ ),
+ 'fromWpBlock' => array(
+ 'description' => __( 'On creation, the ID of a wp_block post to convert into this theme pattern.', 'pattern-builder' ),
+ 'type' => 'integer',
+ ),
+ ),
+ );
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+}
diff --git a/includes/class-pattern-builder-security.php b/includes/class-pattern-builder-security.php
index e1f3e75..f1b3cf5 100644
--- a/includes/class-pattern-builder-security.php
+++ b/includes/class-pattern-builder-security.php
@@ -7,6 +7,10 @@
* @package Pattern_Builder
*/
+namespace TwentyBellows\PatternBuilder;
+
+use WP_Error;
+
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
diff --git a/includes/class-pattern-builder.php b/includes/class-pattern-builder.php
index e1c6ce5..c50838a 100644
--- a/includes/class-pattern-builder.php
+++ b/includes/class-pattern-builder.php
@@ -2,13 +2,31 @@
namespace TwentyBellows\PatternBuilder;
+require_once __DIR__ . '/class-pattern-builder-security.php';
+require_once __DIR__ . '/class-pattern-builder-abstract-pattern.php';
+require_once __DIR__ . '/class-pattern-builder-localization.php';
+require_once __DIR__ . '/class-pattern-file-store.php';
+require_once __DIR__ . '/class-inner-html-processor.php';
+require_once __DIR__ . '/class-block-markup.php';
+require_once __DIR__ . '/class-pattern-resolver.php';
+require_once __DIR__ . '/class-pattern-block.php';
+require_once __DIR__ . '/class-synced-patterns.php';
+require_once __DIR__ . '/class-editor-support.php';
+require_once __DIR__ . '/class-pattern-builder-rest-patterns-controller.php';
+require_once __DIR__ . '/class-pattern-builder-entity.php';
require_once __DIR__ . '/class-pattern-builder-api.php';
require_once __DIR__ . '/class-pattern-builder-admin.php';
require_once __DIR__ . '/class-pattern-builder-editor.php';
-require_once __DIR__ . '/class-pattern-builder-post-type.php';
+require_once __DIR__ . '/class-pattern-builder-migration.php';
/**
* Main class for managing the Pattern Builder plugin.
+ *
+ * Always registers the full stack — the pattern runtime (vendored from the
+ * companion Synced Patterns for Themes plugin, kept logic-identical) and the
+ * editing layer on top. When both plugins are installed, the companion
+ * detects Pattern Builder at `plugins_loaded` and stays entirely unloaded;
+ * this plugin never has to coordinate.
*/
class Pattern_Builder {
@@ -23,10 +41,17 @@ class Pattern_Builder {
* Constructor to initialize the Pattern Builder components.
*/
private function __construct() {
+ ( new Pattern_Block() )->register();
+ ( new Editor_Support( PATTERN_BUILDER_FILE ) )->register();
+
+ // A theme switch changes which pattern files the synced lookup reads.
+ add_action( 'switch_theme', array( Synced_Patterns::class, 'flush' ) );
+
+ new Pattern_Builder_Entity();
new Pattern_Builder_API();
new Pattern_Builder_Admin();
new Pattern_Builder_Editor();
- new Pattern_Builder_Post_Type();
+ new Pattern_Builder_Migration();
}
/**
diff --git a/includes/class-pattern-builder-controller.php b/includes/class-pattern-file-store.php
similarity index 60%
rename from includes/class-pattern-builder-controller.php
rename to includes/class-pattern-file-store.php
index 5f877ed..f3e78a2 100644
--- a/includes/class-pattern-builder-controller.php
+++ b/includes/class-pattern-file-store.php
@@ -11,229 +11,395 @@
require_once __DIR__ . '/class-pattern-builder-security.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
-class Pattern_Builder_Controller {
+/**
+ * Reads and writes block patterns.
+ *
+ * Theme patterns live in PHP files in the theme's (and parent theme's)
+ * `patterns/` directory — the files are the only source of truth, nothing is
+ * mirrored into the database. User patterns are core `wp_block` posts and are
+ * only touched here for listing and conversion.
+ */
+class Pattern_File_Store {
/**
- * Encodes a namespaced pattern slug for storage as a WordPress post_name.
+ * Returns all patterns found as PHP files in the active theme's and the
+ * parent theme's `patterns/` directories.
*
- * WordPress post_name does not support '/' — encode it as '-x-x-'.
- *
- * @param string $slug Pattern slug (e.g. 'my-theme/pattern-name').
- * @return string Encoded slug safe for post_name storage.
+ * @return Abstract_Pattern[]
*/
- public function format_pattern_slug_for_post( $slug ) {
- return str_replace( '/', '-x-x-', $slug );
+ public function get_theme_patterns() {
+ $patterns = array();
+ $seen = array();
+
+ foreach ( $this->get_pattern_directories() as $directory ) {
+ $pattern_files = glob( $directory . '/*.php' );
+
+ if ( ! is_array( $pattern_files ) ) {
+ continue;
+ }
+
+ foreach ( $pattern_files as $pattern_file ) {
+ $pattern = Abstract_Pattern::from_file( $pattern_file );
+
+ if ( '' === $pattern->name || isset( $seen[ $pattern->name ] ) ) {
+ // A child theme pattern overrides a parent pattern with the same slug.
+ continue;
+ }
+
+ $seen[ $pattern->name ] = true;
+ $patterns[] = $pattern;
+ }
+ }
+
+ return $patterns;
}
/**
- * Decodes a post_name-encoded slug back to the original namespaced slug.
+ * Finds a single theme pattern by its namespaced name.
*
- * @param string $slug Encoded slug (e.g. 'my-theme-x-x-pattern-name').
- * @return string Decoded pattern slug.
+ * @param string $name Pattern name (e.g. "theme-slug/pattern-name").
+ * @return Abstract_Pattern|null
*/
- public static function format_pattern_slug_from_post( $slug ) {
- return str_replace( '-x-x-', '/', $slug );
+ public function find_theme_pattern( $name ) {
+ foreach ( $this->get_theme_patterns() as $pattern ) {
+ if ( $pattern->name === $name ) {
+ return $pattern;
+ }
+ }
+
+ return null;
}
/**
- * Gets the tbell_pattern_block post for a pattern, creating it if it doesn't exist.
+ * Returns all user patterns (wp_block posts) from the database.
*
- * @param Abstract_Pattern $pattern The pattern object.
- * @return \WP_Post|null The pattern post or null if not found.
+ * @return Abstract_Pattern[]
*/
- public function get_tbell_pattern_block_post_for_pattern( $pattern ) {
- $path = $this->format_pattern_slug_for_post( $pattern->name );
-
+ public function get_user_patterns(): array {
$query = new WP_Query(
array(
- 'name' => sanitize_title( $path ),
- 'post_type' => 'tbell_pattern_block',
- 'posts_per_page' => 1,
- 'no_found_rows' => true,
- 'update_post_meta_cache' => false,
- 'update_post_term_cache' => false,
+ 'post_type' => 'wp_block',
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'no_found_rows' => true,
)
);
- $pattern_post = $query->have_posts() ? $query->posts[0] : null;
-
- // Clean up after the query.
- wp_reset_postdata();
+ $patterns = array();
- if ( $pattern_post ) {
- $pattern_post->post_name = $pattern->name;
- return $pattern_post;
+ foreach ( $query->posts as $post ) {
+ $patterns[] = Abstract_Pattern::from_post( $post );
}
- return $this->create_tbell_pattern_block_post_for_pattern( $pattern );
+ return $patterns;
}
/**
- * Creates or updates the tbell_pattern_block post that mirrors a theme pattern file.
+ * Updates a theme pattern by writing its PHP file.
*
- * @param Abstract_Pattern $pattern The pattern to upsert.
- * @return \WP_Post The created or updated post.
+ * @param Abstract_Pattern $pattern The pattern to update.
+ * @param array $options Optional settings: 'localize' (bool), 'import_images' (bool).
+ * @return Abstract_Pattern|WP_Error The pattern as re-read from disk, or an error.
*/
- public function create_tbell_pattern_block_post_for_pattern( $pattern ) {
+ public function update_theme_pattern( Abstract_Pattern $pattern, $options = array() ) {
+ if ( ! current_user_can( 'edit_theme_options' ) ) {
+ return new WP_Error(
+ 'insufficient_permissions',
+ __( 'You do not have permission to modify theme patterns.', 'pattern-builder' ),
+ array( 'status' => 403 )
+ );
+ }
- $existing_post = get_page_by_path( $this->format_pattern_slug_for_post( $pattern->name ), OBJECT, array( 'tbell_pattern_block' ) );
+ // Import images unless explicitly disabled.
+ if ( ! isset( $options['import_images'] ) || true === $options['import_images'] ) {
+ $pattern = $this->import_pattern_image_assets( $pattern );
+ }
- $post_id = $existing_post ? $existing_post->ID : null;
+ // Localize if enabled.
+ if ( isset( $options['localize'] ) && true === $options['localize'] ) {
+ $pattern = Pattern_Builder_Localization::localize_pattern_content( $pattern );
+ }
- $meta = array();
+ $result = $this->update_theme_pattern_file( $pattern );
- if ( ! $pattern->synced ) {
- $meta['wp_pattern_sync_status'] = 'unsynced';
- } else {
- delete_post_meta( $post_id, 'wp_pattern_sync_status' );
+ if ( is_wp_error( $result ) ) {
+ return $result;
}
- if ( $pattern->blockTypes ) {
- $meta['wp_pattern_block_types'] = implode( ',', $pattern->blockTypes );
- } else {
- delete_post_meta( $post_id, 'wp_pattern_block_types' );
+ $this->flush_pattern_caches();
+
+ // 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 );
}
- if ( $pattern->templateTypes ) {
- $meta['wp_pattern_template_types'] = implode( ',', $pattern->templateTypes );
- } else {
- delete_post_meta( $post_id, 'wp_pattern_template_types' );
+ return $pattern;
+ }
+
+ /**
+ * Creates a theme pattern from a user pattern (wp_block), deleting the post.
+ *
+ * @param \WP_Post $post The wp_block post to convert.
+ * @param Abstract_Pattern $pattern The pattern data to write (already carrying any edits).
+ * @param array $options Optional settings passed to update_theme_pattern().
+ * @return Abstract_Pattern|WP_Error The new theme pattern, or an error.
+ */
+ public function convert_user_pattern_to_theme( $post, Abstract_Pattern $pattern, $options = array() ) {
+ if ( ! current_user_can( 'edit_theme_options' ) ) {
+ return new WP_Error(
+ 'insufficient_permissions',
+ __( 'You do not have permission to modify theme patterns.', 'pattern-builder' ),
+ array( 'status' => 403 )
+ );
}
- if ( $pattern->postTypes ) {
- $meta['wp_pattern_post_types'] = implode( ',', $pattern->postTypes );
- } else {
- delete_post_meta( $post_id, 'wp_pattern_post_types' );
+ // Theme patterns are namespaced with the theme slug.
+ if ( false === strpos( $pattern->name, '/' ) ) {
+ $pattern->name = get_stylesheet() . '/' . $pattern->name;
}
- if ( $pattern->keywords ) {
- $meta['wp_pattern_keywords'] = implode( ',', $pattern->keywords );
- } else {
- delete_post_meta( $post_id, 'wp_pattern_keywords' );
+ $pattern->source = 'theme';
+ $pattern->id = $pattern->name;
+ $pattern->filePath = null;
+
+ $saved = $this->update_theme_pattern( $pattern, $options );
+
+ if ( is_wp_error( $saved ) ) {
+ return $saved;
}
- if ( false === $pattern->inserter ) {
- $meta['wp_pattern_inserter'] = 'no';
- } else {
- delete_post_meta( $post_id, 'wp_pattern_inserter' );
+ wp_delete_post( $post->ID, true );
+
+ return $saved;
+ }
+
+ /**
+ * Converts a theme pattern into a user pattern (wp_block), deleting the file.
+ *
+ * @param Abstract_Pattern $pattern The theme pattern to convert.
+ * @return Abstract_Pattern|WP_Error The new user pattern (with its post ID), or an error.
+ */
+ public function convert_theme_pattern_to_user( Abstract_Pattern $pattern ) {
+ if ( ! current_user_can( 'edit_theme_options' ) ) {
+ return new WP_Error(
+ 'insufficient_permissions',
+ __( 'You do not have permission to modify theme patterns.', 'pattern-builder' ),
+ array( 'status' => 403 )
+ );
}
- $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,
+ $filepath = $this->get_pattern_filepath( $pattern );
+
+ // Export any theme assets to the media library.
+ $pattern = $this->export_pattern_image_assets( $pattern );
+
+ $post_id = wp_insert_post(
+ array(
+ 'post_title' => $pattern->title,
+ 'post_name' => basename( $pattern->name ),
+ 'post_content' => $pattern->content,
+ 'post_excerpt' => $pattern->description,
+ 'post_type' => 'wp_block',
+ 'post_status' => 'publish',
+ ),
+ true
);
- if ( $post_id ) {
- $post_data['ID'] = $post_id;
+ if ( is_wp_error( $post_id ) ) {
+ return $post_id;
}
- $post_id = wp_insert_post( $post_data, true );
+ 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.
wp_set_object_terms( $post_id, $pattern->categories, 'wp_pattern_category', false );
- // Return the post by post ID.
- $post = get_post( $post_id );
- $post->post_name = $pattern->name;
+ // Delete the theme pattern file.
+ if ( ! is_wp_error( $filepath ) && $filepath ) {
+ $deleted = Pattern_Builder_Security::safe_file_delete(
+ $filepath,
+ array(
+ get_stylesheet_directory() . '/patterns',
+ get_template_directory() . '/patterns',
+ )
+ );
+
+ if ( is_wp_error( $deleted ) ) {
+ return $deleted;
+ }
+ }
+
+ $this->flush_pattern_caches();
- return $post;
+ return Abstract_Pattern::from_post( get_post( $post_id ) );
}
/**
- * Updates a theme pattern — writes the PHP file and syncs the DB post.
+ * Deletes a theme pattern's PHP file.
*
- * @param Abstract_Pattern $pattern The pattern to update.
- * @param array $options Optional settings: 'localize' (bool), 'import_images' (bool).
- * @return Abstract_Pattern|WP_Error
+ * @param Abstract_Pattern $pattern The pattern to delete.
+ * @return array|WP_Error Success message array or WP_Error on failure.
*/
- public function update_theme_pattern( Abstract_Pattern $pattern, $options = array() ) {
- // Check if user has permission to modify theme patterns.
+ public function delete_theme_pattern( Abstract_Pattern $pattern ) {
if ( ! current_user_can( 'edit_theme_options' ) ) {
return new WP_Error(
'insufficient_permissions',
- __( 'You do not have permission to modify theme patterns.', 'pattern-builder' ),
+ __( 'You do not have permission to delete theme patterns.', 'pattern-builder' ),
array( 'status' => 403 )
);
}
- $post = get_page_by_path( $this->format_pattern_slug_for_post( $pattern->name ), OBJECT, array( 'tbell_pattern_block', 'wp_block' ) );
+ $path = $this->get_pattern_filepath( $pattern );
- 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;
+ if ( is_wp_error( $path ) ) {
+ return $path;
}
- // Import images unless explicitly disabled.
- if ( ! isset( $options['import_images'] ) || true === $options['import_images'] ) {
- $pattern = $this->import_pattern_image_assets( $pattern );
+ $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;
}
- // Localize if enabled.
- if ( isset( $options['localize'] ) && true === $options['localize'] ) {
- $pattern = Pattern_Builder_Localization::localize_pattern_content( $pattern );
+ $this->flush_pattern_caches();
+
+ return array( 'message' => __( 'Pattern deleted successfully.', 'pattern-builder' ) );
+ }
+
+ /**
+ * 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 if not found.
+ */
+ public function get_pattern_filepath( $pattern ) {
+ $path = $pattern->filePath ?? get_stylesheet_directory() . '/patterns/' . sanitize_file_name( basename( $pattern->name ) ) . '.php';
+
+ if ( file_exists( $path ) ) {
+ return $path;
}
- // Write the pattern file.
- $this->update_theme_pattern_file( $pattern );
+ $matched_pattern = $this->find_theme_pattern( $pattern->name );
- // 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 );
+ if ( $matched_pattern && isset( $matched_pattern->filePath ) ) {
+ return $matched_pattern->filePath;
}
- $post_id = wp_update_post(
- array(
- 'ID' => $post ? $post->ID : null,
- 'post_title' => $pattern->title,
- 'post_name' => $this->format_pattern_slug_for_post( $pattern->name ),
- 'post_excerpt' => $pattern->description,
- 'post_content' => $pattern->content,
- 'post_type' => 'tbell_pattern_block',
- )
+ return new WP_Error(
+ 'pattern_file_not_found',
+ __( 'Pattern file not found.', 'pattern-builder' ),
+ array( 'status' => 404 )
);
+ }
- if ( $pattern->synced ) {
- delete_post_meta( $post_id, 'wp_pattern_sync_status' );
- } else {
- update_post_meta( $post_id, 'wp_pattern_sync_status', 'unsynced' );
+ /**
+ * 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, construct a new path.
+ if ( is_wp_error( $path ) ) {
+ $filename = sanitize_file_name( basename( $pattern->name ) );
+ $path = get_stylesheet_directory() . '/patterns/' . $filename . '.php';
}
- if ( $pattern->keywords ) {
- update_post_meta( $post_id, 'wp_pattern_keywords', implode( ',', $pattern->keywords ) );
- } else {
- delete_post_meta( $post_id, 'wp_pattern_keywords' );
+ $formatted_content = $this->format_block_markup( $pattern->content );
+ $file_content = $this->build_pattern_file_metadata( $pattern ) . $formatted_content;
+
+ $allowed_dirs = array(
+ get_stylesheet_directory() . '/patterns',
+ get_template_directory() . '/patterns',
+ );
+ $response = Pattern_Builder_Security::safe_file_write( $path, $file_content, $allowed_dirs );
+
+ if ( is_wp_error( $response ) ) {
+ return $response;
}
- if ( $pattern->blockTypes ) {
- update_post_meta( $post_id, 'wp_pattern_block_types', implode( ',', $pattern->blockTypes ) );
- } else {
- delete_post_meta( $post_id, 'wp_pattern_block_types' );
+ return $pattern;
+ }
+
+ /**
+ * Forgets every cache derived from the theme's pattern files.
+ *
+ * Covers core's per-theme pattern header cache, this plugin's synced-slug
+ * lookup, and the Synced Patterns for Themes transient. The companion
+ * stays unloaded while this plugin is active, but its week-long cache may
+ * survive from before — clearing it here keeps the companion current if
+ * this plugin is ever deactivated.
+ *
+ * @return void
+ */
+ public function flush_pattern_caches() {
+ $theme = wp_get_theme();
+ if ( $theme->exists() ) {
+ $theme->delete_pattern_cache();
}
- if ( $pattern->templateTypes ) {
- update_post_meta( $post_id, 'wp_pattern_template_types', implode( ',', $pattern->templateTypes ) );
- } else {
- delete_post_meta( $post_id, 'wp_pattern_template_types' );
+ $parent = $theme->parent();
+ if ( $parent instanceof \WP_Theme && $parent->exists() ) {
+ $parent->delete_pattern_cache();
}
- if ( $pattern->postTypes ) {
- update_post_meta( $post_id, 'wp_pattern_post_types', implode( ',', $pattern->postTypes ) );
- } else {
- delete_post_meta( $post_id, 'wp_pattern_post_types' );
+ Synced_Patterns::flush();
+
+ delete_transient( 'synced_patterns_for_themes_' . get_stylesheet() );
+ }
+
+ /**
+ * Lists the pattern directories of the active theme and its parent.
+ *
+ * @return string[] Absolute directory paths.
+ */
+ private function get_pattern_directories() {
+ $directories = array( get_stylesheet_directory() . '/patterns' );
+
+ if ( get_template_directory() !== get_stylesheet_directory() ) {
+ $directories[] = get_template_directory() . '/patterns';
}
- // Store categories.
- wp_set_object_terms( $post_id, $pattern->categories, 'wp_pattern_category', false );
+ return array_filter( $directories, 'is_dir' );
+ }
- return $pattern;
+ /**
+ * Builds the PHP header metadata block for a pattern file.
+ *
+ * @param Abstract_Pattern $pattern The pattern object.
+ * @return string PHP header comment string.
+ */
+ private function build_pattern_file_metadata( Abstract_Pattern $pattern ): string {
+
+ $categories = $pattern->categories ? "\n * Categories: " . implode( ', ', $pattern->categories ) : '';
+ $keywords = $pattern->keywords ? "\n * Keywords: " . implode( ', ', $pattern->keywords ) : '';
+ $blockTypes = $pattern->blockTypes ? "\n * Block Types: " . implode( ', ', $pattern->blockTypes ) : '';
+ $postTypes = $pattern->postTypes ? "\n * Post Types: " . implode( ', ', $pattern->postTypes ) : '';
+ $templateTypes = $pattern->templateTypes ? "\n * Template Types: " . implode( ', ', $pattern->templateTypes ) : '';
+ $viewportWidth = $pattern->viewportWidth ? "\n * Viewport Width: " . (int) $pattern->viewportWidth : '';
+ $inserter = $pattern->inserter ? '' : "\n * Inserter: no";
+ $synced = $pattern->synced ? "\n * Synced: yes" : '';
+
+ $metadata = "title\n";
+ $metadata .= " * Slug: $pattern->name\n";
+ $metadata .= " * Description: $pattern->description$categories$keywords$blockTypes$postTypes$templateTypes$viewportWidth$inserter$synced\n";
+ $metadata .= " */\n";
+ $metadata .= "?>\n";
+ return $metadata;
}
/**
@@ -244,7 +410,7 @@ public function update_theme_pattern( Abstract_Pattern $pattern, $options = arra
* @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 ) {
+ public function export_pattern_image_assets( $pattern ) {
$home_url = home_url();
@@ -478,327 +644,6 @@ function ( $matches ) use ( $download_and_save_image ) {
return $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.
- if ( ! current_user_can( 'edit_tbell_pattern_blocks' ) ) {
- return new WP_Error(
- 'insufficient_permissions',
- __( 'You do not have permission to modify patterns.', 'pattern-builder' ),
- 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 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;
- }
-
- // Export any theme assets to the media library.
- $pattern = $this->export_pattern_image_assets( $pattern );
-
- if ( empty( $post ) ) {
- $post_id = wp_insert_post(
- array(
- 'post_title' => $pattern->title,
- 'post_name' => basename( $pattern->name ),
- 'post_content' => $pattern->content,
- 'post_excerpt' => $pattern->description,
- 'post_type' => 'wp_block',
- 'post_status' => 'publish',
- )
- );
- } else {
- $post_id = wp_update_post(
- array(
- 'ID' => $post->ID,
- 'post_title' => $pattern->title,
- 'post_name' => basename( $pattern->name ),
- 'post_content' => $pattern->content,
- 'post_excerpt' => $pattern->description,
- 'post_type' => 'wp_block',
- )
- );
- }
-
- // 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.
- wp_set_object_terms( $post_id, $pattern->categories, 'wp_pattern_category', false );
-
- // 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 ) {
- 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();
-
- foreach ( $pattern_files as $pattern_file ) {
- $pattern = Abstract_Pattern::from_file( $pattern_file );
- $patterns[] = $pattern;
- }
-
- 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();
-
- foreach ( $query->posts as $post ) {
- $patterns[] = Abstract_Pattern::from_post( $post );
- }
-
- 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.
- if ( ! current_user_can( 'delete_tbell_pattern_blocks' ) ) {
- return new WP_Error(
- 'insufficient_permissions',
- __( 'You do not have permission to delete patterns.', 'pattern-builder' ),
- array( 'status' => 403 )
- );
- }
-
- $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 ) );
- }
-
- $deleted = wp_delete_post( $post->ID, true );
-
- if ( ! $deleted ) {
- return new WP_Error( 'pattern_delete_failed', 'Failed to delete pattern.', array( 'status' => 500 ) );
- }
-
- return array( 'message' => 'Pattern deleted successfully.' );
- }
-
- /**
- * 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 if not found.
- */
- public function get_pattern_filepath( $pattern ) {
- $path = $pattern->filePath ?? get_stylesheet_directory() . '/patterns/' . sanitize_file_name( basename( $pattern->name ) ) . '.php';
-
- if ( file_exists( $path ) ) {
- return $path;
- }
-
- $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 ( $matched_pattern && isset( $matched_pattern->filePath ) ) {
- return $matched_pattern->filePath;
- }
-
- return new WP_Error(
- 'pattern_file_not_found',
- __( 'Pattern file not found.', 'pattern-builder' ),
- array( 'status' => 404 )
- );
- }
-
- /**
- * 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.
- if ( ! current_user_can( 'edit_theme_options' ) ) {
- return new WP_Error(
- 'insufficient_permissions',
- __( 'You do not have permission to delete theme patterns.', 'pattern-builder' ),
- array( 'status' => 403 )
- );
- }
-
- $path = $this->get_pattern_filepath( $pattern );
-
- if ( is_wp_error( $path ) ) {
- return $path;
- }
-
- $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;
- }
-
- $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 ) );
- }
-
- 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, construct a new path.
- if ( is_wp_error( $path ) ) {
- $filename = sanitize_file_name( basename( $pattern->name ) );
- $path = get_stylesheet_directory() . '/patterns/' . $filename . '.php';
- }
-
- $formatted_content = $this->format_block_markup( $pattern->content );
- $file_content = $this->build_pattern_file_metadata( $pattern ) . $formatted_content;
-
- $allowed_dirs = array(
- get_stylesheet_directory() . '/patterns',
- get_template_directory() . '/patterns',
- );
- $response = Pattern_Builder_Security::safe_file_write( $path, $file_content, $allowed_dirs );
-
- if ( is_wp_error( $response ) ) {
- return $response;
- }
-
- return $pattern;
- }
-
- /**
- * Builds the PHP header metadata block for a pattern file.
- *
- * @param Abstract_Pattern $pattern The pattern object.
- * @return string PHP header comment string.
- */
- private function build_pattern_file_metadata( Abstract_Pattern $pattern ): string {
-
- $categories = $pattern->categories ? "\n * Categories: " . implode( ', ', $pattern->categories ) : '';
- $keywords = $pattern->keywords ? "\n * Keywords: " . implode( ', ', $pattern->keywords ) : '';
- $blockTypes = $pattern->blockTypes ? "\n * Block Types: " . implode( ', ', $pattern->blockTypes ) : '';
- $postTypes = $pattern->postTypes ? "\n * Post Types: " . implode( ', ', $pattern->postTypes ) : '';
- $templateTypes = $pattern->templateTypes ? "\n * Template Types: " . implode( ', ', $pattern->templateTypes ) : '';
- $inserter = $pattern->inserter ? '' : "\n * Inserter: no";
- $synced = $pattern->synced ? "\n * Synced: yes" : '';
-
- $metadata = "title\n";
- $metadata .= " * Slug: $pattern->name\n";
- $metadata .= " * Description: $pattern->description$categories$keywords$blockTypes$postTypes$templateTypes$inserter$synced\n";
- $metadata .= " */\n";
- $metadata .= "?>\n";
- return $metadata;
- }
-
- /**
- * Remaps wp:block blocks that reference theme patterns to wp:pattern blocks.
- *
- * @param Abstract_Pattern $pattern The pattern whose content should be remapped.
- * @return Abstract_Pattern
- */
- public function remap_patterns( Abstract_Pattern $pattern ) {
- $pattern->content = preg_replace_callback(
- '/wp:block\s+({.*})\s*\/?-->/sU',
- function ( $matches ) use ( $pattern ) {
-
- $attributes = json_decode( $matches[1], true );
-
- if ( isset( $attributes['ref'] ) ) {
-
- $pattern_post = get_post( $attributes['ref'], OBJECT );
-
- 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 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 ( $matched ) {
- unset( $attributes['ref'] );
- $attributes['slug'] = $matched->name;
- return 'wp:pattern ' . wp_json_encode( $attributes, JSON_UNESCAPED_SLASHES ) . ' /-->';
- }
- }
- }
-
- return 'wp:block ' . $matches[1] . ' /-->';
- },
- $pattern->content
- );
-
- return $pattern;
- }
-
/**
* Formats block markup for readability.
*
diff --git a/includes/class-pattern-resolver.php b/includes/class-pattern-resolver.php
new file mode 100644
index 0000000..f2a5d0b
--- /dev/null
+++ b/includes/class-pattern-resolver.php
@@ -0,0 +1,366 @@
+
+ */
+ private static $expanding = array();
+
+ /**
+ * Cheap test for markup that might contain a pattern block.
+ *
+ * Parsing every pattern and template would be wasteful on sites that do not
+ * use the feature, and every one of them would have to be parsed to find
+ * out.
+ *
+ * @param mixed $markup Block markup.
+ * @return bool Whether the markup is worth parsing.
+ */
+ public static function contains_pattern_block( $markup ): bool {
+ return is_string( $markup ) && false !== strpos( $markup, 'wp:pattern ' );
+ }
+
+ /**
+ * Composes every pattern with content in a piece of block markup.
+ *
+ * @param string $markup Block markup.
+ * @return string Block markup with those patterns composed into it, or the
+ * markup untouched if there were none.
+ */
+ public static function resolve( string $markup ): string {
+ if ( ! self::contains_pattern_block( $markup ) ) {
+ return $markup;
+ }
+
+ $expansions = self::$expansions;
+ $blocks = self::resolve_blocks( parse_blocks( $markup ) );
+
+ return self::$expansions === $expansions ? $markup : serialize_blocks( $blocks );
+ }
+
+ /**
+ * Composes every pattern with content in a list of parsed blocks.
+ *
+ * @param array[] $blocks Parsed blocks.
+ * @return array[] Parsed blocks, with those patterns composed into them.
+ */
+ public static function resolve_blocks( array $blocks ): array {
+ $resolved = array();
+
+ foreach ( $blocks as $block ) {
+ if ( ! is_array( $block ) ) {
+ continue;
+ }
+
+ foreach ( self::resolve_block( $block ) as $resolved_block ) {
+ $resolved[] = $resolved_block;
+ }
+ }
+
+ return $resolved;
+ }
+
+ /**
+ * Resolves a single parsed block.
+ *
+ * A pattern block becomes the blocks it stands for, which is why this
+ * returns a list rather than a block.
+ *
+ * @param array $block A parsed block.
+ * @return array[] The blocks that replace it.
+ */
+ private static function resolve_block( array $block ): array {
+ if ( 'core/pattern' === ( $block['blockName'] ?? null ) ) {
+ $expanded = self::expand_pattern_block( $block );
+
+ // Null means core's own resolver can take this one from here.
+ return null === $expanded ? array( $block ) : $expanded;
+ }
+
+ if ( empty( $block['innerBlocks'] ) || empty( $block['innerContent'] ) ) {
+ return array( $block );
+ }
+
+ /*
+ * `serialize_block()` walks `innerContent` and consumes one inner block
+ * for every null in it, so the two have to be rebuilt together: a
+ * pattern standing in one null slot may resolve to any number of blocks.
+ */
+ $inner_blocks = array();
+ $inner_content = array();
+ $index = 0;
+
+ foreach ( $block['innerContent'] as $chunk ) {
+ if ( is_string( $chunk ) ) {
+ $inner_content[] = $chunk;
+ continue;
+ }
+
+ if ( ! isset( $block['innerBlocks'][ $index ] ) ) {
+ continue;
+ }
+
+ $resolved = self::resolve_block( $block['innerBlocks'][ $index ] );
+ ++$index;
+
+ foreach ( $resolved as $resolved_block ) {
+ $inner_blocks[] = $resolved_block;
+ $inner_content[] = null;
+ }
+ }
+
+ $block['innerBlocks'] = $inner_blocks;
+ $block['innerContent'] = $inner_content;
+
+ return array( $block );
+ }
+
+ /**
+ * Replaces a pattern block with the pattern's blocks, content written in.
+ *
+ * A pattern block with no content of its own is still expanded when the
+ * pattern it points at reaches one that has some — otherwise core would
+ * flatten its way down to that pattern and drop the content. A pattern
+ * block that leads nowhere near any content is left for core.
+ *
+ * @param array $block A parsed `core/pattern` block.
+ * @return array[]|null The blocks that replace it, an empty array to drop
+ * it, or null to leave it to core's resolver.
+ */
+ private static function expand_pattern_block( array $block ): ?array {
+ $slug = $block['attrs']['slug'] ?? null;
+ $content = $block['attrs'][ Pattern_Block::CONTENT_ATTRIBUTE ] ?? null;
+ $registry = WP_Block_Patterns_Registry::get_instance();
+
+ if ( ! is_string( $slug ) || ! $registry->is_registered( $slug ) ) {
+ return null;
+ }
+
+ // A pattern that contains itself is dropped, the way core drops it.
+ if ( isset( self::$expanding[ $slug ] ) ) {
+ return array();
+ }
+
+ $pattern = $registry->get_registered( $slug );
+ $has_content = is_array( $content ) && ! empty( $content );
+
+ if ( ! $has_content && ! self::contains_pattern_block( $pattern['content'] ?? null ) ) {
+ return null;
+ }
+
+ $blocks = parse_blocks( $pattern['content'] );
+
+ if ( $has_content ) {
+ $blocks = self::apply_content( $blocks, $content );
+ ++self::$expansions;
+ }
+
+ $expansions = self::$expansions;
+ self::$expanding[ $slug ] = true;
+ $blocks = self::resolve_blocks( $blocks );
+ unset( self::$expanding[ $slug ] );
+
+ // Nothing inside needed this resolver, so core should expand it instead.
+ if ( ! $has_content && self::$expansions === $expansions ) {
+ return null;
+ }
+
+ return self::add_pattern_metadata( $blocks, $pattern );
+ }
+
+ /**
+ * Marks a single-block pattern as an instance of that pattern.
+ *
+ * Mirrors what core's `resolve_pattern_blocks()` does when it inlines a
+ * pattern, so a pattern expanded here still reads as a pattern instance in
+ * the editor.
+ *
+ * @param array[] $blocks The pattern's blocks.
+ * @param array $pattern The registered pattern.
+ * @return array[] The blocks.
+ */
+ private static function add_pattern_metadata( array $blocks, array $pattern ): array {
+ if ( 1 !== count( $blocks ) || empty( $pattern['name'] ) ) {
+ return $blocks;
+ }
+
+ $metadata = $blocks[0]['attrs']['metadata'] ?? array();
+ $metadata['patternName'] = $pattern['name'];
+
+ /*
+ * A block's own name wins over the pattern's title, which is the one place
+ * this departs from core's resolver. A block that names a content slot has
+ * just had that slot filled, and renaming it would throw away what it was
+ * for. Core's editor makes the same choice when it expands a pattern.
+ */
+ $values = array(
+ 'name' => $metadata['name'] ?? $pattern['title'] ?? null,
+ 'description' => $pattern['description'] ?? $metadata['description'] ?? null,
+ 'categories' => $pattern['categories'] ?? $metadata['categories'] ?? null,
+ );
+
+ foreach ( $values as $key => $value ) {
+ if ( ! $value ) {
+ continue;
+ }
+
+ $metadata[ $key ] = is_array( $value )
+ ? array_map( 'sanitize_text_field', $value )
+ : sanitize_text_field( $value );
+ }
+
+ $blocks[0]['attrs']['metadata'] = $metadata;
+
+ return $blocks;
+ }
+
+ /**
+ * Writes a pattern's content into that pattern's blocks.
+ *
+ * Every `core/pattern-overrides` binding in the tree is removed afterwards,
+ * including the ones no value was supplied for. The composed blocks are no
+ * longer inside a pattern, so a binding left behind would resolve to
+ * nothing and would only make the block read-only in the editor.
+ *
+ * @param array[] $blocks The pattern's parsed blocks.
+ * @param array $content Content, keyed by slot name and then attribute name.
+ * @return array[] The blocks with the content written into them.
+ */
+ public static function apply_content( array $blocks, array $content ): array {
+ foreach ( $blocks as $index => $block ) {
+ if ( ! is_array( $block ) ) {
+ continue;
+ }
+
+ $name = $block['attrs']['metadata']['name'] ?? null;
+ $values = ( is_string( $name ) && isset( $content[ $name ] ) && is_array( $content[ $name ] ) )
+ ? $content[ $name ]
+ : array();
+
+ $block = self::fill_slots( $block, $values );
+
+ if ( ! empty( $block['innerBlocks'] ) ) {
+ $block['innerBlocks'] = self::apply_content( $block['innerBlocks'], $content );
+ }
+
+ $blocks[ $index ] = $block;
+ }
+
+ return $blocks;
+ }
+
+ /**
+ * Writes values into one block's content slots and removes its bindings.
+ *
+ * @param array $block A parsed block.
+ * @param array $values Values for this block, keyed by attribute name.
+ * @return array The updated block.
+ */
+ private static function fill_slots( array $block, array $values ): array {
+ $bindings = $block['attrs']['metadata']['bindings'] ?? null;
+
+ if ( ! is_array( $bindings ) || empty( $bindings ) ) {
+ return $block;
+ }
+
+ $binds_everything = self::OVERRIDES_SOURCE === ( $bindings['__default']['source'] ?? null );
+ $slots = $binds_everything
+ ? self::get_supported_attributes( $block['blockName'] ?? '' )
+ : array();
+
+ foreach ( $bindings as $attribute => $binding ) {
+ if ( '__default' !== $attribute && self::OVERRIDES_SOURCE === ( $binding['source'] ?? null ) ) {
+ $slots[] = $attribute;
+ }
+ }
+
+ foreach ( array_unique( $slots ) as $attribute ) {
+ if ( array_key_exists( $attribute, $values ) ) {
+ $block = Block_Markup::set_attribute( $block, (string) $attribute, $values[ $attribute ] );
+ }
+
+ unset( $bindings[ $attribute ] );
+ }
+
+ if ( $binds_everything ) {
+ unset( $bindings['__default'] );
+ }
+
+ if ( ! empty( $bindings ) ) {
+ $block['attrs']['metadata']['bindings'] = $bindings;
+
+ return $block;
+ }
+
+ unset( $block['attrs']['metadata']['bindings'] );
+
+ if ( empty( $block['attrs']['metadata'] ) ) {
+ unset( $block['attrs']['metadata'] );
+ }
+
+ return $block;
+ }
+
+ /**
+ * Lists the attributes a block type can bind, for `__default` bindings.
+ *
+ * @param string $block_name Block type name, including namespace.
+ * @return string[] Attribute names.
+ */
+ private static function get_supported_attributes( string $block_name ): array {
+ if ( function_exists( 'get_block_bindings_supported_attributes' ) ) {
+ return get_block_bindings_supported_attributes( $block_name );
+ }
+
+ // WordPress 6.8 and earlier keep this list private to `WP_Block`.
+ $supported = array(
+ 'core/paragraph' => array( 'content' ),
+ 'core/heading' => array( 'content' ),
+ 'core/image' => array( 'id', 'url', 'title', 'alt' ),
+ 'core/button' => array( 'url', 'text', 'linkTarget', 'rel' ),
+ );
+
+ return $supported[ $block_name ] ?? array();
+ }
+}
diff --git a/includes/class-synced-patterns.php b/includes/class-synced-patterns.php
new file mode 100644
index 0000000..c2cf63c
--- /dev/null
+++ b/includes/class-synced-patterns.php
@@ -0,0 +1,183 @@
+ $slug ) ) . ' /-->';
+ }
+
+ /**
+ * Forgets the cached lookup.
+ *
+ * @return void
+ */
+ public static function flush(): void {
+ self::$slugs = null;
+
+ delete_transient( self::CACHE_KEY_PREFIX . get_stylesheet() );
+ }
+
+ /**
+ * Reads the `Synced` header from the active theme's pattern files.
+ *
+ * @return string[] Pattern slugs.
+ */
+ private static function scan_theme_patterns(): array {
+ $slugs = array();
+
+ foreach ( self::get_pattern_directories() as $directory ) {
+ $files = glob( $directory . '/*.php' );
+
+ if ( ! is_array( $files ) ) {
+ continue;
+ }
+
+ foreach ( $files as $file ) {
+ $headers = get_file_data(
+ $file,
+ array(
+ 'slug' => 'Slug',
+ 'synced' => 'Synced',
+ )
+ );
+
+ if ( ! empty( $headers['slug'] ) && self::header_means_yes( $headers['synced'] ) ) {
+ $slugs[] = $headers['slug'];
+ }
+ }
+ }
+
+ return $slugs;
+ }
+
+ /**
+ * Lists the pattern directories of the active theme and its parent.
+ *
+ * @return string[] Absolute directory paths.
+ */
+ private static function get_pattern_directories(): array {
+ $directories = array( get_stylesheet_directory() . '/patterns' );
+
+ if ( get_template_directory() !== get_stylesheet_directory() ) {
+ $directories[] = get_template_directory() . '/patterns';
+ }
+
+ return array_filter( $directories, 'is_dir' );
+ }
+
+ /**
+ * Reads a header value as a yes or a no.
+ *
+ * Accepts what a theme author is likely to write. Version 1 of this plugin
+ * documented `Synced: true` but only ever tested for `yes`.
+ *
+ * @param string $value Raw header value.
+ * @return bool Whether the header says yes.
+ */
+ private static function header_means_yes( string $value ): bool {
+ return in_array( strtolower( trim( $value ) ), array( 'yes', 'true', '1', 'on' ), true );
+ }
+}
diff --git a/package.json b/package.json
index 93ff75b..57f9ec3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "pattern-builder",
- "version": "1.0.4",
+ "version": "2.0.0",
"description": "Manage patterns in WordPress",
"author": "twentybellows",
"main": "build/index.js",
@@ -17,6 +17,9 @@
"plugin-unpack": "rm -rf ./release && mkdir -p ./release && unzip -o ./pattern-builder.zip -d ./release/pattern-builder",
"plugin-test-env": "wp-now start --blueprint=plugin-test-blueprint.json --path=./release/pattern-builder",
"plugin-test": "npm run build && npm run plugin-zip && npm run plugin-unpack && npm run plugin-test-env",
+ "plugin-ship": "./bin/ship.sh",
+ "plugin-ship:dry-run": "./bin/ship.sh --dry-run",
+ "plugin-ship:reset": "./bin/ship-reset.sh",
"start": "wp-env start --xdebug",
"stop": "wp-env stop",
"clean": "wp-env clean all",
diff --git a/pattern-builder.php b/pattern-builder.php
index bfef286..b74eaaf 100644
--- a/pattern-builder.php
+++ b/pattern-builder.php
@@ -4,9 +4,9 @@
* Plugin Name: Pattern Builder
* Plugin URI: https://www.twentybellows.com/pattern-builder/
* Description: Manage Patterns in the WordPress Editor.
- * Requires at least: 6.6
+ * Requires at least: 6.8
* Requires PHP: 7.4
- * Version: 1.0.4
+ * Version: 2.0.0
* Author: Twenty Bellows
* Author URI: https://twentybellows.com
* License: GPL-2.0-or-later
@@ -18,14 +18,16 @@
exit; // Exit if accessed directly.
}
-require_once __DIR__ . '/includes/class-pattern-builder.php';
-require_once __DIR__ . '/includes/class-pattern-builder-post-type.php'; // Loaded via class-pattern-builder.php chain; explicit here for IDE clarity.
-
-use TwentyBellows\PatternBuilder\Pattern_Builder;
-use TwentyBellows\PatternBuilder\Pattern_Builder_Post_Type;
+define( 'PATTERN_BUILDER_VERSION', '2.0.0' );
+define( 'PATTERN_BUILDER_FILE', __FILE__ );
-// Assign role capabilities on activation (not on every init).
-register_activation_hook( __FILE__, array( Pattern_Builder_Post_Type::class, 'assign_capabilities' ) );
+require_once __DIR__ . '/includes/class-pattern-builder.php';
-// Initialize the plugin.
-Pattern_Builder::get_instance();
+/*
+ * Boot on plugins_loaded: plugin directories load alphabetically, so at this
+ * file's include time the companion Synced Patterns for Themes plugin — which
+ * provides the same core/pattern runtime — has not loaded yet. By
+ * plugins_loaded every plugin has, and Pattern_Builder can decide whether to
+ * provide the runtime itself or defer to the companion.
+ */
+add_action( 'plugins_loaded', array( 'TwentyBellows\PatternBuilder\Pattern_Builder', 'get_instance' ) );
diff --git a/readme.md b/readme.md
index 358001e..cdabd13 100644
--- a/readme.md
+++ b/readme.md
@@ -7,7 +7,6 @@
### Pattern Management Made Easy
- **Unified Interface** - Manage both theme patterns and user-created patterns in one place
- **Visual Editor** - Create patterns using the familiar WordPress block editor
-- **Code Editor** - Edit pattern markup directly with syntax validation
- **Live Preview** - See your patterns in action before saving
### Powerful Organization
@@ -23,8 +22,8 @@
## Requirements
-- WordPress 6.6 or higher
-- PHP 7.2 or higher
+- WordPress 6.8 or higher
+- PHP 7.4 or higher
- Modern browser with JavaScript enabled
## Development
diff --git a/readme.txt b/readme.txt
index 943d8e8..67788e3 100644
--- a/readme.txt
+++ b/readme.txt
@@ -1,10 +1,10 @@
=== Pattern Builder ===
Contributors: twentybellows, pbking
Tags: block-patterns, patterns, block-editor, gutenberg, design
-Requires at least: 6.6
+Requires at least: 6.8
Tested up to: 6.9
-Stable tag: 1.0.4
-Requires PHP: 7.2
+Stable tag: 2.0.0
+Requires PHP: 7.4
License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -19,7 +19,6 @@ Pattern Builder transforms how you work with WordPress block patterns, providing
**Pattern Management Made Easy**
* **Unified Interface** - Manage both theme patterns and user-created patterns in one place
* **Visual Editor** - Create patterns using the familiar WordPress block editor
-* **Code Editor** - Edit pattern markup directly with syntax validation
* **Live Preview** - See your patterns in action before saving
**Powerful Organization**
@@ -92,6 +91,22 @@ Yes, Pattern Builder provides a unified interface to manage both theme patterns
== Changelog ==
+= 2.0.0 =
+* Complete architectural overhaul: theme pattern files are now the single source of truth — no more database mirror posts, no more custom post type rows, and no more interception of the /wp/v2/blocks REST API
+* New pattern browser under Appearance → Pattern Builder; every pattern opens in the WordPress editor itself — user patterns in the Site Editor, theme patterns in the core editor bound straight to the pattern file
+* Theme patterns are now real REST entities (string IDs, like core templates) at /pattern-builder/v1/patterns, editable in the post editor in place
+* Synced theme patterns now work through the core/pattern block's content attribute (the same mechanism as the Synced Patterns for Themes plugin 2.0) — inserted copies stay linked to the pattern file with per-instance overrides, and no post ID is involved anywhere
+* Full pattern metadata management: title, description, categories, keywords, block types, post types, template types, viewport width, inserter visibility, and synced status all round-trip through the pattern file header
+* Viewport Width is now preserved when editing patterns (previously lost on save)
+* Parent theme patterns are now included (previously only the child theme was scanned)
+* The block inserter now respects each theme pattern's own Inserter header (previously all theme patterns were hidden)
+* Pattern Bindings panel for naming override slots directly in the editor
+* Works alongside Synced Patterns for Themes 2.0: when both are active the companion provides the pattern runtime and Pattern Builder adds the editing tools
+* Automatic one-time migration: wp:block references to the old mirror posts are rewritten to wp:pattern references, mirror posts are removed, and the old capabilities are cleaned up
+* Fixed unauthenticated pattern deletion and unauthenticated edit-context reads (the old REST interception layer is gone entirely)
+* Performance: no database writes on page load (the old per-request pattern mirroring is gone)
+* Requires WordPress 6.8 and PHP 7.4
+
= 1.0.4 =
* Fixed issue where it prevented Post Types with custom metadata from saving
diff --git a/scripts/version-bump.js b/scripts/version-bump.js
index a25039c..ef76ae6 100755
--- a/scripts/version-bump.js
+++ b/scripts/version-bump.js
@@ -1,5 +1,7 @@
#!/usr/bin/env node
+/* eslint-disable no-console -- CLI tool; console output is its interface. */
+
const fs = require( 'fs' );
const path = require( 'path' );
diff --git a/src/PatternBuilder_Admin.js b/src/PatternBuilder_Admin.js
new file mode 100644
index 0000000..db09249
--- /dev/null
+++ b/src/PatternBuilder_Admin.js
@@ -0,0 +1,66 @@
+/**
+ * The Appearance → Pattern Builder screen. Two modes, decided by the URL's
+ * `pattern` parameter: browse (the pattern grid), and edit — the WordPress
+ * editor itself (core's edit-post package, the same editor post.php runs)
+ * bound to the `pb_pattern` entity.
+ */
+
+import domReady from '@wordpress/dom-ready';
+import { createRoot } from '@wordpress/element';
+import { registerCoreBlocks } from '@wordpress/block-library';
+
+import { PatternBuilderAdminApp } from './admin/App';
+import { bootPatternEditor } from './admin/editor-boot';
+import './admin/admin.scss';
+
+const settings = window.patternBuilderAdmin || {};
+
+/**
+ * Pins the app's bottom edge to the viewport so the browser panes scroll
+ * internally instead of the page. The container sits below whatever the
+ * admin renders above it (admin bar, notices, update nags), so its height
+ * is measured from its actual position — and re-measured when the window
+ * resizes or the content above it changes (a dismissed notice).
+ *
+ * @param {Element} el The app container.
+ */
+function lockToViewportBottom( el ) {
+ const update = () => {
+ const top = el.getBoundingClientRect().top + window.scrollY;
+ el.style.height = Math.max( 400, window.innerHeight - top ) + 'px';
+ };
+
+ update();
+ window.addEventListener( 'resize', update );
+
+ // The admin body keeps a viewport-locked height, but #wpbody-content
+ // grows and shrinks with the notices above the app.
+ if ( window.ResizeObserver ) {
+ new window.ResizeObserver( update ).observe(
+ document.getElementById( 'wpbody-content' ) || document.body
+ );
+ }
+}
+
+if ( settings.pattern ) {
+ bootPatternEditor( settings );
+} else {
+ domReady( () => {
+ const mountPoint = document.getElementById( 'pattern-builder-admin' );
+
+ if ( ! mountPoint ) {
+ return;
+ }
+
+ lockToViewportBottom( mountPoint );
+
+ // Core's editor screens do this during boot; the browse screen (which
+ // renders block previews) boots itself. The edit mode must NOT do
+ // this — initializeEditor registers core blocks on its own.
+ registerCoreBlocks();
+
+ createRoot( mountPoint ).render(
+
+ );
+ } );
+}
diff --git a/src/PatternBuilder_EditorTools.js b/src/PatternBuilder_EditorTools.js
index 995b477..b417cb0 100644
--- a/src/PatternBuilder_EditorTools.js
+++ b/src/PatternBuilder_EditorTools.js
@@ -9,7 +9,9 @@ import { registerPlugin } from '@wordpress/plugins';
import { EditorSidePanel } from './components/EditorSidePanel';
import { PatternPanelAdditionsPlugin } from './components/PatternPanelAdditions';
import { PatternSaveMonitor } from './utils/patternSaveMonitor';
-import './utils/syncedPatternFilter';
+import { registerEditPatternToolbarButton } from './components/EditPatternToolbarButton';
+
+registerEditPatternToolbarButton();
registerPlugin( 'pattern-builder-editor-side-panel', {
render: EditorSidePanel,
diff --git a/src/PatternBuilder_Runtime.js b/src/PatternBuilder_Runtime.js
new file mode 100644
index 0000000..9cca153
--- /dev/null
+++ b/src/PatternBuilder_Runtime.js
@@ -0,0 +1,14 @@
+/**
+ * The pattern runtime: teaches the editor that `core/pattern` carries content.
+ *
+ * Vendored from the Synced Patterns for Themes plugin. This bundle is only
+ * enqueued when that plugin is NOT active — when it is, its own identical
+ * runtime is the single provider and Pattern Builder defers to it.
+ */
+
+import { extendPatternOverridesSource } from './runtime/pattern-overrides-source';
+
+import './runtime/pattern-content-attribute';
+import './runtime/pattern-content-edit';
+
+extendPatternOverridesSource();
diff --git a/src/admin/App.js b/src/admin/App.js
new file mode 100644
index 0000000..19f2390
--- /dev/null
+++ b/src/admin/App.js
@@ -0,0 +1,43 @@
+import { useCallback } from '@wordpress/element';
+
+import { PatternBrowser } from './PatternBrowser';
+import { getSiteEditorUrl } from '../utils/patternNavigation';
+
+/**
+ * The Pattern Builder admin app: the pattern browser.
+ *
+ * Editing always happens in the WordPress editor — user patterns open in
+ * the Site Editor's pattern canvas, theme patterns in this same page's edit
+ * mode (`&pattern={id}`), which hosts core's edit-post editor bound to the
+ * `pb_pattern` entity.
+ *
+ * @param {Object} props Component props.
+ * @param {Object} props.settings The settings the PHP side printed.
+ */
+export function PatternBuilderAdminApp( { settings } ) {
+ const openPattern = useCallback(
+ ( pattern ) => {
+ if ( pattern.source === 'user' ) {
+ // User patterns are wp_block posts; the Site Editor edits
+ // them natively.
+ window.location.href = getSiteEditorUrl( pattern );
+ return;
+ }
+
+ const url = new URL(
+ settings.adminUrl || window.location.href,
+ window.location.href
+ );
+ url.searchParams.set( 'pattern', pattern.id );
+ window.location.href = url.toString();
+ },
+ [ settings.adminUrl ]
+ );
+
+ return (
+
+ );
+}
diff --git a/src/admin/PatternBrowser.js b/src/admin/PatternBrowser.js
new file mode 100644
index 0000000..11aa03a
--- /dev/null
+++ b/src/admin/PatternBrowser.js
@@ -0,0 +1,324 @@
+import { __, _x } from '@wordpress/i18n';
+import { useState, useEffect, useMemo, useCallback } from '@wordpress/element';
+import {
+ Button,
+ Modal,
+ SearchControl,
+ SnackbarList,
+ Spinner,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalHStack as HStack,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalHeading as Heading,
+} from '@wordpress/components';
+import { addTemplate } from '@wordpress/icons';
+import { BlockEditorProvider } from '@wordpress/block-editor';
+import { useSelect, useDispatch } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+import { store as noticesStore } from '@wordpress/notices';
+
+import { fetchAllPatterns } from '../utils/resolvers';
+import { PatternCard } from '../components/PatternCard';
+import { PatternDetailsPanel } from '../components/PatternDetailsPanel';
+import { PatternCreatePanel } from '../components/PatternCreatePanel';
+
+const ALL = 'all';
+const MINE = 'mine';
+const UNCATEGORIZED = 'uncategorized';
+
+/**
+ * The category rail: All patterns, My patterns (user-created), every
+ * category in use, and Uncategorized — each with a count, the way the Site
+ * Editor's Patterns screen lays them out.
+ *
+ * @param {Object} props Component props.
+ * @param {Array} props.categories The category descriptors.
+ * @param {string} props.active The active category slug.
+ * @param {Function} props.onSelect Called with a category slug.
+ */
+function CategoryRail( { categories, active, onSelect } ) {
+ return (
+
-
- { source === 'theme' && (
-
- { __(
- 'Theme Patterns are stored in files in your theme. They are tied to the current theme and can be exported with your theme to be used in other environments.',
- 'pattern-builder'
- ) }
-
- ) }
- { source === 'user' && (
-
- { __(
- 'User Patterns are stored in the database and can be used across themes. They are not tied to a specific theme but are only available in this environment.',
- 'pattern-builder'
- ) }
-
+
+ { isThemePattern ? (
+ <>
+
+ { __(
+ 'This is a Theme Pattern. It is stored as a file in your theme, is tied to the current theme, and can be shipped with the theme to other environments.',
+ 'pattern-builder'
+ ) }
+
+
+
+ { __(
+ 'Converting moves the pattern into the database (exporting its theme images to the media library) and deletes the theme file. The last saved version is converted.',
+ 'pattern-builder'
+ ) }
+
+ >
+ ) : (
+ <>
+
+ { __(
+ 'This is a User Pattern. It is stored in the database, works across themes, but only exists in this environment.',
+ 'pattern-builder'
+ ) }
+
+
+
+ { __(
+ 'Converting writes the pattern into a file in the active theme (importing its images as theme assets) and deletes the database copy. The last saved version is converted.',
+ 'pattern-builder'
+ ) }
+
+ >
) }
- >
+
);
};
diff --git a/src/components/PatternSyncedStatusPanel.js b/src/components/PatternSyncedStatusPanel.js
index d7f280b..57c16eb 100644
--- a/src/components/PatternSyncedStatusPanel.js
+++ b/src/components/PatternSyncedStatusPanel.js
@@ -1,4 +1,4 @@
-import { __, _x } from '@wordpress/i18n';
+import { __ } from '@wordpress/i18n';
import {
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControl as ToggleGroupControl,
@@ -8,39 +8,88 @@ import {
__experimentalText as Text,
} from '@wordpress/components';
import { dispatch } from '@wordpress/data';
-import { useState, useEffect } from 'react';
+import { useState, useEffect } from '@wordpress/element';
-export const PatternSyncedStatusPanel = ( { patternPost } ) => {
- if ( ! patternPost ) {
- return null;
- }
+/**
+ * Toggles a pattern between synced and unsynced.
+ *
+ * For a theme pattern (pb_pattern) the choice is the `synced` entity field,
+ * persisted as the pattern file's `Synced: yes` header on the next save. For
+ * a user pattern (wp_block) it is core's `wp_pattern_sync_status` meta.
+ *
+ * @param {Object} root0 Component props.
+ * @param {Object} root0.patternPost The pattern's entity record.
+ * @param {string} root0.postType The pattern's post type.
+ */
+export const PatternSyncedStatusPanel = ( { patternPost, postType } ) => {
+ const isThemePattern = postType === 'pb_pattern';
- const [ synced, setSynced ] = useState(
- patternPost.wp_pattern_sync_status === 'unsynced' ? 'false' : 'true'
- );
+ const getSyncedValue = () => {
+ if ( isThemePattern ) {
+ return patternPost.synced ? 'true' : 'false';
+ }
+
+ return patternPost.wp_pattern_sync_status === 'unsynced' ||
+ patternPost.meta?.wp_pattern_sync_status === 'unsynced'
+ ? 'false'
+ : 'true';
+ };
+
+ const [ synced, setSynced ] = useState( getSyncedValue() );
useEffect( () => {
- setSynced(
- patternPost.wp_pattern_sync_status === 'unsynced' ? 'false' : 'true'
- );
- }, [ patternPost.synced ] );
+ setSynced( getSyncedValue() );
+ }, [
+ patternPost.synced,
+ patternPost.wp_pattern_sync_status,
+ patternPost.meta?.wp_pattern_sync_status,
+ ] );
+
+ if ( ! patternPost ) {
+ return null;
+ }
const changeSyncedStatus = ( value ) => {
setSynced( value );
+
+ if ( isThemePattern ) {
+ dispatch( 'core' ).editEntityRecord(
+ 'postType',
+ 'pb_pattern',
+ patternPost.id,
+ { synced: value === 'true' }
+ );
+ return;
+ }
+
+ /*
+ * Core registers the meta with enum [partial, unsynced] — an empty
+ * string fails REST validation. Synced is the ABSENCE of the meta,
+ * and null is the REST meta API's delete.
+ */
dispatch( 'core' ).editEntityRecord(
'postType',
'wp_block',
patternPost.id,
- { wp_pattern_sync_status: value === 'true' ? '' : 'unsynced' }
+ {
+ meta: {
+ ...( patternPost.meta || {} ),
+ wp_pattern_sync_status:
+ value === 'true' ? null : 'unsynced',
+ },
+ }
);
};
return (
<>
-
+
+ { __(
+ 'Should this pattern be synced?',
+ 'pattern-builder'
+ ) }
+