| @@ -41,13 +41,22 @@ | ||
| 41 | 41 | const ASSET_PATH = NOTIFICATIONX_ASSETS_PATH . 'public/'; |
| 42 | 42 | protected $notificationXArr = []; |
| 43 | 43 | |
| 44 | 44 | /** |
| 45 | + * Block-editor bars whose block assets are already on this page, keyed by | |
| 46 | + * the bar's `gutenberg_id` (see enqueue_gutenberg_bar_assets()). | |
| 47 | + * | |
| 48 | + * @var array<int, true> | |
| 49 | + */ | |
| 50 | + protected $gutenberg_bar_assets = []; | |
| 51 | + | |
| 52 | + /** | |
| 45 | 53 | * Initially Invoked |
| 46 | 54 | * when its initialized. |
| 47 | 55 | */ |
| 48 | 56 | public function __construct() { |
| 49 | 57 | Analytics::get_instance(); |
| 58 | + BarSpace::get_instance(); | |
| 50 | 59 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reviewed for the NotificationX codebase: acceptable in this context. |
| 51 | 60 | if (!is_admin() || !empty($_GET['frontend'])) { |
| 52 | 61 | add_action('init', [$this, 'init'], 10); |
| 53 | 62 | } |
| @@ -66,13 +75,134 @@ | ||
| 66 | 75 | add_filter('nx_fallback_data', [$this, 'fallback_data'], 10, 3); |
| 67 | 76 | add_filter('nx_filtered_data', [$this, 'filtered_data'], 9999, 3); |
| 68 | 77 | add_filter('nx_filtered_post', [$this, 'filtered_post'], 9999, 2); |
| 69 | 78 | add_action('wp_print_footer_scripts', [$this, 'footer_scripts']); |
| 79 | + // After wp_enqueue_scripts (wp_head priority 1) has collected the page's bars. | |
| 80 | + add_action('wp_head', [$this, 'print_bar_reserve'], 3); | |
| 70 | 81 | add_filter('body_class', [ $this, 'nx_add_body_class' ] ); |
| 82 | + add_filter('style_loader_tag', [$this, 'non_blocking_style_tag'], 10, 2); | |
| 71 | 83 | |
| 72 | 84 | } |
| 73 | 85 | |
| 74 | 86 | /** |
| 87 | + * Third-party stylesheets used by the notification themes, keyed by style | |
| 88 | + * handle. | |
| 89 | + * | |
| 90 | + * They used to be CSS `@import`s at the top of frontend.css, which the | |
| 91 | + * browser only discovers once frontend.css has downloaded: a serial, | |
| 92 | + * render-blocking chain to two more origins on every page. They are now | |
| 93 | + * enqueued next to `notificationx-public` and loaded without blocking the | |
| 94 | + * first paint (see non_blocking_style_tag()). | |
| 95 | + * | |
| 96 | + * @since 3.3.3 | |
| 97 | + * @return array<string, string> Style handle => URL. | |
| 98 | + */ | |
| 99 | + public function get_external_styles() { | |
| 100 | + $styles = []; | |
| 101 | + /** | |
| 102 | + * Filters whether NotificationX loads Open Sans from Google Fonts. | |
| 103 | + * | |
| 104 | + * Return false if the site already loads Open Sans or must not | |
| 105 | + * contact Google Fonts; the themes then fall back to sans-serif. | |
| 106 | + * | |
| 107 | + * @since 3.3.3 | |
| 108 | + * @param bool $load Default true. | |
| 109 | + */ | |
| 110 | + if (apply_filters('notificationx_load_open_sans', true)) { | |
| 111 | + $styles['notificationx-open-sans'] = 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700&display=swap'; | |
| 112 | + } | |
| 113 | + /** | |
| 114 | + * Filters whether NotificationX loads FontAwesome 4.7 from cdnjs. | |
| 115 | + * | |
| 116 | + * The icons are drawn in pseudo-elements of a few themes only. Return | |
| 117 | + * false if the site already loads FontAwesome 4. | |
| 118 | + * | |
| 119 | + * @since 3.3.3 | |
| 120 | + * @param bool $load Default true. | |
| 121 | + */ | |
| 122 | + if (apply_filters('notificationx_load_fontawesome', true)) { | |
| 123 | + $styles['notificationx-fontawesome-4'] = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css'; | |
| 124 | + } | |
| 125 | + return $styles; | |
| 126 | + } | |
| 127 | + | |
| 128 | + /** | |
| 129 | + * Enqueue the third-party font and icon stylesheets (get_external_styles()). | |
| 130 | + * | |
| 131 | + * Call this wherever `notificationx-public` (or a stylesheet that bundles | |
| 132 | + * the frontend themes) is enqueued. | |
| 133 | + * | |
| 134 | + * @since 3.3.3 | |
| 135 | + * @return void | |
| 136 | + */ | |
| 137 | + public function enqueue_external_styles() { | |
| 138 | + foreach ($this->get_external_styles() as $handle => $src) { | |
| 139 | + // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- Third-party URL; a ?ver= query would only split the CDN cache. | |
| 140 | + wp_enqueue_style($handle, $src, [], null); | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + /** | |
| 145 | + * Load some stylesheets without blocking the first paint. | |
| 146 | + * | |
| 147 | + * - `notificationx-public` styles only what the runtime renders — bars, | |
| 148 | + * popups and shortcodes all mount from REST data after the page has | |
| 149 | + * loaded (the bar's reserved space is a separate inline style), and the | |
| 150 | + * runtime waits for this sheet before its first render (whenStyled()). | |
| 151 | + * At ~600 KB it was the largest render-blocking stylesheet on every page. | |
| 152 | + * - `notificationx-gdpr-modal` styles only the cookie-preferences modal, | |
| 153 | + * which opens on a click. | |
| 154 | + * - The font and icon stylesheets from get_external_styles(): the fonts | |
| 155 | + * swap in, and the FontAwesome glyphs are drawn only in pseudo-elements | |
| 156 | + * of notifications the runtime renders after the page has loaded. | |
| 157 | + * | |
| 158 | + * The stylesheet is requested as `print` and switched to `all` once it has | |
| 159 | + * loaded; the <noscript> copy keeps it for visitors without JavaScript. | |
| 160 | + * `data-nx-style` marks it for the runtime, which also switches it (see | |
| 161 | + * applyDeferredStyles() in external-styles.ts) in case the inline handler | |
| 162 | + * was stripped by an optimizer or blocked by a Content Security Policy. | |
| 163 | + * | |
| 164 | + * @param string $tag The link tag. | |
| 165 | + * @param string $handle Style handle. | |
| 166 | + * @return string | |
| 167 | + */ | |
| 168 | + public function non_blocking_style_tag($tag, $handle) { | |
| 169 | + $handles = ['notificationx-public', 'notificationx-gdpr-modal', 'notificationx-open-sans', 'notificationx-fontawesome-4']; | |
| 170 | + if (!in_array($handle, $handles, true) || false !== strpos($tag, 'onload=')) { | |
| 171 | + return $tag; | |
| 172 | + } | |
| 173 | + $deferred = preg_replace('/\smedia=([\'"])all\1/', ' media=$1print$1 onload="this.media=\'all\'" data-nx-style=$1print$1', $tag, 1, $count); | |
| 174 | + if (!$count) { | |
| 175 | + return $tag; | |
| 176 | + } | |
| 177 | + return $deferred . '<noscript>' . trim($tag) . "</noscript>\n"; | |
| 178 | + } | |
| 179 | + | |
| 180 | + /** | |
| 181 | + * Script dependencies of the `notificationx-public` runtime. | |
| 182 | + * | |
| 183 | + * `wp-hooks` provides `window.wp.hooks`, the registry the runtime reads its | |
| 184 | + * frontend filters from (see nxdev/notificationx/frontend/core/hooks.ts and | |
| 185 | + * docs/api/frontend-js-hooks.md). It is always kept, even if a filter drops | |
| 186 | + * it, because without it every add-on filter silently stops firing. | |
| 187 | + * | |
| 188 | + * @since 3.3.3 | |
| 189 | + * @return string[] | |
| 190 | + */ | |
| 191 | + public function get_script_dependencies() { | |
| 192 | + /** | |
| 193 | + * Filters the script dependencies of the `notificationx-public` runtime. | |
| 194 | + * | |
| 195 | + * @since 3.3.3 | |
| 196 | + * @param string[] $deps Script handles. | |
| 197 | + */ | |
| 198 | + $deps = apply_filters( 'nx_frontend_script_deps', [ 'wp-hooks' ] ); | |
| 199 | + $deps = is_array( $deps ) ? array_filter( $deps, 'is_string' ) : []; | |
| 200 | + array_unshift( $deps, 'wp-hooks' ); | |
| 201 | + return array_values( array_unique( $deps ) ); | |
| 202 | + } | |
| 203 | + | |
| 204 | + /** | |
| 75 | 205 | * This method is responsible for enqueueing scripts for public use. |
| 76 | 206 | * |
| 77 | 207 | * @return void |
| 78 | 208 | */ |
| @@ -78,11 +208,15 @@ | ||
| 78 | 208 | */ |
| 79 | 209 | public function enqueue_scripts() { |
| 80 | 210 | $custom_css = $this->generate_custom_css(); |
| 81 | 211 | // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context. |
| 82 | - wp_register_script('notificationx-public', Helper::file('public/js/frontend.js', true), [], apply_filters('nx_frontend_js_version', NOTIFICATIONX_VERSION ), true); | |
| 212 | + wp_register_script('notificationx-public', Helper::file('public/js/frontend.js', true), $this->get_script_dependencies(), apply_filters('nx_frontend_js_version', NOTIFICATIONX_VERSION ), true); | |
| 83 | 213 | // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context. |
| 84 | 214 | wp_register_style('notificationx-public', Helper::file('public/css/frontend.css', true), [], apply_filters('nx_frontend_css_version', NOTIFICATIONX_VERSION ), 'all'); |
| 215 | + // GDPR cookie-customisation modal styles, split out of frontend.css | |
| 216 | + // (~305 KB) so only pages with an active GDPR notice load them. | |
| 217 | + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context. | |
| 218 | + wp_register_style('notificationx-gdpr-modal', Helper::file('public/css/gdpr-modal.css', true), ['notificationx-public'], apply_filters('nx_frontend_css_version', NOTIFICATIONX_VERSION ), 'all'); | |
| 85 | 219 | // wp_register_style('notificationx-icon-pack', Helper::file('public/icon/style.css', true), [], NOTIFICATIONX_VERSION, 'all'); |
| 86 | 220 | // Localize scripts for frontend |
| 87 | 221 | wp_localize_script( |
| 88 | 222 | 'notificationx-public', |
| @@ -150,8 +284,12 @@ | ||
| 150 | 284 | } |
| 151 | 285 | } |
| 152 | 286 | |
| 153 | 287 | wp_enqueue_style('notificationx-public'); |
| 288 | + $this->enqueue_external_styles(); | |
| 289 | + if ( ! empty( $this->notificationXArr['gdpr'] ) ) { | |
| 290 | + wp_enqueue_style('notificationx-gdpr-modal'); | |
| 291 | + } | |
| 154 | 292 | wp_enqueue_script('notificationx-public'); |
| 155 | 293 | wp_enqueue_style('dashicons'); |
| 156 | 294 | do_action('notificationx_scripts', $this->notificationXArr); |
| 157 | 295 | wp_add_inline_style( 'notificationx-public', $custom_css ); |
| @@ -161,13 +299,76 @@ | ||
| 161 | 299 | // LATER |
| 162 | 300 | } |
| 163 | 301 | } |
| 164 | 302 | |
| 303 | + /** | |
| 304 | + * Reserve the top bar's measured height before it mounts (see BarSpace). | |
| 305 | + * | |
| 306 | + * @return void | |
| 307 | + */ | |
| 308 | + public function print_bar_reserve() { | |
| 309 | + // The builder preview passes preview data here, not bar IDs. | |
| 310 | + if (!empty($this->notificationXArr['nxPreview']) || empty($this->notificationXArr['pressbar']) || !is_array($this->notificationXArr['pressbar'])) { | |
| 311 | + return; | |
| 312 | + } | |
| 313 | + $ids = array_filter($this->notificationXArr['pressbar'], 'is_numeric'); | |
| 314 | + if ($ids) { | |
| 315 | + BarSpace::get_instance()->print_reserve($ids); | |
| 316 | + } | |
| 317 | + } | |
| 318 | + | |
| 165 | 319 | public function nx_add_body_class($classes) { |
| 166 | 320 | $classes[] = 'has-notificationx'; |
| 321 | + // Tells the runtime it need not fetch these bars' pages for their assets. | |
| 322 | + foreach (array_keys($this->gutenberg_bar_assets) as $gutenberg_id) { | |
| 323 | + $classes[] = 'nx-bar-assets-' . $gutenberg_id; | |
| 324 | + } | |
| 167 | 325 | return $classes; |
| 168 | 326 | } |
| 169 | 327 | |
| 328 | + /** | |
| 329 | + * Put a block-editor bar's block assets on the current page. | |
| 330 | + * | |
| 331 | + * The bar's HTML arrives over REST after load, where anything its blocks | |
| 332 | + * enqueue is discarded. The runtime used to recover those assets by | |
| 333 | + * fetching the bar's own permalink — a full themed page (~300 KB on | |
| 334 | + * essential-blocks.com) — and loading each stylesheet and script it had | |
| 335 | + * that this page did not, one after another, before the bar could settle. | |
| 336 | + * Rendering the blocks here, while this page's assets are still being | |
| 337 | + * collected, lets each block enqueue what it needs (e.g. a countdown's | |
| 338 | + * frontend script) exactly as it would in post content; the output is | |
| 339 | + * discarded. Mirrors the Elementor branch above. | |
| 340 | + * | |
| 341 | + * Runs for page loads only: REST and admin requests have no page to add | |
| 342 | + * assets to. | |
| 343 | + * | |
| 344 | + * @param int|string $gutenberg_id The bar's block post ID. | |
| 345 | + * @return void | |
| 346 | + */ | |
| 347 | + protected function enqueue_gutenberg_bar_assets($gutenberg_id) { | |
| 348 | + $gutenberg_id = absint($gutenberg_id); | |
| 349 | + if (!$gutenberg_id || isset($this->gutenberg_bar_assets[$gutenberg_id]) || !doing_action('wp_enqueue_scripts')) { | |
| 350 | + return; | |
| 351 | + } | |
| 352 | + // Same lookup as PressBar::print_bar_notice(), which renders the HTML the runtime shows. | |
| 353 | + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context. | |
| 354 | + $post_id = apply_filters('wpml_object_id', $gutenberg_id, 'wp_block', true); | |
| 355 | + $post = $post_id ? get_post($post_id) : null; | |
| 356 | + if (!$post || '' === trim((string) $post->post_content)) { | |
| 357 | + return; | |
| 358 | + } | |
| 359 | + // This runs inside <head> (wp_enqueue_scripts). A block whose render | |
| 360 | + // callback echoes instead of returning would print into <head> and push | |
| 361 | + // every later head tag into <body>; buffer and discard all output. | |
| 362 | + ob_start(); | |
| 363 | + try { | |
| 364 | + do_blocks($post->post_content); | |
| 365 | + } finally { | |
| 366 | + ob_end_clean(); | |
| 367 | + } | |
| 368 | + $this->gutenberg_bar_assets[$gutenberg_id] = true; | |
| 369 | + } | |
| 370 | + | |
| 170 | 371 | private function separate_css($css) { |
| 171 | 372 | $media_css = ''; |
| 172 | 373 | $normal_css = ''; |
| 173 | 374 | |
| @@ -194,9 +395,10 @@ | ||
| 194 | 395 | public function generate_custom_css() { |
| 195 | 396 | $posts = Database::get_instance()->get_posts(Database::$table_posts, '*', ['enabled' => true] ); |
| 196 | 397 | $combine_css = ""; |
| 197 | 398 | foreach ($posts as $post) { |
| 198 | - if( !empty( $post['data']['add_custom_css'] ) && !empty( $post['nx_id'] ) ) { | |
| 399 | + // Raw rows (not normalize_post()), so check the type here. | |
| 400 | + if( !empty( $post['data']['add_custom_css'] ) && is_string( $post['data']['add_custom_css'] ) && !empty( $post['nx_id'] ) ) { | |
| 199 | 401 | $separatedCss = $this->separate_css($post['data']['add_custom_css']); |
| 200 | 402 | if( !empty( $post['data']['source'] ) && $post['data']['source'] == 'press_bar' ) { |
| 201 | 403 | $combine_css .= "{$separatedCss['normal_css']} {$separatedCss['media_css']} "; |
| 202 | 404 | } else if( !empty( $post['data']['source'] ) && $post['data']['source'] == 'gdpr_notification' ) { |
| @@ -229,11 +431,48 @@ | ||
| 229 | 431 | public function localizeScripts() { |
| 230 | 432 | return []; |
| 231 | 433 | } |
| 232 | 434 | |
| 435 | + /** | |
| 436 | + * The URL WordPress prints for a registered stylesheet. | |
| 437 | + * | |
| 438 | + * @param string $handle Style handle. | |
| 439 | + * @return string Empty when the handle has no source. | |
| 440 | + */ | |
| 441 | + protected function style_url($handle) { | |
| 442 | + $style = wp_styles()->query($handle, 'registered'); | |
| 443 | + if (!$style || empty($style->src) || !is_string($style->src)) { | |
| 444 | + return ''; | |
| 445 | + } | |
| 446 | + $src = $style->src; | |
| 447 | + // Same version rule as WP_Styles::_css_href(). | |
| 448 | + $ver = null === $style->ver ? '' : ($style->ver ? $style->ver : get_bloginfo('version')); | |
| 449 | + if ('' !== $ver) { | |
| 450 | + $src = add_query_arg('ver', $ver, $src); | |
| 451 | + } | |
| 452 | + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core filter, so CDN rewrites apply. | |
| 453 | + return (string) apply_filters('style_loader_src', $src, $handle); | |
| 454 | + } | |
| 455 | + | |
| 233 | 456 | public function get_localize_data($data) { |
| 234 | 457 | $data['rest'] = REST::get_instance()->rest_data(false); |
| 235 | 458 | $data['assets'] = self::ASSET_URL; |
| 459 | + if (empty($data['cross'])) { | |
| 460 | + // The runtime re-adds these if an optimizer combined them into a | |
| 461 | + // bundle that does not apply (e.g. a print-only one). | |
| 462 | + $styles = []; | |
| 463 | + foreach (['notificationx-public', 'notificationx-gdpr-modal'] as $handle) { | |
| 464 | + if (wp_style_is($handle, 'enqueued') || wp_style_is($handle, 'done')) { | |
| 465 | + $url = $this->style_url($handle); | |
| 466 | + if ($url) { | |
| 467 | + $styles[$handle] = $url; | |
| 468 | + } | |
| 469 | + } | |
| 470 | + } | |
| 471 | + if ($styles) { | |
| 472 | + $data['styles'] = $styles; | |
| 473 | + } | |
| 474 | + } | |
| 236 | 475 | $data['is_pro'] = false; |
| 237 | 476 | $data['gmt_offset'] = get_option('gmt_offset'); |
| 238 | 477 | $data['lang'] = get_locale(); |
| 239 | 478 | $data['common_assets'] = NOTIFICATIONX_COMMON_URL; |
| @@ -243,8 +482,12 @@ | ||
| 243 | 482 | 'queried_id' => get_queried_object_id(), |
| 244 | 483 | 'pid' => !empty($GLOBALS['post']->ID) ? $GLOBALS['post']->ID : 0, |
| 245 | 484 | ]; |
| 246 | 485 | $data['localeData'] = load_script_textdomain('notificationx-public', 'notificationx'); |
| 486 | + if (!empty($data['cross'])) { | |
| 487 | + // Cross-domain embeds have no WordPress enqueue: the runtime adds these. | |
| 488 | + $data['external_styles'] = $this->get_external_styles(); | |
| 489 | + } | |
| 247 | 490 | return $data; |
| 248 | 491 | } |
| 249 | 492 | |
| 250 | 493 | public function get_notifications_data($params) { |
| @@ -273,8 +516,13 @@ | ||
| 273 | 516 | 'shortcode' => [], |
| 274 | 517 | 'inline_shortcode' => false, |
| 275 | 518 | ] |
| 276 | 519 | ); |
| 520 | + // These come straight from the public REST `notice` request, where | |
| 521 | + // `?global=1` arrives as a string; the code below needs ID lists. | |
| 522 | + foreach (['global', 'active', 'pressbar', 'gdpr', 'popup', 'exit_intent', 'shortcode'] as $list_key) { | |
| 523 | + $params[$list_key] = NotificationX::get_instance()->normalize_multiple_value($params[$list_key]); | |
| 524 | + } | |
| 277 | 525 | $global = $params['global']; |
| 278 | 526 | $active = $params['active']; |
| 279 | 527 | $pressbar = $params['pressbar']; |
| 280 | 528 | $gdpr = $params['gdpr']; |
| @@ -594,8 +842,10 @@ | ||
| 594 | 842 | $bar_notifications[] = $return_posts ? $settings : $settings['nx_id']; |
| 595 | 843 | if (!empty($settings['elementor_id']) && class_exists('\Elementor\Plugin')) { |
| 596 | 844 | // @todo Find a function to only load css instead of building content. |
| 597 | 845 | \Elementor\Plugin::$instance->frontend->get_builder_content($settings['elementor_id'], false); |
| 846 | + } elseif (!empty($settings['gutenberg_id'])) { | |
| 847 | + $this->enqueue_gutenberg_bar_assets($settings['gutenberg_id']); | |
| 598 | 848 | } |
| 599 | 849 | } elseif($settings['source'] == 'gdpr_notification') { |
| 600 | 850 | $gdpr_notification[] = $return_posts ? $settings : $settings['nx_id']; |
| 601 | 851 | } elseif($settings['source'] == 'popup_notification') { |