| @@ -10,8 +10,11 @@ | ||
| 10 | 10 | class Helper { |
| 11 | 11 | |
| 12 | 12 | use Importer; |
| 13 | 13 | |
| 14 | + /** Memoized responsive device list (see get_responsive_devices). */ | |
| 15 | + private static $responsive_devices_cache = null; | |
| 16 | + | |
| 14 | 17 | public static function get_time() { |
| 15 | 18 | return time() + ( get_option( 'gmt_offset' ) * HOUR_IN_SECONDS ); |
| 16 | 19 | } |
| 17 | 20 | |
| @@ -24,8 +27,251 @@ | ||
| 24 | 27 | |
| 25 | 28 | return $default; |
| 26 | 29 | } |
| 27 | 30 | |
| 31 | + /** | |
| 32 | + * Responsive breakpoint widths (px) for the whole plugin. User-configurable | |
| 33 | + * via Settings; defaults preserve the historical 800/480 values. Every CSS | |
| 34 | + * generator and the block editor reads these so breakpoints stay in sync. | |
| 35 | + */ | |
| 36 | + /** | |
| 37 | + * Make one CSS property or declaration value safe to write into a stylesheet. | |
| 38 | + * | |
| 39 | + * Block attributes reach the compiled CSS verbatim and that CSS is echoed | |
| 40 | + * inside a `<style>` element, so a value carrying `</style>` closes the | |
| 41 | + * element and everything after it is parsed as HTML — a stored XSS | |
| 42 | + * available to anyone who can set a block attribute, which includes a | |
| 43 | + * Contributor editing their own draft. `{` and `}` are the same problem one | |
| 44 | + * level down: they close the rule and let the value choose its own | |
| 45 | + * selector. | |
| 46 | + * | |
| 47 | + * Only those four characters are removed. A declaration value never needs | |
| 48 | + * them, and everything a real one does need survives: data URIs (which | |
| 49 | + * carry `;`), gradients, `calc()` and `var()` (parentheses and commas), | |
| 50 | + * font stacks (quotes), `content` escapes (backslashes), and shorthand | |
| 51 | + * slashes such as `font: 12px/1.5`. | |
| 52 | + * | |
| 53 | + * `;` is deliberately kept. With the braces gone, an injected `;` can only | |
| 54 | + * add declarations to the same rule — which targets the block's own | |
| 55 | + * element, exactly what its style controls already allow — so removing it | |
| 56 | + * would break data URIs to buy nothing. | |
| 57 | + * | |
| 58 | + * @param mixed $value A CSS property name or declaration value. | |
| 59 | + * @return string The value with the escape characters removed. | |
| 60 | + */ | |
| 61 | + public static function esc_css_value( $value ) { | |
| 62 | + if ( ! is_scalar( $value ) ) { | |
| 63 | + return ''; | |
| 64 | + } | |
| 65 | + return str_replace( [ '<', '>', '{', '}' ], '', (string) $value ); | |
| 66 | + } | |
| 67 | + | |
| 68 | + public static function get_breakpoints() { | |
| 69 | + $tablet = (int) self::get_settings( 'breakpoint_tablet', 800 ); | |
| 70 | + $mobile = (int) self::get_settings( 'breakpoint_mobile', 480 ); | |
| 71 | + | |
| 72 | + // Guard against nonsensical config (mobile must be below tablet). | |
| 73 | + if ( $tablet < 1 ) { | |
| 74 | + $tablet = 800; | |
| 75 | + } | |
| 76 | + if ( $mobile < 1 || $mobile >= $tablet ) { | |
| 77 | + $mobile = min( 480, $tablet - 1 ); | |
| 78 | + } | |
| 79 | + | |
| 80 | + return array( | |
| 81 | + 'tablet' => $tablet, | |
| 82 | + 'mobile' => $mobile, | |
| 83 | + ); | |
| 84 | + } | |
| 85 | + | |
| 86 | + /** | |
| 87 | + * The full ordered list of responsive devices the atomic style system emits | |
| 88 | + * for: the base (Desktop, width 0 = no media query), the two built-in | |
| 89 | + * breakpoints, then any user-registered custom breakpoints. Each entry: | |
| 90 | + * id - stable identifier (also the WP device name for the built-ins) | |
| 91 | + * label - shown in the editor device switcher | |
| 92 | + * suffix - appended to responsive attribute keys (e.g. fontSize + suffix) | |
| 93 | + * width - max-width px for the @media rule (0 = base, no media query) | |
| 94 | + * | |
| 95 | + * Ordering is load-bearing, not cosmetic: the base comes first and every | |
| 96 | + * other device follows widest-first, so narrower breakpoints emit later and | |
| 97 | + * win in `cascade` mode. Precedence therefore follows the breakpoint's own | |
| 98 | + * bounds rather than the order a custom breakpoint happened to be | |
| 99 | + * registered in. | |
| 100 | + */ | |
| 101 | + public static function get_responsive_devices() { | |
| 102 | + if ( null !== self::$responsive_devices_cache ) { | |
| 103 | + return self::$responsive_devices_cache; | |
| 104 | + } | |
| 105 | + | |
| 106 | + $bp = self::get_breakpoints(); | |
| 107 | + // Built-ins are max-width only (min 0). `width` is the sort key | |
| 108 | + // (max-width, or a large value for min-only so it sorts widest). | |
| 109 | + $devices = array( | |
| 110 | + array( 'id' => 'Desktop', 'label' => 'Desktop', 'suffix' => '', 'width' => 0, 'min' => 0, 'max' => 0 ), | |
| 111 | + array( 'id' => 'Tablet', 'label' => 'Tablet', 'suffix' => 'Tablet', 'width' => $bp['tablet'], 'min' => 0, 'max' => $bp['tablet'] ), | |
| 112 | + array( 'id' => 'Mobile', 'label' => 'Mobile', 'suffix' => 'Mobile', 'width' => $bp['mobile'], 'min' => 0, 'max' => $bp['mobile'] ), | |
| 113 | + ); | |
| 114 | + | |
| 115 | + $custom = self::get_settings( 'breakpoint_custom', array() ); | |
| 116 | + if ( is_array( $custom ) ) { | |
| 117 | + foreach ( $custom as $c ) { | |
| 118 | + $c = (array) $c; | |
| 119 | + // Advanced breakpoints support a min and/or max width. `width` is | |
| 120 | + // kept as a legacy alias for max-width. | |
| 121 | + $max = isset( $c['maxWidth'] ) ? (int) $c['maxWidth'] : ( isset( $c['width'] ) ? (int) $c['width'] : 0 ); | |
| 122 | + $min = isset( $c['minWidth'] ) ? (int) $c['minWidth'] : 0; | |
| 123 | + if ( $max < 1 && $min < 1 ) { | |
| 124 | + continue; // needs at least one bound | |
| 125 | + } | |
| 126 | + // Stable, alphanumeric suffix so stored values survive label edits. | |
| 127 | + $key = ! empty( $c['key'] ) ? preg_replace( '/[^a-zA-Z0-9]/', '', $c['key'] ) : (string) ( $max ? $max : $min ); | |
| 128 | + $suffix = 'Bp' . ucfirst( $key ); | |
| 129 | + $label = ! empty( $c['label'] ) ? $c['label'] : self::breakpoint_auto_label( $min, $max ); | |
| 130 | + $devices[] = array( | |
| 131 | + 'id' => $suffix, | |
| 132 | + 'label' => $label, | |
| 133 | + 'suffix' => $suffix, | |
| 134 | + 'width' => $max > 0 ? $max : 999999, // sort key (min-only = widest) | |
| 135 | + 'min' => $min, | |
| 136 | + 'max' => $max, | |
| 137 | + ); | |
| 138 | + } | |
| 139 | + } | |
| 140 | + | |
| 141 | + self::$responsive_devices_cache = self::sort_responsive_devices( $devices ); | |
| 142 | + return self::$responsive_devices_cache; | |
| 143 | + } | |
| 144 | + | |
| 145 | + /** | |
| 146 | + * Base first, then widest-first. Ties break on id so the order is stable | |
| 147 | + * regardless of the PHP version's sort stability. | |
| 148 | + */ | |
| 149 | + private static function sort_responsive_devices( $devices ) { | |
| 150 | + $base = array(); | |
| 151 | + $rest = array(); | |
| 152 | + foreach ( $devices as $d ) { | |
| 153 | + if ( empty( $d['min'] ) && empty( $d['max'] ) ) { | |
| 154 | + $base[] = $d; | |
| 155 | + } else { | |
| 156 | + $rest[] = $d; | |
| 157 | + } | |
| 158 | + } | |
| 159 | + | |
| 160 | + usort( | |
| 161 | + $rest, | |
| 162 | + function ( $a, $b ) { | |
| 163 | + $cmp = (int) $b['width'] - (int) $a['width']; | |
| 164 | + return 0 !== $cmp ? $cmp : strcmp( (string) $a['id'], (string) $b['id'] ); | |
| 165 | + } | |
| 166 | + ); | |
| 167 | + | |
| 168 | + return array_merge( $base, $rest ); | |
| 169 | + } | |
| 170 | + | |
| 171 | + /** Drop the memoized device list (settings changed mid-request). */ | |
| 172 | + public static function flush_responsive_devices_cache() { | |
| 173 | + self::$responsive_devices_cache = null; | |
| 174 | + } | |
| 175 | + | |
| 176 | + /** A readable fallback label for a min/max breakpoint. */ | |
| 177 | + public static function breakpoint_auto_label( $min, $max ) { | |
| 178 | + if ( $min > 0 && $max > 0 ) { | |
| 179 | + return $min . '–' . $max . 'px'; | |
| 180 | + } | |
| 181 | + if ( $max > 0 ) { | |
| 182 | + return '≤ ' . $max . 'px'; | |
| 183 | + } | |
| 184 | + return '≥ ' . $min . 'px'; | |
| 185 | + } | |
| 186 | + | |
| 187 | + /** Compose a CSS media condition (no `@media` keyword) from min/max px. */ | |
| 188 | + public static function breakpoint_media_condition( $min, $max ) { | |
| 189 | + $parts = array(); | |
| 190 | + if ( $min > 0 ) { | |
| 191 | + $parts[] = '(min-width:' . (int) $min . 'px)'; | |
| 192 | + } | |
| 193 | + if ( $max > 0 ) { | |
| 194 | + $parts[] = '(max-width:' . (int) $max . 'px)'; | |
| 195 | + } | |
| 196 | + return implode( ' and ', $parts ); | |
| 197 | + } | |
| 198 | + | |
| 199 | + /** | |
| 200 | + * How breakpoint queries relate to each other, site-wide. | |
| 201 | + * | |
| 202 | + * cascade (default) - max-width envelopes. A Tablet value still applies at | |
| 203 | + * Mobile widths unless Mobile overrides it. This is how | |
| 204 | + * aBlocks v1/v2 blocks behave, so a page mixing block | |
| 205 | + * versions stays consistent. | |
| 206 | + * strict - exclusive bands. A Tablet value applies only between | |
| 207 | + * the Mobile bound and the Tablet bound, matching | |
| 208 | + * WordPress core and block themes. | |
| 209 | + */ | |
| 210 | + public static function get_breakpoint_mode() { | |
| 211 | + return 'strict' === self::get_settings( 'breakpoint_mode', 'cascade' ) ? 'strict' : 'cascade'; | |
| 212 | + } | |
| 213 | + | |
| 214 | + /** | |
| 215 | + * The single place an atomic media query is built. Returns the complete | |
| 216 | + * `@media …` prelude for a device entry, or '' for the base device (which | |
| 217 | + * needs no query at all). | |
| 218 | + * | |
| 219 | + * Both bounds are honoured, so a custom breakpoint declared with only a | |
| 220 | + * `minWidth` produces a real min-width query instead of being skipped — | |
| 221 | + * animations already behaved this way, style rules did not. | |
| 222 | + */ | |
| 223 | + public static function breakpoint_media_query( $device ) { | |
| 224 | + list( $min, $max ) = self::breakpoint_bounds( $device ); | |
| 225 | + | |
| 226 | + if ( $min < 1 && $max < 1 ) { | |
| 227 | + return ''; | |
| 228 | + } | |
| 229 | + | |
| 230 | + $condition = self::breakpoint_media_condition( $min, $max ); | |
| 231 | + return '' === $condition ? '' : '@media screen and ' . $condition; | |
| 232 | + } | |
| 233 | + | |
| 234 | + /** | |
| 235 | + * The [ min, max ] a device's media query actually covers. Mirror of the | |
| 236 | + * JS `effectiveBounds()`. | |
| 237 | + * | |
| 238 | + * @param array $device A device entry. | |
| 239 | + * @return int[] [ min, max ], 0 meaning unbounded. | |
| 240 | + */ | |
| 241 | + public static function breakpoint_bounds( $device ) { | |
| 242 | + $device = (array) $device; | |
| 243 | + $min = isset( $device['min'] ) ? (int) $device['min'] : 0; | |
| 244 | + $max = isset( $device['max'] ) ? (int) $device['max'] : 0; | |
| 245 | + | |
| 246 | + /* | |
| 247 | + * Strict mode bounds a max-width breakpoint from below with the next | |
| 248 | + * narrower breakpoint, turning overlapping envelopes into exclusive | |
| 249 | + * bands. A breakpoint that already declares its own min is left alone — | |
| 250 | + * the author has stated the band explicitly. | |
| 251 | + */ | |
| 252 | + if ( 'strict' === self::get_breakpoint_mode() && $max > 0 && $min < 1 ) { | |
| 253 | + $narrower = self::next_narrower_max( $max ); | |
| 254 | + if ( $narrower > 0 ) { | |
| 255 | + $min = $narrower + 1; | |
| 256 | + } | |
| 257 | + } | |
| 258 | + | |
| 259 | + return [ $min, $max ]; | |
| 260 | + } | |
| 261 | + | |
| 262 | + /** The largest max-width bound narrower than $max, or 0 if none. */ | |
| 263 | + private static function next_narrower_max( $max ) { | |
| 264 | + $best = 0; | |
| 265 | + foreach ( self::get_responsive_devices() as $d ) { | |
| 266 | + $dmax = isset( $d['max'] ) ? (int) $d['max'] : 0; | |
| 267 | + if ( $dmax > 0 && $dmax < $max && $dmax > $best ) { | |
| 268 | + $best = $dmax; | |
| 269 | + } | |
| 270 | + } | |
| 271 | + return $best; | |
| 272 | + } | |
| 273 | + | |
| 28 | 274 | public static function get_page_permalink( $page, $fallback = null ) { |
| 29 | 275 | $page_id = self::get_settings( $page ); |
| 30 | 276 | $permalink = 0 < $page_id ? get_permalink( $page_id ) : ''; |
| 31 | 277 | if ( ! $permalink ) { |
| @@ -76,15 +322,28 @@ | ||
| 76 | 322 | } |
| 77 | 323 | public static function is_active_quizpress() { |
| 78 | 324 | return class_exists( 'QuizPress' ); |
| 79 | 325 | } |
| 326 | + public static function is_active_zencommunity() { | |
| 327 | + $zencommunity = 'zencommunity/zencommunity.php'; | |
| 328 | + return self::is_plugin_active( $zencommunity ); | |
| 329 | + } | |
| 330 | + public static function is_active_gemboards() { | |
| 331 | + $gemboards = 'gemboards/gemboards.php'; | |
| 332 | + return self::is_plugin_active( $gemboards ); | |
| 333 | + } | |
| 80 | 334 | public static function is_active_easy_content_manager() { |
| 81 | - $easy_content_manager = 'easy-content-manager/easy-content-manager.php'; | |
| 82 | - return self::is_plugin_active( $easy_content_manager ); | |
| 335 | + return class_exists( 'EasyContentManager' ); | |
| 83 | 336 | } |
| 84 | 337 | |
| 85 | 338 | public static function is_enabled_assets_generation() { |
| 86 | - $flag = (bool) self::get_settings( 'enabled_assets_file_generation' ); | |
| 339 | + // Default OFF — combining/generating per-page asset files churns while a | |
| 340 | + // site is still being built, so it's recommended (via the Performance tab | |
| 341 | + // notice) once the site is complete rather than forced on. When enabled it | |
| 342 | + // merges every block's CSS/JS into one per-page file, inlined when small | |
| 343 | + // (see Assets::enqueue_frontend_assets), removing the per-block | |
| 344 | + // render-blocking stylesheets. | |
| 345 | + $flag = (bool) self::get_settings( 'enabled_assets_file_generation', false ); | |
| 87 | 346 | return apply_filters( 'ablocks/is_enabled_assets_generation', $flag ); |
| 88 | 347 | } |
| 89 | 348 | |
| 90 | 349 | public static function is_plugin_active( $basename ) { |
| @@ -100,20 +359,28 @@ | ||
| 100 | 359 | return true; |
| 101 | 360 | } |
| 102 | 361 | } |
| 103 | 362 | |
| 363 | + /** | |
| 364 | + * The aBlocks submenu. | |
| 365 | + * | |
| 366 | + * Each item declares the aBlocks capability that owns it rather than | |
| 367 | + * manage_options, so a site can hand somebody the Theme Builder without | |
| 368 | + * handing them the whole of WordPress. Administrators hold every one of | |
| 369 | + * these, so nothing changes for them. See Permissions. | |
| 370 | + */ | |
| 104 | 371 | public static function get_admin_menu_list() { |
| 105 | 372 | $menu = []; |
| 106 | 373 | $menu[ ABLOCKS_PLUGIN_SLUG ] = [ |
| 107 | 374 | 'parent_slug' => ABLOCKS_PLUGIN_SLUG, |
| 108 | 375 | 'title' => __( 'Dashboard', 'ablocks' ), |
| 109 | - 'capability' => 'manage_options', | |
| 376 | + 'capability' => Permissions::ACCESS, | |
| 110 | 377 | ]; |
| 111 | 378 | if ( self::is_enabled_block( 'form-builder' ) ) { |
| 112 | 379 | $menu[ ABLOCKS_PLUGIN_SLUG . '-submissions' ] = [ |
| 113 | 380 | 'parent_slug' => ABLOCKS_PLUGIN_SLUG, |
| 114 | 381 | 'title' => __( 'Submissions', 'ablocks' ), |
| 115 | - 'capability' => 'manage_options', | |
| 382 | + 'capability' => 'ablocks_view_submissions', | |
| 116 | 383 | ]; |
| 117 | 384 | } |
| 118 | 385 | if ( self::get_addon_active_status( 'theme-builder' ) ) { |
| 119 | 386 | $menu[ ABLOCKS_PLUGIN_SLUG . '-theme-builder' ] = [ |
| @@ -118,26 +385,31 @@ | ||
| 118 | 385 | if ( self::get_addon_active_status( 'theme-builder' ) ) { |
| 119 | 386 | $menu[ ABLOCKS_PLUGIN_SLUG . '-theme-builder' ] = [ |
| 120 | 387 | 'parent_slug' => ABLOCKS_PLUGIN_SLUG, |
| 121 | 388 | 'title' => __( 'Theme Builder', 'ablocks' ), |
| 122 | - 'capability' => 'manage_options', | |
| 389 | + 'capability' => 'ablocks_manage_theme_builder', | |
| 123 | 390 | ]; |
| 124 | 391 | } |
| 125 | 392 | $menu[ ABLOCKS_PLUGIN_SLUG . '-addons' ] = [ |
| 126 | 393 | 'parent_slug' => ABLOCKS_PLUGIN_SLUG, |
| 127 | 394 | 'title' => __( 'Add-ons', 'ablocks' ), |
| 128 | - 'capability' => 'manage_options', | |
| 395 | + 'capability' => 'ablocks_manage_addons', | |
| 129 | 396 | ]; |
| 397 | + $menu[ ABLOCKS_PLUGIN_SLUG . '-scanner' ] = [ | |
| 398 | + 'parent_slug' => ABLOCKS_PLUGIN_SLUG, | |
| 399 | + 'title' => __( 'Site Scanner', 'ablocks' ), | |
| 400 | + 'capability' => 'ablocks_run_scanner', | |
| 401 | + ]; | |
| 130 | 402 | $menu[ ABLOCKS_PLUGIN_SLUG . '-settings' ] = [ |
| 131 | 403 | 'parent_slug' => ABLOCKS_PLUGIN_SLUG, |
| 132 | 404 | 'title' => __( 'Settings', 'ablocks' ), |
| 133 | - 'capability' => 'manage_options', | |
| 405 | + 'capability' => Permissions::SAVE_SETTINGS, | |
| 134 | 406 | ]; |
| 135 | 407 | if ( ! defined( 'ABLOCKS_PRO_VERSION' ) ) { |
| 136 | 408 | $menu[ ABLOCKS_PLUGIN_SLUG . '-get-pro' ] = [ |
| 137 | 409 | 'parent_slug' => ABLOCKS_PLUGIN_SLUG, |
| 138 | 410 | 'title' => '<span class="dashicons dashicons-awards academy-blue-color"></span> ' . __( 'Get Pro', 'ablocks' ), |
| 139 | - 'capability' => 'manage_options', | |
| 411 | + 'capability' => Permissions::ACCESS, | |
| 140 | 412 | ]; |
| 141 | 413 | } |
| 142 | 414 | return apply_filters( 'ablocks/admin_menu_list', $menu ); |
| 143 | 415 | } |
| @@ -156,43 +428,135 @@ | ||
| 156 | 428 | public static function get_array_value( $array, $key, $default ) { |
| 157 | 429 | return ( isset( $array[ $key ] ) && ! empty( $array[ $key ] ) ) ? $array[ $key ] : $default; |
| 158 | 430 | } |
| 159 | 431 | |
| 160 | - public static function get_responsive_value( $attribute, $attribute_object_key, $device, $attribute_default_value = [] ) { | |
| 161 | - // Closure to "clean" a value (similar to your JS clean() helper) | |
| 162 | - $clean = function( $value ) { | |
| 163 | - return self::has_value( $value ) ? $value : null; | |
| 164 | - }; | |
| 432 | + /** | |
| 433 | + * Whether a stored responsive value is set — the mirror of the editor's | |
| 434 | + * hasValue(). 0 and '0' are set; null, '' and blank strings are unset (they | |
| 435 | + * inherit); arrays/objects are set when non-empty. Unlike has_value(), which | |
| 436 | + * many unrelated callers rely on, this never treats 0 as missing. | |
| 437 | + * | |
| 438 | + * @param mixed $value Stored value. | |
| 439 | + * @return bool | |
| 440 | + */ | |
| 441 | + public static function has_responsive_value( $value ) { | |
| 442 | + if ( null === $value ) { | |
| 443 | + return false; | |
| 444 | + } | |
| 445 | + if ( is_string( $value ) ) { | |
| 446 | + return '' !== trim( $value ); | |
| 447 | + } | |
| 448 | + if ( is_array( $value ) ) { | |
| 449 | + return ! empty( $value ); | |
| 450 | + } | |
| 451 | + if ( is_object( $value ) ) { | |
| 452 | + return ! empty( get_object_vars( $value ) ); | |
| 453 | + } | |
| 454 | + return true; | |
| 455 | + } | |
| 165 | 456 | |
| 166 | - // Desktop value (fallback to default, or false if nothing found) | |
| 167 | - $desktop_value = | |
| 168 | - $clean( $attribute[ $attribute_object_key ] ?? null ) ?? | |
| 169 | - $clean( $attribute_default_value[ $attribute_object_key ] ?? null ) ?? | |
| 170 | - false; | |
| 457 | + /** | |
| 458 | + * The devices a device inherits from, nearest first, ending with Desktop ('') | |
| 459 | + * — the mirror of the editor's getDeviceAncestors(). | |
| 460 | + * | |
| 461 | + * A wider device is an ancestor only when its range contains the device's own | |
| 462 | + * width, i.e. exactly when the frontend cascade applies its rule there, so a | |
| 463 | + * min-only (≥1400) or banded (900–1200) breakpoint is never inherited by | |
| 464 | + * Tablet. With the built-in devices this is Tablet ← Desktop, Mobile ← Tablet. | |
| 465 | + * Desktop has no ancestors; an unknown suffix (e.g. the phantom probe) | |
| 466 | + * inherits Desktop only. | |
| 467 | + * | |
| 468 | + * @param string $device Device suffix ('' | 'Desktop' | 'Tablet' | 'Bp…'). | |
| 469 | + * @return string[] Ancestor suffixes, nearest first. | |
| 470 | + */ | |
| 471 | + public static function get_responsive_ancestors( $device ) { | |
| 472 | + $target = 'Desktop' === $device ? '' : (string) $device; | |
| 473 | + if ( '' === $target ) { | |
| 474 | + return []; | |
| 475 | + } | |
| 476 | + $devices = array_values( self::get_responsive_devices() ); | |
| 477 | + $index = array_search( $target, array_column( $devices, 'suffix' ), true ); | |
| 478 | + if ( false === $index ) { | |
| 479 | + return [ '' ]; | |
| 480 | + } | |
| 481 | + $ancestors = []; | |
| 482 | + for ( $i = $index - 1; $i >= 0; $i-- ) { | |
| 483 | + if ( empty( $devices[ $i ]['min'] ) && empty( $devices[ $i ]['max'] ) ) { | |
| 484 | + continue; // the base is always last | |
| 485 | + } | |
| 486 | + if ( self::breakpoint_contains( $devices[ $i ], $devices[ $index ] ) ) { | |
| 487 | + $ancestors[] = $devices[ $i ]['suffix']; | |
| 488 | + } | |
| 489 | + } | |
| 490 | + $ancestors[] = ''; | |
| 491 | + return $ancestors; | |
| 492 | + } | |
| 171 | 493 | |
| 172 | - // Tablet value (fallback to desktop if nothing found) | |
| 173 | - $tablet_key = $attribute_object_key . 'Tablet'; | |
| 174 | - $tablet_value = | |
| 175 | - $clean( $attribute[ $tablet_key ] ?? null ) ?? | |
| 176 | - $clean( $attribute_default_value[ $tablet_key ] ?? null ) ?? | |
| 177 | - $desktop_value; | |
| 494 | + /** | |
| 495 | + * Whether breakpoint $outer's range contains breakpoint $inner, judged at | |
| 496 | + * $inner's own width (its max, else its min) — i.e. whether $outer's rule | |
| 497 | + * still applies where $inner starts. The base device contains everything. | |
| 498 | + * Shared by inheritance (get_responsive_ancestors) and CSS dedupe. | |
| 499 | + * | |
| 500 | + * @param array $outer Breakpoint with `min` / `max` bounds. | |
| 501 | + * @param array $inner Breakpoint with `min` / `max` bounds. | |
| 502 | + * @return bool | |
| 503 | + */ | |
| 504 | + /** | |
| 505 | + * Run a per-device CSS builder for a custom-breakpoint (or phantom probe) | |
| 506 | + * suffix. Block builders read `$attributes[ 'x' . $device ]` directly, and | |
| 507 | + * attribute defaults only declare the built-in suffixes, so a custom suffix | |
| 508 | + * key is missing — which is exactly "unset" for the caller | |
| 509 | + * (custom_device_map() subtracts the phantom baseline). Only those | |
| 510 | + * missing-key warnings are silenced, and only while the builder runs. | |
| 511 | + * | |
| 512 | + * @param callable $builder `( $device ) => styles`. | |
| 513 | + * @param string $device Device suffix. | |
| 514 | + * @return array Styles. | |
| 515 | + */ | |
| 516 | + public static function call_device_builder( $builder, $device ) { | |
| 517 | + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler | |
| 518 | + set_error_handler( | |
| 519 | + function ( $errno, $errstr ) { | |
| 520 | + return (bool) preg_match( '/^Undefined (array key|index|offset)/', $errstr ); | |
| 521 | + }, | |
| 522 | + E_WARNING | E_NOTICE | |
| 523 | + ); | |
| 524 | + try { | |
| 525 | + return (array) call_user_func( $builder, $device ); | |
| 526 | + } finally { | |
| 527 | + restore_error_handler(); | |
| 528 | + } | |
| 529 | + } | |
| 178 | 530 | |
| 179 | - // Mobile value (fallback to tablet if nothing found) | |
| 180 | - $mobile_key = $attribute_object_key . 'Mobile'; | |
| 181 | - $mobile_value = | |
| 182 | - $clean( $attribute[ $mobile_key ] ?? null ) ?? | |
| 183 | - $clean( $attribute_default_value[ $mobile_key ] ?? null ) ?? | |
| 184 | - $tablet_value; | |
| 531 | + public static function breakpoint_contains( $outer, $inner ) { | |
| 532 | + $o_min = isset( $outer['min'] ) ? (int) $outer['min'] : 0; | |
| 533 | + $o_max = isset( $outer['max'] ) ? (int) $outer['max'] : 0; | |
| 534 | + $i_min = isset( $inner['min'] ) ? (int) $inner['min'] : 0; | |
| 535 | + $i_max = isset( $inner['max'] ) ? (int) $inner['max'] : 0; | |
| 536 | + $width = $i_max ? $i_max : $i_min; | |
| 537 | + return ( ! $o_min || $width >= $o_min ) && ( ! $o_max || $width <= $o_max ); | |
| 538 | + } | |
| 185 | 539 | |
| 186 | - // Return based on device | |
| 187 | - switch ( $device ) { | |
| 188 | - case 'Mobile': | |
| 189 | - return $mobile_value; | |
| 190 | - case 'Tablet': | |
| 191 | - return $tablet_value; | |
| 192 | - default: | |
| 193 | - return $desktop_value; | |
| 540 | + /** | |
| 541 | + * A device's value for a key: its own, else the nearest containing wider | |
| 542 | + * device's (see get_responsive_ancestors()), else Desktop's — the mirror of | |
| 543 | + * the editor's getResponsiveValue(). Stored values win over declared | |
| 544 | + * defaults per device. Returns false when nothing is set anywhere. | |
| 545 | + */ | |
| 546 | + public static function get_responsive_value( $attribute, $attribute_object_key, $device, $attribute_default_value = [] ) { | |
| 547 | + $target = 'Desktop' === $device ? '' : (string) $device; | |
| 548 | + $suffixes = array_merge( [ $target ], self::get_responsive_ancestors( $target ) ); | |
| 549 | + foreach ( $suffixes as $suffix ) { | |
| 550 | + $key = $attribute_object_key . $suffix; | |
| 551 | + if ( isset( $attribute[ $key ] ) && self::has_responsive_value( $attribute[ $key ] ) ) { | |
| 552 | + return $attribute[ $key ]; | |
| 553 | + } | |
| 554 | + if ( isset( $attribute_default_value[ $key ] ) && self::has_responsive_value( $attribute_default_value[ $key ] ) ) { | |
| 555 | + return $attribute_default_value[ $key ]; | |
| 556 | + } | |
| 194 | 557 | } |
| 558 | + return false; | |
| 195 | 559 | } |
| 196 | 560 | |
| 197 | 561 | public static function is_gutenberg_editor() { |
| 198 | 562 | global $pagenow; |
| @@ -391,8 +755,14 @@ | ||
| 391 | 755 | } |
| 392 | 756 | |
| 393 | 757 | public static function get_content_by_object_id( string $id_or_fse_slug ) : ?string { |
| 394 | 758 | if ( is_numeric( $id_or_fse_slug ) ) { |
| 759 | + if ( | |
| 760 | + ! current_user_can( 'edit_post', $id_or_fse_slug ) && | |
| 761 | + get_post_status( $id_or_fse_slug ) !== 'publish' | |
| 762 | + ) { | |
| 763 | + return null; | |
| 764 | + } | |
| 395 | 765 | return get_post_field( 'post_content', intval( $id_or_fse_slug ) ); |
| 396 | 766 | } elseif ( |
| 397 | 767 | ! empty( $template = get_block_template( $id_or_fse_slug, 'wp_template_part' ) ) || |
| 398 | 768 | ! empty( $template = get_block_template( $id_or_fse_slug ) ) |
| @@ -402,12 +772,20 @@ | ||
| 402 | 772 | return null; |
| 403 | 773 | } |
| 404 | 774 | |
| 405 | 775 | public static function get_block_attributes( string $post_id, string $block_id, string $block_name ) : array { |
| 406 | - if ( ! is_null( $post_content = self::get_content_by_object_id( $post_id ) ) ) { | |
| 407 | - if ( is_array( $blocks = parse_blocks( $post_content ) ) ) { | |
| 408 | - return self::get_block_attributes_recursive( $block_id, $block_name, $blocks ); | |
| 409 | - } | |
| 776 | + // Cache parsed blocks per object id for the request — this is called once | |
| 777 | + // per loop/REST lookup and would otherwise re-fetch + re-parse the whole | |
| 778 | + // post content every time. | |
| 779 | + static $parsed_cache = []; | |
| 780 | + if ( ! array_key_exists( $post_id, $parsed_cache ) ) { | |
| 781 | + $post_content = self::get_content_by_object_id( $post_id ); | |
| 782 | + $parsed_cache[ $post_id ] = ( ! is_null( $post_content ) && is_array( $blocks = parse_blocks( $post_content ) ) ) | |
| 783 | + ? $blocks | |
| 784 | + : null; | |
| 785 | + } | |
| 786 | + if ( is_array( $parsed_cache[ $post_id ] ) ) { | |
| 787 | + return self::get_block_attributes_recursive( $block_id, $block_name, $parsed_cache[ $post_id ] ); | |
| 410 | 788 | } |
| 411 | 789 | return []; |
| 412 | 790 | } |
| 413 | 791 | |