PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / trunk
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder vtrunk
2.12.0 2.11.1 2.11.0 2.10.0 2.9.0 2.7.4 2.7.5 2.7.6 2.7.7 2.8.0 2.8.1 2.9.1 trunk 1.0 1.0-beta1 1.0-beta2 1.0-beta3 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.2.0 1.2.1 All 78 releases
ablocks / includes / classes / global-classes.php

global-classes.php in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder trunk, at includes/classes/global-classes.php

505 lines 15.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace ABlocks\Classes;
3
4 if ( ! defined( 'ABSPATH' ) ) {
5 exit;
6 }
7
8 use ABlocks\Helper;
9
10 /**
11 * Reusable CSS class library (Tailwind-style + user-created classes).
12 *
13 * A class is a record { id, label, css } stored in the `ablocks_global_classes`
14 * option. Blocks reference classes by id (never copying the styles), so editing
15 * one class updates every block using it. The compiled `.ablocks-gc-{id}{css}`
16 * rules are enqueued once on the front end and in the editor.
17 *
18 * This is the v1 storage layer for the atomic class system — `css` is a raw
19 * declaration string for now; it will grow into structured variants
20 * (breakpoint/state) as the system matures.
21 */
22 class GlobalClasses {
23
24 const OPTION = 'ablocks_global_classes';
25 const HANDLE = 'ablocks-global-classes';
26
27 /**
28 * Timestamp of the last change to the library.
29 *
30 * A generated page stylesheet embeds the compiled CSS of the classes that
31 * page uses, so editing a class has to make every one of those files stale.
32 * Comparing this stamp against a file's mtime does that without writing
33 * anything into the file or its name (see Assets::is_assets_generated).
34 */
35 const REV_OPTION = 'ablocks_global_classes_rev';
36
37 /** Compiled whole-library CSS, kept until the revision moves. */
38 const CSS_TRANSIENT = 'ablocks_global_classes_css';
39
40 /** One-time marker for the autoload repair below. */
41 const AUTOLOAD_FIXED = 'ablocks_global_classes_autoload_fixed';
42
43 /**
44 * Cross-request mutex for the library.
45 *
46 * Every write is a read-modify-write of one option holding the whole list,
47 * and the editor makes them constantly — a debounced save per style change,
48 * from every open block and every open tab. Two of those overlapping used to
49 * mean the second request read the list before the first had written its
50 * copy back, so the first one's class simply vanished. On a slow connection,
51 * where a request can be in flight for many seconds, a single stale write
52 * could take the whole afternoon's classes with it.
53 */
54 const LOCK_OPTION = 'ablocks_global_classes.lock';
55
56 /** Seconds after which a held lock is assumed to belong to a dead request. */
57 const LOCK_TIMEOUT = 20;
58
59 /** How long a writer waits for the lock before giving up on it. */
60 const LOCK_WAIT = 5;
61
62 /**
63 * Whether the compiled CSS has already been attached this request.
64 *
65 * Both enqueue hooks can fire in one request, and core fires
66 * `enqueue_block_assets` from inside `wp_enqueue_scripts` — which attached
67 * the entire stylesheet twice on every front-end page.
68 *
69 * @var bool
70 */
71 private static $enqueued = false;
72
73 public static function init() {
74 $self = new self();
75 add_action( 'wp_ajax_ablocks/global_classes/get', [ $self, 'ajax_get' ] );
76 add_action( 'wp_ajax_ablocks/global_classes/save', [ $self, 'ajax_save' ] );
77 add_action( 'wp_ajax_ablocks/global_classes/delete', [ $self, 'ajax_delete' ] );
78
79 // Compiled class CSS on the front end and in the editor canvas. The two
80 // hooks are not interchangeable and must not both run on one request:
81 // `enqueue_block_assets` is what reaches the editor's iframed canvas,
82 // while the front-end pass runs on `wp_enqueue_scripts` at 100 — after
83 // Assets::enqueue_frontend_assets (99) — so it can see whether the
84 // generated page stylesheet already carries these rules.
85 add_action( 'wp_enqueue_scripts', [ $self, 'enqueue_css' ], 100 );
86 add_action( 'enqueue_block_assets', [ $self, 'enqueue_editor_css' ], 100 );
87
88 add_action( 'admin_init', [ $self, 'maybe_drop_autoload' ] );
89 }
90
91 public static function get_all() {
92 $raw = get_option( self::OPTION, '[]' );
93 $data = json_decode( (string) $raw, true );
94 return is_array( $data ) ? array_values( $data ) : [];
95 }
96
97 private function persist( array $classes ) {
98 $json = wp_json_encode( array_values( $classes ) );
99
100 // A save that changes nothing is common — renaming a class to the name
101 // it already has, or a debounced style write that lands after an
102 // identical one. Bumping the revision for it would expire the compiled
103 // CSS and stale every generated page stylesheet on the site for no
104 // reason at all.
105 if ( (string) get_option( self::OPTION, '[]' ) === $json ) {
106 return false;
107 }
108
109 // Explicitly never autoloaded. WordPress only excludes an option on its
110 // own above ~150 KB, which leaves a library of a few hundred classes
111 // loaded on every request — including admin-ajax, REST and cron, none
112 // of which render a block.
113 update_option( self::OPTION, $json, false );
114 self::bump_revision();
115 return true;
116 }
117
118 /**
119 * Take the library mutex.
120 *
121 * `INSERT IGNORE` on the options table is the only primitive available here
122 * that is atomic across concurrent requests — `option_name` is unique, so
123 * exactly one of them can create the row. `add_option()` cannot stand in for
124 * it: it reads and then writes, which is the very race this closes. Core
125 * takes its upgrade lock the same way (see WP_Upgrader::create_lock).
126 *
127 * A caller that cannot get the lock still writes. Losing a class to a race
128 * is much worse than the theoretical risk of one, and a lock this short is
129 * only ever unavailable because another writer is mid-flight.
130 *
131 * @return bool Whether the lock is now held by this request.
132 */
133 private static function acquire_lock() {
134 global $wpdb;
135
136 $deadline = microtime( true ) + self::LOCK_WAIT;
137
138 do {
139 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- atomicity is the point; no cache to prime.
140 $acquired = $wpdb->query(
141 $wpdb->prepare(
142 "INSERT IGNORE INTO `$wpdb->options` ( `option_name`, `option_value`, `autoload` ) VALUES (%s, %s, 'off') /* LOCK */",
143 self::LOCK_OPTION,
144 time()
145 )
146 );
147
148 if ( $acquired ) {
149 return true;
150 }
151
152 // Held by someone else — or left behind by a request that died
153 // between taking the lock and releasing it, in which case nothing
154 // but the timeout will ever clear it.
155 // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- must not read a cached copy of a lock.
156 $held = (int) $wpdb->get_var(
157 $wpdb->prepare( "SELECT option_value FROM `$wpdb->options` WHERE option_name = %s", self::LOCK_OPTION )
158 );
159
160 if ( ! $held || ( time() - $held ) > self::LOCK_TIMEOUT ) {
161 self::release_lock();
162 continue;
163 }
164
165 usleep( 50000 );
166 } while ( microtime( true ) < $deadline );
167
168 return false;
169 }
170
171 private static function release_lock() {
172 delete_option( self::LOCK_OPTION );
173 }
174
175 /**
176 * Read the library past any cached copy.
177 *
178 * Inside the lock the point is to see what the request we just waited for
179 * actually wrote, which a value cached earlier in this request would hide.
180 */
181 private static function get_all_fresh() {
182 wp_cache_delete( self::OPTION, 'options' );
183 // And the "this option does not exist" cache: on a site with no library
184 // yet, the first read caches its absence, and the request we waited for
185 // is very likely the one that just created it.
186 wp_cache_delete( 'notoptions', 'options' );
187 return self::get_all();
188 }
189
190 /**
191 * Mark the library as changed: expires the compiled-CSS cache and makes
192 * every generated page stylesheet stale, so they rebuild on next visit.
193 */
194 public static function bump_revision() {
195 $now = time();
196 update_option( self::REV_OPTION, $now, true );
197 delete_transient( self::CSS_TRANSIENT );
198 return $now;
199 }
200
201 public static function get_revision() {
202 return (int) get_option( self::REV_OPTION, 0 );
203 }
204
205 /**
206 * One-time repair for a library saved before persist() passed the autoload
207 * flag explicitly.
208 */
209 public function maybe_drop_autoload() {
210 if ( get_option( self::AUTOLOAD_FIXED ) ) {
211 return;
212 }
213 if ( function_exists( 'wp_set_option_autoload' ) ) {
214 wp_set_option_autoload( self::OPTION, false );
215 }
216 update_option( self::AUTOLOAD_FIXED, 1, true );
217 }
218
219 private function sanitize_id( $id ) {
220 return sanitize_html_class( $id );
221 }
222
223 /**
224 * Insert or update a class. Returns the stored record. Pass a structured
225 * $styles array ({normal,hover}) for controls-editable classes, or a raw
226 * $css declaration string (legacy).
227 */
228 public function upsert( $id, $label, $css = '', $styles = null ) {
229 $id = $this->sanitize_id( $id );
230 if ( '' === $id ) {
231 return null;
232 }
233
234 $record = null;
235 $locked = self::acquire_lock();
236
237 try {
238 // Read INSIDE the lock. Reading first and locking afterwards would
239 // leave exactly the window this is here to close.
240 $classes = self::get_all_fresh();
241 $index = $this->index_of( $classes, $id );
242 $existing = null === $index ? null : $classes[ $index ];
243 $record = $this->build_record( $id, $label, $css, $styles, $existing );
244
245 if ( null === $index ) {
246 $classes[] = $record;
247 } else {
248 $classes[ $index ] = $record;
249 }
250
251 $this->persist( $classes );
252 } finally {
253 if ( $locked ) {
254 self::release_lock();
255 }
256 }
257
258 return $record;
259 }
260
261 /**
262 * Where a class sits in the list, or null.
263 *
264 * @param array $classes The library.
265 * @param string $id Class id.
266 * @return int|null The index, or null when the class is new.
267 */
268 private function index_of( array $classes, $id ) {
269 foreach ( $classes as $i => $c ) {
270 if ( isset( $c['id'] ) && $c['id'] === $id ) {
271 return $i;
272 }
273 }
274 return null;
275 }
276
277 /**
278 * The record to store for a class.
279 *
280 * @param string $id Class id.
281 * @param string $label Display label.
282 * @param string $css Raw declaration string (legacy form).
283 * @param array|null $styles Structured styles, when the caller sent them.
284 * @param array|null $existing The record already stored, if any.
285 * @return array The record.
286 */
287 private function build_record( $id, $label, $css, $styles, $existing ) {
288 $record = [
289 'id' => $id,
290 'label' => sanitize_text_field( $label ),
291 ];
292
293 if ( is_array( $styles ) ) {
294 $record['styles'] = $styles;
295 return $record;
296 }
297
298 if ( '' !== trim( (string) $css ) ) {
299 // Declaration string only — strip braces/at-rules so a class can't
300 // break out of its own selector.
301 $record['css'] = trim( preg_replace( '/[{}<>]/', '', (string) $css ) );
302 return $record;
303 }
304
305 // Neither form came in, so this is a save that only touches the label.
306 // The record is rebuilt from scratch here, so carrying the look over
307 // explicitly is what stops a rename from emptying the class of
308 // everything it styles.
309 if ( isset( $existing['styles'] ) ) {
310 $record['styles'] = $existing['styles'];
311 } elseif ( isset( $existing['css'] ) ) {
312 $record['css'] = $existing['css'];
313 } else {
314 $record['css'] = '';
315 }
316
317 return $record;
318 }
319
320 public function remove( $id ) {
321 $id = $this->sanitize_id( $id );
322 $locked = self::acquire_lock();
323
324 try {
325 $classes = array_filter( self::get_all_fresh(), function ( $c ) use ( $id ) {
326 return ! ( isset( $c['id'] ) && $c['id'] === $id );
327 } );
328 $this->persist( $classes );
329 } finally {
330 if ( $locked ) {
331 self::release_lock();
332 }
333 }
334 }
335
336 /**
337 * Compile the library, or only the classes named in $ids.
338 *
339 * Order follows the LIBRARY, not $ids: when two classes on one element set
340 * the same property, library order decides the winner on the front end, and
341 * the editor preview mirrors that (see buildPreviewCss). Emitting in caller
342 * order would let a block preview differently from the published page.
343 *
344 * @param array|null $ids Class ids to compile, or null for the whole library.
345 */
346 public static function compiled_css_for( $ids = null ) {
347 $wanted = null;
348 if ( is_array( $ids ) ) {
349 if ( empty( $ids ) ) {
350 return '';
351 }
352 $wanted = array_flip( array_map( 'strval', $ids ) );
353 }
354
355 $css = '';
356 foreach ( self::get_all() as $c ) {
357 if ( empty( $c['id'] ) ) {
358 continue;
359 }
360 $id = $c['id'];
361 if ( null !== $wanted && ! isset( $wanted[ $id ] ) ) {
362 continue;
363 }
364 if ( ! empty( $c['styles'] ) && is_array( $c['styles'] ) ) {
365 $css .= AtomicStyles::compile_variants( '.ablocks-gc-' . $id, $c['styles'] );
366 } elseif ( ! empty( $c['css'] ) ) {
367 $css .= '.ablocks-gc-' . $id . '{' . $c['css'] . '}';
368 }
369 }
370 return $css;
371 }
372
373 public function compiled_css() {
374 return self::compiled_css_for( null );
375 }
376
377 /**
378 * The whole library, compiled once per revision.
379 *
380 * This path cannot know which classes the page uses — that needs the block
381 * tree, and only the asset generator walks it — so it still emits every
382 * class. Caching the result at least stops each uncached request from
383 * recompiling byte-identical CSS.
384 */
385 private static function cached_css() {
386 $rev = self::get_revision();
387 $cached = get_transient( self::CSS_TRANSIENT );
388
389 if ( is_array( $cached ) && isset( $cached['rev'], $cached['css'] ) && (int) $cached['rev'] === $rev ) {
390 return (string) $cached['css'];
391 }
392
393 $css = self::compiled_css_for( null );
394 set_transient(
395 self::CSS_TRANSIENT,
396 [
397 'rev' => $rev,
398 'css' => $css,
399 ],
400 WEEK_IN_SECONDS
401 );
402
403 return $css;
404 }
405
406 /** The editor canvas copy; the front end is served from enqueue_css(). */
407 public function enqueue_editor_css() {
408 if ( ! is_admin() ) {
409 return;
410 }
411 $this->enqueue_css();
412 }
413
414 public function enqueue_css() {
415 if ( self::$enqueued ) {
416 return;
417 }
418
419 // When the generated page stylesheet is in play it already contains the
420 // classes this page uses, compiled by AssetsGenerator. A second, whole-
421 // library copy inline would be pure weight.
422 if ( ! is_admin() && wp_style_is( 'ablocks-blocks-combine-style', 'enqueued' ) ) {
423 self::$enqueued = true;
424 return;
425 }
426
427 $css = self::cached_css();
428 if ( '' === $css ) {
429 return;
430 }
431
432 self::$enqueued = true;
433
434 if ( ! wp_style_is( self::HANDLE, 'registered' ) ) {
435 wp_register_style( self::HANDLE, false, [], ABLOCKS_VERSION );
436 }
437 wp_enqueue_style( self::HANDLE );
438 wp_add_inline_style( self::HANDLE, $css );
439 }
440
441 // ---- AJAX ----
442
443 private function verify() {
444 check_ajax_referer( 'ablocks_nonce', 'security' );
445 if ( ! current_user_can( 'edit_posts' ) ) {
446 wp_send_json_error( [ 'message' => 'forbidden' ], 403 );
447 }
448 }
449
450 public function ajax_get() {
451 // Same gate as save/delete below. Reading the library is not sensitive,
452 // but leaving one of the three endpoints open to any logged-in user is
453 // the kind of inconsistency that turns into a hole when the payload
454 // grows.
455 $this->verify();
456 wp_send_json_success( self::get_all() );
457 }
458
459 private function sanitize_styles( $value ) {
460 if ( is_array( $value ) ) {
461 $out = [];
462 foreach ( $value as $k => $v ) {
463 $out[ sanitize_text_field( $k ) ] = $this->sanitize_styles( $v );
464 }
465 return $out;
466 }
467 return sanitize_text_field( (string) $value );
468 }
469
470 public function ajax_save() {
471 $this->verify();
472 $id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : '';
473 $label = isset( $_POST['label'] ) ? sanitize_text_field( wp_unslash( $_POST['label'] ) ) : '';
474 $css = isset( $_POST['css'] ) ? wp_unslash( $_POST['css'] ) : '';
475
476 $styles = null;
477 if ( isset( $_POST['styles'] ) ) {
478 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- sanitize_styles() walks the decoded tree.
479 $decoded = json_decode( wp_unslash( $_POST['styles'] ), true );
480
481 // A styles payload that will not decode is a truncated or corrupted
482 // request, not an instruction to empty the class. Saving it anyway
483 // used to replace everything the class styled with nothing.
484 if ( ! is_array( $decoded ) ) {
485 wp_send_json_error( [ 'message' => 'invalid styles' ], 400 );
486 }
487
488 $styles = $this->sanitize_styles( $decoded );
489 }
490
491 $record = $this->upsert( $id, $label, $css, $styles );
492 if ( ! $record ) {
493 wp_send_json_error( [ 'message' => 'invalid id' ], 400 );
494 }
495 wp_send_json_success( $record );
496 }
497
498 public function ajax_delete() {
499 $this->verify();
500 $id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : '';
501 $this->remove( $id );
502 wp_send_json_success();
503 }
504 }
505