| 1 |
<?php |
| 2 |
/** |
| 3 |
* Block Patterns module (spec 051-block-patterns). |
| 4 |
* |
| 5 |
* Exposes the Templately Gutenberg catalog as native WordPress block patterns: |
| 6 |
* plan-keyed remote sync into a local cache (transient list + inert content |
| 7 |
* files), metadata-only registration on guarded `init` with WP 6.5 filePath |
| 8 |
* lazy content, start-page/post pattern keys for the native new-page chooser, |
| 9 |
* a save-time media localizer with persistent source-URL dedup, and a |
| 10 |
* cache-fed dashboard widget. v1 is connected-sites-only and WP >= 6.5 only |
| 11 |
* (spec 051 Clarifications, 2026-07-28). Design decisions: specs/051-block- |
| 12 |
* patterns/research.md (D1-D9). |
| 13 |
* |
| 14 |
* @package Templately |
| 15 |
*/ |
| 16 |
|
| 17 |
namespace Templately\Modules\BlockPatterns; |
| 18 |
|
| 19 |
use Templately\Core\Capabilities; |
| 20 |
use Templately\Core\Module_Base; |
| 21 |
use Templately\Modules\BlockPatterns\Cleanup\PatternCacheTask; |
| 22 |
use Templately\Modules\BlockPatterns\REST\Content as ContentEndpoint; |
| 23 |
use Templately\Modules\BlockPatterns\REST\Search as SearchEndpoint; |
| 24 |
use Templately\Modules\BlockPatterns\REST\Sync as SyncEndpoint; |
| 25 |
|
| 26 |
class Module extends Module_Base { |
| 27 |
|
| 28 |
public function get_name(): string { |
| 29 |
return 'block-patterns'; |
| 30 |
} |
| 31 |
|
| 32 |
/** |
| 33 |
* WP >= 6.5 only: filePath lazy pattern content (the whole registration |
| 34 |
* design) does not exist below 6.5, and v1 ships no inline fallback |
| 35 |
* (research D8 / spec FR-006). |
| 36 |
* |
| 37 |
* Since spec 053 the check is the `pattern-file-path` capability. Its |
| 38 |
* declaration (Capability_Seed) still routes the version through the |
| 39 |
* legacy `templately_block_patterns_wp_version` filter, so existing tests |
| 40 |
* and third-party code keep working; the documented override going |
| 41 |
* forward is `templately_capability_pattern-file-path`. |
| 42 |
*/ |
| 43 |
public function is_active(): bool { |
| 44 |
return Capabilities::get_instance()->has( 'pattern-file-path' ); |
| 45 |
} |
| 46 |
|
| 47 |
public function get_dependencies(): array { |
| 48 |
// full-site-import: Utils::import_and_replace_attachments (MediaLocalizer); |
| 49 |
// gutenberg-integration: registers the gutenberg json helper that util resolves |
| 50 |
// through the templately_fsi_json_helper seam; |
| 51 |
// utilities: the guarded remover this module's cache-clear deletes through. |
| 52 |
return [ 'full-site-import', 'gutenberg-integration', 'utilities' ]; |
| 53 |
} |
| 54 |
|
| 55 |
protected function init_hooks(): void { |
| 56 |
// Manual-only contribution to the shared cleanup service (spec 052): a |
| 57 |
// "clear the pattern library" action for the Utilities tab. Never |
| 58 |
// scheduled — every cleared pattern is re-fetched from the cloud. |
| 59 |
PatternCacheTask::register(); |
| 60 |
|
| 61 |
// REGISTER ON `init`, LIKE CORE DOES — and never on any later action. |
| 62 |
// |
| 63 |
// This used to run on `rest_api_init` to keep the catalog out of the |
| 64 |
// editor's bootstrap payload. It achieved the exact opposite. WordPress |
| 65 |
// keeps a SECOND copy of every pattern registered outside `init`: |
| 66 |
// |
| 67 |
// // class-wp-block-patterns-registry.php |
| 68 |
// if ( current_action() && 'init' !== current_action() ) { |
| 69 |
// $this->registered_patterns_outside_init[ $pattern_name ] = $pattern; |
| 70 |
// } |
| 71 |
// |
| 72 |
// and `wp-admin/edit-form-blocks.php` inlines that bucket wholesale into |
| 73 |
// the page as `__experimentalAdditionalBlockPatterns`. Core keeps it only |
| 74 |
// to detect DEPRECATED registrations from `admin_init`/`current_screen` — |
| 75 |
// its own comment says so — and we were feeding it as our main path. |
| 76 |
// Measured on a connected site: 100 of 100 of our patterns in that bucket, |
| 77 |
// 87,630 bytes inlined into every single editor page load. |
| 78 |
// |
| 79 |
// Registered on `init` we are in the ordinary registry only, which nothing |
| 80 |
// inlines, and core-data's `getBlockPatterns` resolver fetches |
| 81 |
// `/wp/v2/block-patterns/patterns` when the editor actually wants patterns. |
| 82 |
// |
| 83 |
// The original fear — that `init` registration forces content resolution — |
| 84 |
// belonged to the eager `filePath` era, when one pattern averaged ~50KB and |
| 85 |
// the whole catalog inlined megabytes. In lazy mode a pattern's content IS |
| 86 |
// the ~865-byte placeholder, passed inline, so there is no file to resolve |
| 87 |
// and nothing to defer. |
| 88 |
// |
| 89 |
// PRIORITY 20 IS LOAD-BEARING. Core registers its own pattern categories on |
| 90 |
// `init` at the default 10 (`_register_core_block_patterns_and_categories`, |
| 91 |
// `_register_theme_block_patterns`). `PatternRegistrar::core_category_for()` |
| 92 |
// asks the live registry whether a core equivalent exists, so running before |
| 93 |
// core would find none and file every design into a parallel `templately-*` |
| 94 |
// bucket instead of core's "Call to action", "Header" and friends. |
| 95 |
add_action( 'init', function () { |
| 96 |
PatternRegistrar::get_instance()->register_all(); |
| 97 |
}, 20 ); |
| 98 |
|
| 99 |
// Cron: self-chaining sync events (research D5). |
| 100 |
add_action( PatternSync::EVENT_SYNC_LIST, [ PatternSync::get_instance(), 'sync_list' ] ); |
| 101 |
add_action( PatternSync::EVENT_SYNC_CONTENT, [ PatternSync::get_instance(), 'sync_content_batch' ] ); |
| 102 |
|
| 103 |
// Plan/connection boundary invalidation: the stored account lives in |
| 104 |
// `_templately_user` user meta (written via update_user_option on login/ |
| 105 |
// logout/plan refresh) — deleting the plan-keyed caches on any write to it |
| 106 |
// covers connect, disconnect and plan change (plan.md cross-cutting rules). |
| 107 |
$invalidate = function ( $meta_id, $object_id, $meta_key ) { |
| 108 |
PatternSync::get_instance()->maybe_invalidate_for_meta( (string) $meta_key ); |
| 109 |
}; |
| 110 |
add_action( 'updated_user_meta', $invalidate, 10, 3 ); |
| 111 |
add_action( 'added_user_meta', $invalidate, 10, 3 ); |
| 112 |
add_action( 'deleted_user_meta', $invalidate, 10, 3 ); |
| 113 |
|
| 114 |
// Save-time media localization (research D6). |
| 115 |
add_action( 'wp_after_insert_post', [ MediaLocalizer::get_instance(), 'maybe_localize' ], 10, 2 ); |
| 116 |
add_action( MediaLocalizer::EVENT_LOCALIZE_REST, [ MediaLocalizer::get_instance(), 'localize_deferred' ] ); |
| 117 |
|
| 118 |
// Media is imported when a pattern is INSERTED, so a discarded draft would |
| 119 |
// otherwise leave its images orphaned in the library. |
| 120 |
add_action( 'before_delete_post', [ MediaLocalizer::get_instance(), 'cleanup_for_post' ] ); |
| 121 |
|
| 122 |
// Dashboard widget (research D9: server-rendered, cache-only). |
| 123 |
add_action( 'wp_dashboard_setup', [ DashboardWidget::get_instance(), 'register_widget' ] ); |
| 124 |
|
| 125 |
// First sync: schedule (never fetch inline) when no list has ever synced. |
| 126 |
add_action( 'admin_init', [ PatternSync::get_instance(), 'ensure_scheduled' ] ); |
| 127 |
|
| 128 |
// The lazy-pattern placeholder block. Registered server-side so the block |
| 129 |
// type exists for parsing/validation; its editor UI ships in this module's |
| 130 |
// JS, which compiles into the `templately` bundle via the boot manifest |
| 131 |
// (react-src/generated/moduleManifest.ts) and rides that bundle's |
| 132 |
// unconditional enqueue in Admin::enqueue_scripts() onto the editor screen. |
| 133 |
// That is the same reach the retired enqueue_module_assets() glob had. |
| 134 |
add_action( 'init', function () { |
| 135 |
if ( ! PatternRegistrar::lazy_mode() ) { |
| 136 |
return; |
| 137 |
} |
| 138 |
if ( ! \WP_Block_Type_Registry::get_instance()->is_registered( 'templately/lazy-pattern' ) ) { |
| 139 |
register_block_type( 'templately/lazy-pattern', [ |
| 140 |
'api_version' => 3, |
| 141 |
'attributes' => [ |
| 142 |
'patternId' => [ 'type' => 'number', 'default' => 0 ], |
| 143 |
'previewUrl' => [ 'type' => 'string', 'default' => '' ], |
| 144 |
'title' => [ 'type' => 'string', 'default' => '' ], |
| 145 |
'kind' => [ 'type' => 'string', 'default' => '' ], |
| 146 |
], |
| 147 |
// Never renders on the front end: on the canvas it replaces |
| 148 |
// itself with the real blocks, so it cannot reach saved content. |
| 149 |
'render_callback' => '__return_empty_string', |
| 150 |
] ); |
| 151 |
} |
| 152 |
}, 5 ); |
| 153 |
|
| 154 |
// The placeholder renders inside the editor CANVAS IFRAME, which the |
| 155 |
// module's admin stylesheet never reaches — enqueue_block_assets is the |
| 156 |
// hook WordPress mirrors into that iframe, so the loading/error state |
| 157 |
// keeps its Templately styling instead of rendering unstyled. |
| 158 |
add_action( 'enqueue_block_assets', function () { |
| 159 |
if ( ! is_admin() || ! PatternRegistrar::lazy_mode() ) { |
| 160 |
return; |
| 161 |
} |
| 162 |
|
| 163 |
// Emitted by this module's OWN stylesheet entry, not by the retired module-CSS glob |
| 164 |
// that used to write assets/css/modules/style-block-patterns.css. Every module's |
| 165 |
// JS-imported CSS now lands in style-templately.css, which is enqueued on the admin |
| 166 |
// page and never reaches the canvas iframe — hence the separate entry. The |
| 167 |
// `style-` prefix is @wordpress/scripts': a source file named `style.scss` emits |
| 168 |
// `style-{entry}.css`. See webpack.config.partial.js. |
| 169 |
$relative = 'assets/css/style-block-patterns.css'; |
| 170 |
if ( ! file_exists( TEMPLATELY_PATH . $relative ) ) { |
| 171 |
return; |
| 172 |
} |
| 173 |
|
| 174 |
wp_enqueue_style( |
| 175 |
'templately-block-patterns', |
| 176 |
TEMPLATELY_URL . $relative, |
| 177 |
[], |
| 178 |
(string) filemtime( TEMPLATELY_PATH . $relative ) |
| 179 |
); |
| 180 |
} ); |
| 181 |
} |
| 182 |
|
| 183 |
public function register_rest_routes(): void { |
| 184 |
ContentEndpoint::get_instance()->register_routes(); |
| 185 |
SyncEndpoint::get_instance()->register_routes(); |
| 186 |
SearchEndpoint::get_instance()->register_routes(); |
| 187 |
} |
| 188 |
} |
| 189 |
|