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 @@ + + + + + + + + + + + + + + + Pattern + Builder + 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 ( +
    + { categories.map( ( category ) => ( +
  • + +
  • + ) ) } +
+ ); +} + +/** + * The browse screen: a category rail, a grid of uniform pattern cards, and + * a details sidebar for the selected pattern. + * + * @param {Object} props Component props. + * @param {Function} props.onEdit Called with the pattern to open its editor. + * @param {Object} props.editorSettings Block editor settings for previews. + */ +export function PatternBrowser( { onEdit, editorSettings } ) { + const [ patterns, setPatterns ] = useState( null ); + const [ search, setSearch ] = useState( '' ); + const [ category, setCategory ] = useState( ALL ); + const [ selectedId, setSelectedId ] = useState( null ); + const [ isCreateOpen, setIsCreateOpen ] = useState( false ); + + const refresh = useCallback( () => { + fetchAllPatterns() + .then( setPatterns ) + .catch( () => setPatterns( [] ) ); + }, [] ); + + useEffect( refresh, [ refresh ] ); + + // Labels for registered pattern categories; raw slugs otherwise. + const registeredCategories = useSelect( + ( select ) => select( coreStore ).getBlockPatternCategories(), + [] + ); + + const snackbarNotices = useSelect( + ( select ) => + select( noticesStore ) + .getNotices() + .filter( ( notice ) => notice.type === 'snackbar' ), + [] + ); + const { removeNotice } = useDispatch( noticesStore ); + + const categories = useMemo( () => { + const all = patterns || []; + const labelFor = ( slug ) => + ( registeredCategories || [] ).find( ( c ) => c.name === slug ) + ?.label || slug; + + const counts = {}; + let uncategorized = 0; + + all.forEach( ( pattern ) => { + const slugs = pattern.categories || []; + if ( slugs.length === 0 ) { + uncategorized++; + } + slugs.forEach( ( slug ) => { + counts[ slug ] = ( counts[ slug ] || 0 ) + 1; + } ); + } ); + + const rail = [ + { + slug: ALL, + label: __( 'All patterns', 'pattern-builder' ), + count: all.length, + }, + { + slug: MINE, + label: __( 'My patterns', 'pattern-builder' ), + count: all.filter( ( p ) => p.source === 'user' ).length, + }, + ...Object.keys( counts ) + .sort( ( a, b ) => + labelFor( a ).localeCompare( labelFor( b ) ) + ) + .map( ( slug ) => ( { + slug, + label: labelFor( slug ), + count: counts[ slug ], + } ) ), + ]; + + if ( uncategorized > 0 ) { + rail.push( { + slug: UNCATEGORIZED, + label: __( 'Uncategorized', 'pattern-builder' ), + count: uncategorized, + } ); + } + + return rail; + }, [ patterns, registeredCategories ] ); + + const filteredPatterns = useMemo( () => { + if ( ! patterns ) { + return []; + } + + const term = search.trim().toLowerCase(); + + return patterns.filter( ( pattern ) => { + if ( category === MINE && pattern.source !== 'user' ) { + return false; + } + + if ( + category === UNCATEGORIZED && + ( pattern.categories || [] ).length > 0 + ) { + return false; + } + + if ( + category !== ALL && + category !== MINE && + category !== UNCATEGORIZED && + ! ( pattern.categories || [] ).includes( category ) + ) { + return false; + } + + if ( ! term ) { + return true; + } + + const haystack = [ + pattern.title, + pattern.name, + pattern.description, + ...( pattern.keywords || [] ), + ...( pattern.categories || [] ), + ] + .join( ' ' ) + .toLowerCase(); + + return haystack.includes( term ); + } ); + }, [ patterns, search, category ] ); + + const selectedPattern = useMemo( + () => + ( patterns || [] ).find( + ( pattern ) => pattern.id === selectedId + ) || null, + [ patterns, selectedId ] + ); + + if ( null === patterns ) { + return ( +
+ +
+ ); + } + + return ( + /* + * The provider gives the preview iframes real editor settings — theme + * styles, and the block-bindings attribute map the preview needs to + * render blocks with `__default` bindings. + */ + +
+ + +
+ + + + + + { filteredPatterns.length === 0 && ( +

{ __( 'No patterns found.', 'pattern-builder' ) }

+ ) } + +
+ { filteredPatterns.map( ( pattern ) => ( + + setSelectedId( + selected.id === selectedId + ? null + : selected.id + ) + } + /> + ) ) } +
+
+ + { selectedPattern && ( + + ) } + + { isCreateOpen && ( + setIsCreateOpen( false ) } + className="pattern-builder-browser__create-modal" + > + + + ) } + + +
+
+ ); +} diff --git a/src/admin/admin.scss b/src/admin/admin.scss new file mode 100644 index 0000000..e9d61e7 --- /dev/null +++ b/src/admin/admin.scss @@ -0,0 +1,238 @@ +// The Appearance → Pattern Builder screen — browse mode only. Edit mode +// hands the whole page to core's edit-post editor, which brings its own +// styles. + +// Nothing renders below the app — the page must not scroll; the browser +// panes scroll internally. +body.appearance_page_pattern-builder { + + #wpfooter { + display: none; + } + + #wpbody-content { + padding-bottom: 0; + } +} + +.pattern-builder-admin { + // Fill the admin content area edge to edge. + margin-left: -20px; + // Fallback height; the boot script measures the real offset (admin + // bar, notices) and pins the bottom edge to the viewport. + height: calc(100vh - 32px); + background: #fff; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.pattern-builder-browser { + display: flex; + flex: 1; + min-height: 0; + + &__loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + flex: 1; + } + + // ---- Category rail. ---- + + &__sidebar { + flex: 0 0 240px; + border-right: 1px solid #ddd; + overflow-y: auto; + padding: 16px 12px; + background: #f8f8f8; + } + + &__title { + padding: 8px 12px 16px; + } + + &__categories { + list-style: none; + margin: 0; + padding: 0; + } + + &__category { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + border: 0; + border-radius: 4px; + background: transparent; + font-size: 13px; + text-align: left; + cursor: pointer; + color: #1e1e1e; + + &:hover { + background: #f0f0f0; + color: var(--wp-admin-theme-color, #3858e9); + } + + &.is-active { + background: #1e1e1e; + color: #fff; + } + } + + &__category-count { + opacity: 0.7; + } + + // ---- Grid. ---- + + &__main { + flex: 1; + min-width: 0; + overflow-y: auto; + padding: 24px 32px; + } + + &__toolbar { + margin-bottom: 24px; + } + + &__search { + min-width: 280px; + flex: 1; + max-width: 480px; + } + + &__grid { + display: grid; + // Small base size so plenty of columns fit (three across even with + // the details sidebar open); cards stretch to fill the row. + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 24px; + align-items: start; + } + + // ---- Details sidebar. ---- + + &__details { + flex: 0 0 320px; + border-left: 1px solid #ddd; + overflow: hidden; + display: flex; + flex-direction: column; + background: #fff; + } + + &__create-modal .components-modal__content { + min-width: 360px; + } + + &__snackbars { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 100; + } +} + +// ---- Pattern cards: uniform 1:1 previews. ---- + +.pattern-builder-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0; + border: 0; + background: transparent; + text-align: left; + cursor: pointer; + + &__preview { + display: block; + aspect-ratio: 1 / 1; + border: 1px solid #ddd; + border-radius: 8px; + overflow: hidden; + background: #fff; + position: relative; + + // The preview scales by viewport width and reports its own height; + // the square crops it. + > div { + width: 100%; + } + } + + &:hover &__preview { + border-color: var(--wp-admin-theme-color, #3858e9); + } + + &.is-selected &__preview { + border-color: var(--wp-admin-theme-color, #3858e9); + box-shadow: 0 0 0 1.5px var(--wp-admin-theme-color, #3858e9); + } + + &__placeholder { + display: block; + width: 100%; + height: 100%; + background: #f0f0f0; + } + + &__footer { + display: flex; + flex-direction: column; + gap: 2px; + padding: 0 4px; + min-width: 0; + } +} + +// ---- Details panel. ---- + +.pattern-builder-details { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + + &__header { + padding: 16px; + border-bottom: 1px solid #ddd; + } + + &__panels { + flex: 1; + overflow-y: auto; + min-height: 0; + + .components-panel__body { + border-left: 0; + border-right: 0; + } + } + + &__loading { + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + } + + &__actions { + padding: 16px; + border-top: 1px solid #ddd; + } + + &__action-button { + width: 100%; + justify-content: center; + } +} diff --git a/src/admin/editor-boot.js b/src/admin/editor-boot.js new file mode 100644 index 0000000..1deaa1b --- /dev/null +++ b/src/admin/editor-boot.js @@ -0,0 +1,85 @@ +/** + * Boots the WordPress editor — core's `@wordpress/edit-post` package, the + * same editor that powers post.php — bound to a `pb_pattern` entity, so a + * theme pattern gets the full core editing experience (header, list view, + * inspector, document panels, keyboard shortcuts) and its saves go straight + * to the pattern file. + */ + +import domReady from '@wordpress/dom-ready'; +import { registerPlugin } from '@wordpress/plugins'; +import { Button } from '@wordpress/components'; +import { __, isRTL } from '@wordpress/i18n'; +import { chevronLeft, chevronRight } from '@wordpress/icons'; +import { + initializeEditor, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalMainDashboardButton as MainDashboardButton, +} from '@wordpress/edit-post'; + +/** + * The editor assumes it lives at post.php and rewrites the address bar to + * `post.php?post={id}` as it settles (its BrowserURL component). Theme + * patterns have string ids post.php can't load, and this page's own URL is + * the shareable one — so those rewrites are dropped. + */ +function keepPageUrl() { + const original = window.history.replaceState.bind( window.history ); + + window.history.replaceState = ( state, title, url ) => { + if ( typeof url === 'string' && url.startsWith( 'post.php' ) ) { + return; + } + + return original( state, title, url ); + }; +} + +/** + * The fullscreen-mode close button. The editor's default links to the post + * type's list table, which a rowless type doesn't have — this one returns + * to wherever the user came from (the Site Editor, the Pattern Builder + * browse screen, …; the URL is validated server-side). + * + * @param {Object} props Component props. + * @param {string} props.url The URL to go back to. + */ +function BackButton( { url } ) { + return ( + +

{ __( @@ -126,4 +146,4 @@ export const PatternBuilderConfiguration = () => { ); -}; \ No newline at end of file +}; diff --git a/src/components/PatternCard.js b/src/components/PatternCard.js new file mode 100644 index 0000000..2f7d100 --- /dev/null +++ b/src/components/PatternCard.js @@ -0,0 +1,62 @@ +import { BlockPreview } from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; +import { + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalText as Text, +} from '@wordpress/components'; + +/** + * A browse-grid pattern card: a uniform 1:1 preview with the title beneath. + * Clicking selects the pattern (details live in the sidebar) — the card + * itself carries no actions. + * + * @param {Object} props Component props. + * @param {Object} props.pattern The pattern. + * @param {boolean} props.isSelected Whether the card is selected. + * @param {Function} props.onSelect Called with the pattern on click. + */ +export const PatternCard = ( { pattern, isSelected, onSelect } ) => { + const blocks = pattern.getBlocks(); + + const badges = [ + pattern.source === 'theme' + ? __( 'Theme', 'pattern-builder' ) + : __( 'User', 'pattern-builder' ), + ]; + + if ( ! pattern.synced ) { + badges.push( __( 'Not synced', 'pattern-builder' ) ); + } + + return ( + + ); +}; diff --git a/src/components/PatternCreatePanel.js b/src/components/PatternCreatePanel.js index bf27049..947db25 100644 --- a/src/components/PatternCreatePanel.js +++ b/src/components/PatternCreatePanel.js @@ -1,12 +1,14 @@ /** * WordPress dependencies */ -import { __, _x } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; import apiFetch from '@wordpress/api-fetch'; import { useState } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; -import { TextControl, TextareaControl, Button } from '@wordpress/components'; import { + TextControl, + TextareaControl, + Button, // eslint-disable-next-line @wordpress/no-unsafe-wp-apis __experimentalVStack as VStack, // eslint-disable-next-line @wordpress/no-unsafe-wp-apis @@ -19,7 +21,9 @@ import { import { addTemplate } from '@wordpress/icons'; import { store as blockEditorStore } from '@wordpress/block-editor'; -export const PatternCreatePanel = () => { +import { navigateToPattern } from '../utils/patternNavigation'; + +export const PatternCreatePanel = ( { onCreated } ) => { const { onNavigateToEntityRecord } = useSelect( ( select ) => { const { getSettings } = select( blockEditorStore ); return { @@ -30,12 +34,20 @@ export const PatternCreatePanel = () => { const createPattern = () => { createPatternCall( newPatternOptions ) .then( ( pattern ) => { - onNavigateToEntityRecord( { - postId: pattern.id, - postType: 'wp_block', - } ); + if ( onCreated ) { + onCreated( pattern ); + } + navigateToPattern( + { + id: pattern.id, + name: pattern.name, + source: pattern.source || 'user', + }, + onNavigateToEntityRecord + ); } ) .catch( ( error ) => { + // eslint-disable-next-line no-console console.error( 'Error creating pattern:', error ); } ); }; @@ -49,8 +61,27 @@ export const PatternCreatePanel = () => { } ); const createPatternCall = ( pattern ) => { + if ( pattern.source === 'theme' ) { + // Theme patterns are file-backed pb_pattern entities. + return apiFetch( { + path: '/pattern-builder/v1/patterns', + method: 'POST', + data: { + title: pattern.title, + description: pattern.description, + synced: pattern.synced, + }, + } ); + } + + const body = { + title: pattern.title, + excerpt: pattern.description, + status: 'publish', + }; + if ( ! pattern.synced ) { - pattern.meta = { + body.meta = { wp_pattern_sync_status: 'unsynced', }; } @@ -58,10 +89,7 @@ export const PatternCreatePanel = () => { return apiFetch( { path: '/wp/v2/blocks', method: 'POST', - body: JSON.stringify( pattern ), - headers: { - 'Content-Type': 'application/json', - }, + data: body, } ); }; return ( @@ -96,9 +124,9 @@ export const PatternCreatePanel = () => { /> <>

-