| @@ -8,13 +8,19 @@ | ||
| 8 | 8 | use ABlocks\Classes\FileUpload; |
| 9 | 9 | use ABlocks\Classes\AssetsGenerator; |
| 10 | 10 | use ABlocks\Classes\RegisterScripts; |
| 11 | 11 | use ABlocks\Classes\GlobalCssGenerator; |
| 12 | +use ABlocks\Classes\GlobalClasses; | |
| 13 | +use ABlocks\Classes\AtomicStyles; | |
| 12 | 14 | use ABlocks\Classes\FontLoadLocally; |
| 13 | 15 | use ABlocks\Admin\Menu; |
| 14 | 16 | use ABlocks\Helper; |
| 15 | 17 | |
| 16 | 18 | class Assets { |
| 19 | + | |
| 20 | + /** Records which plugin version last generated the page stylesheets. */ | |
| 21 | + const BUILD_OPTION = 'ablocks_assets_build'; | |
| 22 | + | |
| 17 | 23 | public $current_page_template_part = []; |
| 18 | 24 | public $current_page_blocks = []; |
| 19 | 25 | private $FileUpload; |
| 20 | 26 | private $current_page_slug = ''; |
| @@ -37,8 +43,14 @@ | ||
| 37 | 43 | add_action( 'enqueue_block_editor_assets', [ $self, 'global_css_variable' ] ); |
| 38 | 44 | add_action( 'enqueue_block_editor_assets', [ $self, 'add_editor_inline_css' ] ); |
| 39 | 45 | add_action( 'enqueue_block_editor_assets', [ $self, 'editor_google_fonts' ] ); |
| 40 | 46 | |
| 47 | + // The localized ablocks_nonce is minted once per page load, so a tab left | |
| 48 | + // open past the nonce lifetime (or across a re-login) 403s on every | |
| 49 | + // aBlocks request. Renew it the way core renews wp_rest. | |
| 50 | + add_filter( 'wp_refresh_nonces', [ $self, 'refresh_heartbeat_nonce' ] ); | |
| 51 | + add_action( 'wp_ajax_ablocks/refresh_nonce', [ $self, 'ajax_refresh_nonce' ] ); | |
| 52 | + | |
| 41 | 53 | // Detect page |
| 42 | 54 | add_action( 'wp', array( $self, 'detect_page' ) ); |
| 43 | 55 | |
| 44 | 56 | if ( ! is_admin() && Helper::is_enabled_assets_generation() ) { |
| @@ -106,18 +118,32 @@ | ||
| 106 | 118 | 'ajax_url' => esc_url( admin_url( 'admin-ajax.php' ) ), |
| 107 | 119 | 'is_pro' => (bool) Helper::is_active_ablocks_pro(), |
| 108 | 120 | 'is_archive' => (bool) is_archive(), |
| 109 | 121 | 'archive_post_type' => $this->get_current_archive_post_type(), |
| 122 | + // Responsive breakpoint widths (px) shared with the JS CSS generators | |
| 123 | + // and the block editor so media queries match the frontend. | |
| 124 | + 'breakpoints' => Helper::get_breakpoints(), | |
| 125 | + // Ordered device list (Desktop + built-ins + custom breakpoints) for | |
| 126 | + // the atomic block's responsive device switcher. Widest-first, so | |
| 127 | + // the editor and the frontend agree on breakpoint precedence. | |
| 128 | + 'responsive_devices' => Helper::get_responsive_devices(), | |
| 129 | + // Whether breakpoint queries overlap (cascade) or are exclusive | |
| 130 | + // bands (strict). The editor preview builds matching queries. | |
| 131 | + 'breakpoint_mode' => Helper::get_breakpoint_mode(), | |
| 110 | 132 | ]; |
| 111 | 133 | } |
| 112 | 134 | |
| 113 | 135 | public function get_localize_dashboard_data() { |
| 114 | - if ( ! current_user_can( 'manage_options' ) ) { | |
| 136 | + // Anyone permitted to reach an aBlocks screen needs the nonce, or the | |
| 137 | + // dashboard loads and then 403s on every request it makes. | |
| 138 | + if ( ! current_user_can( Permissions::ACCESS ) ) { | |
| 115 | 139 | return $this->get_localize_script_data(); |
| 116 | 140 | } |
| 117 | 141 | return array_merge($this->get_localize_script_data(), [ |
| 118 | 142 | 'nonce' => wp_create_nonce( 'wp_rest' ), |
| 119 | 143 | 'ablocks_nonce' => wp_create_nonce( 'ablocks_nonce' ), |
| 144 | + 'permissions' => Permissions::for_user(), | |
| 145 | + 'is_admin_user' => Permissions::is_real_admin(), | |
| 120 | 146 | ]); |
| 121 | 147 | } |
| 122 | 148 | public function get_localize_editor_data() { |
| 123 | 149 | if ( ! current_user_can( 'edit_posts' ) ) { |
| @@ -140,8 +166,42 @@ | ||
| 140 | 166 | return $data; |
| 141 | 167 | } |
| 142 | 168 | |
| 143 | 169 | /** |
| 170 | + * Whether the current user is issued ablocks_nonce in the editor or the | |
| 171 | + * dashboard, and so may have it renewed. Mirrors the gates above. | |
| 172 | + */ | |
| 173 | + private function can_renew_nonce() { | |
| 174 | + return is_user_logged_in() && ( current_user_can( 'edit_posts' ) || current_user_can( Permissions::ACCESS ) ); | |
| 175 | + } | |
| 176 | + | |
| 177 | + /** | |
| 178 | + * Heartbeat: hand an open page a fresh ablocks_nonce whenever core refreshes | |
| 179 | + * its own nonces (the page's nonces are ageing, or the session changed). | |
| 180 | + * | |
| 181 | + * @param array $response The Heartbeat response. | |
| 182 | + * @return array | |
| 183 | + */ | |
| 184 | + public function refresh_heartbeat_nonce( $response ) { | |
| 185 | + if ( $this->can_renew_nonce() ) { | |
| 186 | + $response['ablocks_nonce'] = wp_create_nonce( 'ablocks_nonce' ); | |
| 187 | + } | |
| 188 | + return $response; | |
| 189 | + } | |
| 190 | + | |
| 191 | + /** | |
| 192 | + * A fresh ablocks_nonce for a request that was rejected with a stale one — | |
| 193 | + * the aBlocks counterpart of core's `rest-nonce` action. Logged-in only, and | |
| 194 | + * only for users who would be given the nonce on page load anyway. | |
| 195 | + */ | |
| 196 | + public function ajax_refresh_nonce() { | |
| 197 | + if ( ! $this->can_renew_nonce() ) { | |
| 198 | + wp_send_json_error( [ 'message' => 'forbidden' ], 403 ); | |
| 199 | + } | |
| 200 | + wp_send_json_success( [ 'nonce' => wp_create_nonce( 'ablocks_nonce' ) ] ); | |
| 201 | + } | |
| 202 | + | |
| 203 | + /** | |
| 144 | 204 | * Whether the frontend ABlocksGlobal payload has been attached this request. |
| 145 | 205 | * |
| 146 | 206 | * @var bool |
| 147 | 207 | */ |
| @@ -196,8 +256,11 @@ | ||
| 196 | 256 | 'academy_lms' => Helper::is_active_academy(), |
| 197 | 257 | 'storeengine' => Helper::is_active_storeengine(), |
| 198 | 258 | 'wp_map_block' => Helper::is_active_wp_map_block(), |
| 199 | 259 | 'easy_content_manager' => Helper::is_active_easy_content_manager(), |
| 260 | + 'zencommunity' => Helper::is_active_zencommunity(), | |
| 261 | + 'gemboards' => Helper::is_active_gemboards(), | |
| 262 | + 'quizpress' => Helper::is_active_quizpress(), | |
| 200 | 263 | ] |
| 201 | 264 | ); |
| 202 | 265 | return apply_filters( |
| 203 | 266 | 'ablocks/assets/dashboard_scripts_data', |
| @@ -229,8 +292,11 @@ | ||
| 229 | 292 | 'global_color' => (array) Helper::get_settings( 'global_color', [] ), |
| 230 | 293 | 'global_typography' => (array) Helper::get_settings( 'global_typography', [] ), |
| 231 | 294 | 'global_typography_list' => wp_list_pluck( Helper::get_settings( 'global_typography', [] ), 'value', 'id' ), |
| 232 | 295 | 'global_font_family_fallback' => (string) Helper::get_settings( 'global_font_family_fallback', 'Sans-serif' ), |
| 296 | + // Editor paste — see includes/api/paste-controller.php. | |
| 297 | + 'paste_google_docs' => (bool) Helper::get_settings( 'paste_google_docs', true ), | |
| 298 | + 'paste_convert_webp' => (bool) Helper::get_settings( 'paste_convert_webp', true ), | |
| 233 | 299 | // Theme.json + Font Library families, so uploaded/theme fonts are |
| 234 | 300 | // selectable next to the Google catalog. |
| 235 | 301 | 'theme_fonts' => \ABlocks\Classes\FontStack::get_theme_font_families(), |
| 236 | 302 | ], |
| @@ -243,9 +309,13 @@ | ||
| 243 | 309 | 'quizpress' => Helper::is_active_quizpress(), |
| 244 | 310 | 'easy_content_manager' => Helper::is_active_easy_content_manager(), |
| 245 | 311 | ], |
| 246 | 312 | 'blocks_status' => $ablocks_blocks, |
| 247 | - 'post_types' => $post_types | |
| 313 | + 'post_types' => $post_types, | |
| 314 | + // What this user may do inside the editor. Read by | |
| 315 | + // src/utils/permissions.js to hide controls they cannot use. | |
| 316 | + 'permissions' => Permissions::for_user(), | |
| 317 | + 'is_admin_user' => Permissions::is_real_admin(), | |
| 248 | 318 | ); |
| 249 | 319 | return apply_filters( |
| 250 | 320 | 'ablocks/assets/editor_scripts_data', |
| 251 | 321 | array_merge( |
| @@ -807,23 +877,113 @@ | ||
| 807 | 877 | |
| 808 | 878 | public function is_assets_generated() { |
| 809 | 879 | $file_name = $this->current_page_slug; |
| 810 | 880 | $css_file_path = $this->FileUpload->get_file_path( $file_name . '.min.css' ); |
| 811 | - return file_exists( $css_file_path ); | |
| 881 | + | |
| 882 | + if ( ! file_exists( $css_file_path ) ) { | |
| 883 | + return false; | |
| 884 | + } | |
| 885 | + | |
| 886 | + // The file embeds the global-class CSS this page needs, so editing a | |
| 887 | + // class has to make every generated stylesheet stale. Comparing the | |
| 888 | + // file's mtime against the library's change stamp rebuilds them lazily, | |
| 889 | + // one page at a time on next visit, without stamping a revision into | |
| 890 | + // every file name (which the per-post delete in Blocks relies on). | |
| 891 | + // | |
| 892 | + // Strictly greater, not >=: mtime has one-second resolution, so a file | |
| 893 | + // written in the same second as a class edit is indistinguishable from | |
| 894 | + // one written just after it. Treating that tie as stale costs one extra | |
| 895 | + // regeneration; treating it as fresh would serve the old CSS until the | |
| 896 | + // next edit. | |
| 897 | + return filemtime( $css_file_path ) > max( | |
| 898 | + GlobalClasses::get_revision(), | |
| 899 | + self::build_revision() | |
| 900 | + ); | |
| 812 | 901 | } |
| 813 | 902 | |
| 903 | + /** | |
| 904 | + * When this plugin's own CSS last changed, as a timestamp. | |
| 905 | + * | |
| 906 | + * The generated file bakes in each block's static stylesheet (see | |
| 907 | + * AssetsGenerator, which concatenates get_static_css() into it), so a CSS | |
| 908 | + * fix shipped in an update reached nobody whose pages were generated before | |
| 909 | + * it: the file still existed, still parsed, and nothing about it looked | |
| 910 | + * stale, so it was served unchanged forever. Verified by planting a | |
| 911 | + * stylesheet carrying the previous release's rules — it survived every | |
| 912 | + * subsequent page load untouched. That is why a fix could land in the | |
| 913 | + * editor, which loads each block's style.css directly, and never appear on | |
| 914 | + * the front end. | |
| 915 | + * | |
| 916 | + * Keyed on the version rather than a file scan: it is one option read on | |
| 917 | + * the common path, and every shipped CSS change comes with a version bump. | |
| 918 | + * Each page then rebuilds once, on its next visit, exactly the way a | |
| 919 | + * global-class edit already makes them rebuild. | |
| 920 | + * | |
| 921 | + * @return int Timestamp of the running version's first request. | |
| 922 | + */ | |
| 923 | + public static function build_revision() { | |
| 924 | + $stamp = get_option( self::BUILD_OPTION ); | |
| 925 | + | |
| 926 | + // The compiler's output revision is part of the key, so an emission | |
| 927 | + // change stales every baked page even without a version bump. See | |
| 928 | + // AtomicStyles::OUTPUT_REVISION. | |
| 929 | + $build = ABLOCKS_VERSION . '+' . AtomicStyles::OUTPUT_REVISION; | |
| 930 | + | |
| 931 | + if ( | |
| 932 | + is_array( $stamp ) && | |
| 933 | + isset( $stamp['version'], $stamp['time'] ) && | |
| 934 | + $build === $stamp['version'] | |
| 935 | + ) { | |
| 936 | + return (int) $stamp['time']; | |
| 937 | + } | |
| 938 | + | |
| 939 | + $now = time(); | |
| 940 | + update_option( | |
| 941 | + self::BUILD_OPTION, | |
| 942 | + [ | |
| 943 | + 'version' => $build, | |
| 944 | + 'time' => $now, | |
| 945 | + ], | |
| 946 | + true | |
| 947 | + ); | |
| 948 | + | |
| 949 | + return $now; | |
| 950 | + } | |
| 951 | + | |
| 814 | 952 | public function set_current_page_template_part( $content, $block ) { |
| 815 | 953 | if ( ! isset( $block['blockName'] ) && is_array( $block ) ) { |
| 816 | 954 | foreach ( $block as $block_item ) { |
| 817 | - if ( ! empty( $block_item['blockName'] ) && strpos( $block_item['blockName'], 'ablocks/' ) !== false ) { | |
| 955 | + if ( $this->is_page_asset_block( $block_item ) ) { | |
| 818 | 956 | $this->current_page_blocks[] = $block_item; |
| 819 | 957 | } |
| 820 | 958 | } |
| 821 | 959 | } |
| 822 | - if ( ! empty( $block['blockName'] ) && strpos( $block['blockName'], 'ablocks/' ) !== false ) { | |
| 960 | + if ( $this->is_page_asset_block( $block ) ) { | |
| 823 | 961 | $this->current_page_blocks[] = $block; |
| 824 | 962 | } |
| 825 | 963 | return $content; |
| 964 | + } | |
| 965 | + | |
| 966 | + /** | |
| 967 | + * Whether a top-level block contributes to the page's generated assets. | |
| 968 | + * | |
| 969 | + * A synced pattern counts too: AssetsGenerator::recursive_block_parser() | |
| 970 | + * already expands its reference. Collecting only `ablocks/*` names left a | |
| 971 | + * page whose content is just a pattern with no combined CSS/JS at all — so a | |
| 972 | + * Loop Filter placed from a pattern rendered unstyled and did nothing when | |
| 973 | + * clicked. | |
| 974 | + * | |
| 975 | + * @param mixed $block Parsed block. | |
| 976 | + * @return bool | |
| 977 | + */ | |
| 978 | + private function is_page_asset_block( $block ) { | |
| 979 | + if ( ! is_array( $block ) || empty( $block['blockName'] ) ) { | |
| 980 | + return false; | |
| 981 | + } | |
| 982 | + if ( 'core/block' === $block['blockName'] ) { | |
| 983 | + return ! empty( $block['attrs']['ref'] ); | |
| 984 | + } | |
| 985 | + return strpos( $block['blockName'], 'ablocks/' ) !== false; | |
| 826 | 986 | } |
| 827 | 987 | |
| 828 | 988 | public function set_theme_builder_locations( $args ) { |
| 829 | 989 | $this->theme_builder_locations = $args; |