PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / trunk
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! vtrunk
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / modules / block-patterns / PatternRegistrar.php

PatternRegistrar.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! trunk, at modules/block-patterns/PatternRegistrar.php

405 lines 14.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Templately\Modules\BlockPatterns;
4
5 use Templately\Utils\Base;
6 use Templately\Utils\Helper;
7
8 /**
9 * Registers cached, plan-permitted catalog items as native block patterns.
10 * Metadata-only from the local cache; content resolves lazily through the
11 * registry's filePath support (WP 6.5+ — the module's is_active() floor).
12 * Zero HTTP on this path, ever (spec 051 FR-002/SC-003; research D1).
13 */
14 class PatternRegistrar extends Base {
15
16 /** The one flat category every Templately pattern also belongs to. */
17 const BRAND_CATEGORY = 'templately';
18
19 /**
20 * Cumulative pattern-content budget, in bytes.
21 *
22 * Registered patterns are NOT free at page load: WordPress preloads the
23 * patterns REST response into the editor, and building it resolves every
24 * registered pattern's content — `filePath` defers the read, it does not
25 * avoid it. Templately designs are large (a real catalog averages ~50KB per
26 * pattern, full-page templates far more), so an unbounded catalog turned
27 * post-new.php into a 13MB, 6-second page. Registration stops once this much
28 * content has been committed, newest-first.
29 */
30 const CONTENT_BUDGET_BYTES = 1500000;
31
32 /**
33 * Register every registrable cached item + its category. Safe to call
34 * repeatedly (idempotent) and safe against corrupt cache entries — one bad
35 * item is skipped and logged, never fatal (FR-011).
36 */
37 public function register_all(): void {
38 $sync = PatternSync::get_instance();
39
40 if ( null === $sync->plan_key() ) {
41 return; // v1: connected sites only.
42 }
43
44 $list = $sync->get_list();
45 if ( null === $list || empty( $list['items'] ) ) {
46 return;
47 }
48
49 // Lazy patterns carry a few hundred bytes each, so the budget that exists
50 // to keep the editor page small has nothing to protect against — the whole
51 // catalog can register.
52 $budget = self::lazy_mode()
53 ? PHP_INT_MAX
54 : (int) apply_filters( 'templately_block_patterns_content_budget', self::CONTENT_BUDGET_BYTES );
55 $spent = 0;
56 $capped = 0;
57
58 foreach ( $list['items'] as $item ) {
59 try {
60 $cost = $this->content_size( $item, $sync );
61
62 // Always allow the first pattern through, so a single oversized
63 // design cannot leave the library looking empty.
64 if ( $spent > 0 && ( $spent + $cost ) > $budget ) {
65 $capped++;
66 continue;
67 }
68
69 if ( $this->register_item( $item, $sync ) ) {
70 $spent += $cost;
71 }
72 } catch ( \Throwable $e ) {
73 Helper::log( 'block-patterns: skipped corrupt cache entry — ' . $e->getMessage() );
74 }
75 }
76
77 if ( $capped > 0 ) {
78 // Never silently truncate — say what was dropped and why.
79 Helper::log( sprintf(
80 'block-patterns: registered %s bytes of pattern content; %d pattern(s) held back by the %s-byte budget.',
81 number_format_i18n( $spent ),
82 $capped,
83 number_format_i18n( $budget )
84 ) );
85 }
86 }
87
88 /**
89 * Whether patterns register as lazy placeholders (thumbnail preview, content
90 * fetched on insert) rather than full markup.
91 *
92 * The eager path is kept intact behind this switch while the lazy one is
93 * being proven — flip to false to get the original behaviour back.
94 */
95 public static function lazy_mode(): bool {
96 return (bool) apply_filters( 'templately_block_patterns_lazy', true );
97 }
98
99 /**
100 * The placeholder markup a lazy pattern is made of: one templately/lazy-pattern
101 * block carrying the id and thumbnail. It never persists — on the canvas the
102 * block replaces itself with the real pattern.
103 */
104 private function lazy_content( array $item ): string {
105 $attrs = [
106 'patternId' => absint( $item['id'] ),
107 'previewUrl' => esc_url_raw( $item['preview_url'] ?? '' ),
108 'title' => sanitize_text_field( $item['title'] ),
109 // Drives the preview crop, and only that. Cloud thumbnails are a fixed
110 // 900x1030 branded card: a page screenshot fills it, but a SECTION is a
111 // centred strip on a solid background — a header occupies ~7% of the
112 // height, so an uncropped tile is mostly padding.
113 'kind' => sanitize_key( $item['kind'] ?? '' ),
114 ];
115
116 return '<!-- wp:templately/lazy-pattern ' . wp_json_encode( $attrs ) . ' /-->';
117 }
118
119 /**
120 * Search keywords. `keywords` is a real scoring field in the inserter's
121 * ranking (alongside name/title/description/category), so the catalog's own
122 * tags belong here — without them a search for "restaurant" cannot match a
123 * pattern tagged exactly that.
124 *
125 * @return string[]
126 */
127 private function keywords_for( array $item ): array {
128 $keywords = [ 'templately' ];
129
130 foreach ( (array) ( $item['tags'] ?? [] ) as $tag ) {
131 $tag = sanitize_text_field( (string) $tag );
132 if ( '' !== $tag ) {
133 $keywords[] = $tag;
134 }
135 }
136
137 if ( ! empty( $item['category'] ) ) {
138 $keywords[] = str_replace( '-', ' ', sanitize_key( $item['category'] ) );
139 }
140
141 return array_values( array_unique( $keywords ) );
142 }
143
144 private function content_size( $item, PatternSync $sync ): int {
145 if ( ! is_array( $item ) || empty( $item['id'] ) ) {
146 return 0;
147 }
148
149 $path = $sync->content_path( absint( $item['id'] ) );
150
151 return file_exists( $path ) ? (int) filesize( $path ) : 0;
152 }
153
154 /**
155 * The pattern definition for one catalog item, in the shape the editor holds.
156 *
157 * Extracted from register_item() so a LIVE-SEARCHED item — which is never
158 * registered server-side, only injected into the editor's settings — is
159 * described by exactly the same code. Two copies of this would drift, and the
160 * drift would show up as search results that look or behave subtly unlike the
161 * registered catalog.
162 *
163 * @param array $item Catalog Item per data-model.md.
164 * @return array{name:string, title:string, categories:string[], keywords:string[], content:string, viewportWidth:int, description:string}
165 */
166 public function describe_item( array $item ): array {
167 $id = absint( $item['id'] );
168 $category = ! empty( $item['category'] ) ? sanitize_key( $item['category'] ) : 'general';
169
170 $pattern = [
171 'name' => "templately/{$id}",
172 'title' => sanitize_text_field( $item['title'] ),
173 'categories' => $this->categories_for( $category ),
174 'keywords' => $this->keywords_for( $item ),
175 'viewportWidth' => 1200,
176 'description' => sprintf(
177 /* translators: %s: pattern title */
178 __( '%s — from the Templately library', 'templately' ),
179 sanitize_text_field( $item['title'] )
180 ),
181 'content' => $this->lazy_content( $item ),
182 ];
183
184 $kind = $item['kind'] ?? 'section';
185 if ( 'page' === $kind || 'post' === $kind ) {
186 $pattern['blockTypes'] = [ 'core/post-content' ];
187 $pattern['postTypes'] = [ $kind ];
188 }
189
190 return $pattern;
191 }
192
193 /**
194 * @param array $item Catalog Item per data-model.md.
195 * @return bool Whether a new pattern was registered by this call.
196 */
197 private function register_item( $item, PatternSync $sync ): bool {
198 if ( ! is_array( $item ) || empty( $item['id'] ) || empty( $item['title'] ) ) {
199 return false;
200 }
201
202 $id = absint( $item['id'] );
203 $lazy = self::lazy_mode();
204
205 // Eager mode needs the markup on disk; lazy mode needs nothing but a
206 // thumbnail, so the whole catalog is registrable.
207 $file_path = $sync->content_path( $id );
208 if ( ! $lazy && ! file_exists( $file_path ) ) {
209 return false; // content not synced yet — appears once the top-up runner lands it
210 }
211
212 $name = "templately/{$id}";
213 if ( \WP_Block_Patterns_Registry::get_instance()->is_registered( $name ) ) {
214 return false;
215 }
216
217 $category = ! empty( $item['category'] ) ? sanitize_key( $item['category'] ) : 'general';
218
219 $pattern = [
220 'title' => sanitize_text_field( $item['title'] ),
221 'categories' => $this->categories_for( $category ),
222 'keywords' => $this->keywords_for( $item ),
223 'viewportWidth' => 1200,
224 'description' => sprintf(
225 /* translators: %s: pattern title */
226 __( '%s — from the Templately library', 'templately' ),
227 sanitize_text_field( $item['title'] )
228 ),
229 ];
230
231 if ( $lazy ) {
232 // A few hundred bytes instead of ~67KB: the inserter renders the
233 // thumbnail, and the real markup is fetched only when inserted.
234 $pattern['content'] = $this->lazy_content( $item );
235 } else {
236 $pattern['filePath'] = $file_path;
237 }
238
239 // Start-page/post chooser keys (spec 051 FR-005; research D7): page and
240 // post template items surface in WordPress's native "choose a pattern"
241 // modal for exactly their post type; sections carry neither key.
242 $kind = $item['kind'] ?? 'section';
243 if ( 'page' === $kind || 'post' === $kind ) {
244 $pattern['blockTypes'] = [ 'core/post-content' ];
245 $pattern['postTypes'] = [ $kind ];
246 }
247
248 return register_block_pattern( $name, $pattern );
249 }
250
251 /**
252 * Categories a pattern is filed under.
253 *
254 * TWO of them, deliberately (UI review 2026-07-28):
255 *
256 * 1. The matching CORE category when one exists — core already ships
257 * `call-to-action`, `banner`, `header`, `testimonials` and friends. Filing
258 * only into a parallel `templately-*` bucket meant a user browsing core's
259 * "Call to action" saw none of ours, which defeats the point of being in
260 * the native inserter at all.
261 * 2. One flat `templately` category, so the library is still browsable as a
262 * set and the brand is visible in the sidebar.
263 *
264 * A catalog slug with no core equivalent falls back to its own
265 * `templately-{slug}` category so nothing is silently swallowed.
266 *
267 * @return string[]
268 */
269 private function categories_for( string $category ): array {
270 $this->ensure_brand_category();
271
272 $categories = [ self::BRAND_CATEGORY ];
273
274 $core = $this->core_category_for( $category );
275 if ( null !== $core ) {
276 $categories[] = $core;
277
278 return $categories;
279 }
280
281 $own = "templately-{$category}";
282 $this->ensure_own_category( $own, $category );
283 $categories[] = $own;
284
285 return $categories;
286 }
287
288 /**
289 * Map a catalog category slug onto a core pattern category, when core has an
290 * equivalent. Checked against the live registry rather than a hardcoded list
291 * so a theme-registered category counts too.
292 */
293 private function core_category_for( string $category ): ?string {
294 $registry = \WP_Block_Pattern_Categories_Registry::get_instance();
295
296 // Keys are the slugs the LIVE catalog actually returns (verified against a
297 // real sync, 2026-07-28): features-services, herobanner, info, blog,
298 // header, contact, footer, product, archive, testimonials, cta,
299 // team-member, gallery, faq, videos, subscription, pricing.
300 // `null` = deliberately no core equivalent; keep our own bucket.
301 // Slugs that already equal a core slug (header, footer, contact, gallery,
302 // testimonials, videos) need no entry — they match directly.
303 $aliases = apply_filters( 'templately_block_patterns_category_aliases', [
304 'features-services' => 'services',
305 'herobanner' => 'banner',
306 'hero' => 'banner',
307 'banners' => 'banner',
308 'blog' => 'posts',
309 'archive' => 'query',
310 'team-member' => 'team',
311 'cta' => 'call-to-action',
312 'headers' => 'header',
313 'footers' => 'footer',
314 'testimonial' => 'testimonials',
315 'service' => 'services',
316 'galleries' => 'gallery',
317 // No core equivalent — these keep a Templately-owned category.
318 'info' => null,
319 'product' => null,
320 'faq' => null,
321 'subscription' => null,
322 'pricing' => null,
323 'landing' => null,
324 ] );
325
326 $slug = array_key_exists( $category, $aliases ) ? $aliases[ $category ] : $category;
327
328 if ( null === $slug ) {
329 return null;
330 }
331
332 return $registry->is_registered( $slug ) ? $slug : null;
333 }
334
335 private function ensure_brand_category(): void {
336 if ( \WP_Block_Pattern_Categories_Registry::get_instance()->is_registered( self::BRAND_CATEGORY ) ) {
337 return;
338 }
339
340 register_block_pattern_category( self::BRAND_CATEGORY, [
341 'label' => __( 'Templately', 'templately' ),
342 'description' => __( 'Ready-made sections and page templates from your Templately library.', 'templately' ),
343 ] );
344 }
345
346 /**
347 * Words `ucfirst()` gets wrong because they are acronyms, not words.
348 *
349 * Deliberately tiny and about ENGLISH, not about plugins — this is the same
350 * class of fix as sentence-casing, and unlike a namespace-to-plugin table it
351 * cannot go stale in a way that breaks behaviour. A catalog slug that is not
352 * listed here simply reads as it did before.
353 */
354 const ACRONYMS = [ 'faq', 'cta', 'seo', 'ai' ];
355
356 /**
357 * A catalog slug as a human label: `single-post` -> `Single post`,
358 * `faq` -> `FAQ`.
359 *
360 * Sentence case matches core's own labels ("Call to action", not "Call To
361 * Action") — `ucwords()` made ours read as foreign in the list.
362 */
363 private function humanize( string $category ): string {
364 $words = explode( '-', $category );
365
366 foreach ( $words as $i => $word ) {
367 $words[ $i ] = in_array( $word, self::ACRONYMS, true )
368 ? strtoupper( $word )
369 : ( 0 === $i ? ucfirst( $word ) : $word );
370 }
371
372 return implode( ' ', $words );
373 }
374
375 /**
376 * Register the fallback category for a catalog slug core has no equivalent for.
377 *
378 * THE LABEL CARRIES THE BRAND, and that is not decoration. In the inserter
379 * these sit inside one long alphabetical list where an unattributed "Pricing"
380 * is merely vague. From WordPress 7.1 the Site Editor's Patterns screen builds
381 * its sidebar views from this same registry, so each one becomes a TOP-LEVEL
382 * entry sitting beside core's — and a dozen generic names appearing in a
383 * user's sidebar after a WordPress upgrade, with nothing saying where they
384 * came from or how to remove them, is a support problem we would be handing
385 * ourselves. Verified against the live catalog: twelve of these register
386 * (Slider, Info, Countdown, Pricing, Subscription, FAQ, Product, Single page,
387 * Single product, Landing page, Single post, Product archive).
388 *
389 * The brand category itself stays unprefixed — "Templately" needs no prefix.
390 */
391 private function ensure_own_category( string $slug, string $category ): void {
392 if ( \WP_Block_Pattern_Categories_Registry::get_instance()->is_registered( $slug ) ) {
393 return;
394 }
395
396 register_block_pattern_category( $slug, [
397 'label' => sprintf(
398 /* translators: %s: pattern category name, e.g. "Pricing". */
399 __( 'Templately: %s', 'templately' ),
400 $this->humanize( $category )
401 ),
402 ] );
403 }
404 }
405