diff --git a/FeatureShowcase_Plugin_Admin.php b/FeatureShowcase_Plugin_Admin.php index b9589dcc7..11d61bada 100644 --- a/FeatureShowcase_Plugin_Admin.php +++ b/FeatureShowcase_Plugin_Admin.php @@ -240,6 +240,22 @@ private static function get_cards() { return array( 'new' => array( + 'partytown' => array( + 'title' => esc_html__( 'PartyTown', 'w3-total-cache' ), + 'icon' => 'dashicons-performance', + 'text' => esc_html__( 'This feature allows you to optimize third-party scripts by offloading them to web workers using PartyTown. It significantly improves your site\'s performance by moving heavy JavaScript tasks off the main thread.', 'w3-total-cache' ), + 'button' => '', + 'link' => '' . __( 'More info', 'w3-total-cache' ) . '', + 'is_premium' => true, + 'is_new' => true, + 'version' => 'X.X.X', + ), 'alwayscached' => array( 'title' => esc_html__( 'Always Cached', 'w3-total-cache' ), 'icon' => 'dashicons-yes', diff --git a/Generic_Plugin.php b/Generic_Plugin.php index 535589576..8b8b7dad9 100644 --- a/Generic_Plugin.php +++ b/Generic_Plugin.php @@ -550,6 +550,7 @@ function ob_callback( $buffer ) { 'swarmify', 'lazyload', 'removecssjs', + 'partytown', 'deferscripts', 'minify', 'newrelic', diff --git a/Root_Environment.php b/Root_Environment.php index 39619bc2e..27b176e6b 100644 --- a/Root_Environment.php +++ b/Root_Environment.php @@ -178,6 +178,7 @@ private function get_handlers() { new Cdn_Environment(), new Extension_ImageService_Environment(), new Extension_AlwaysCached_Environment(), + new UserExperience_PartyTown_Environment(), ); return $a; diff --git a/UserExperience_GeneralPage_View.php b/UserExperience_GeneralPage_View.php index e1d086085..c393ac618 100644 --- a/UserExperience_GeneralPage_View.php +++ b/UserExperience_GeneralPage_View.php @@ -169,6 +169,40 @@ ) ); + Util_Ui::config_item_extension_enabled( + array( + 'extension_id' => 'user-experience-partytown', + 'checkbox_label' => esc_html__( 'Enable PartyTown', 'w3-total-cache' ), + 'description' => __( + 'This feature allows you to optimize third-party scripts by offloading them to web workers using PartyTown.', + 'w3-total-cache' + ) . ( + UserExperience_PartyTown_Extension::is_enabled() + ? wp_kses( + sprintf( + // translators: 1 opening HTML a tag to W3TC User Experience page, 2 closing HTML a tag. + __( + ' Settings can be found on the %1$sUser Experience page%2$s.', + 'w3-total-cache' + ), + '', + '' + ), + array( + 'a' => array( + 'href' => array(), + ), + ) + ) + : '' + ), + 'label_class' => 'w3tc_single_column', + 'pro' => true, + 'disabled' => ! Util_Environment::is_w3tc_pro( $config ) ? true : false, + 'show_learn_more' => true, + ) + ); + Util_Ui::config_item_extension_enabled( array( 'extension_id' => 'user-experience-preload-requests', diff --git a/UserExperience_PartyTown_Environment.php b/UserExperience_PartyTown_Environment.php new file mode 100644 index 000000000..97cd0a8f0 --- /dev/null +++ b/UserExperience_PartyTown_Environment.php @@ -0,0 +1,182 @@ +get_boolean( 'config.check' ) || $force_all_checks ) { + if ( UserExperience_PartyTown_Extension::is_enabled() ) { + try { + $this->rules_add( $config, $exs ); + } catch ( Util_WpFile_FilesystemOperationException $ex ) { + $exs->push( $ex ); + } + } else { + $this->rules_remove( $exs ); + } + } + + if ( count( $exs->exceptions() ) > 0 ) { + throw $exs; + } + } + + /** + * Fixes environment once event occurs. + * + * @since X.X.X + * + * @param Config $config Config object. + * @param mixed $event Event. + * @param Config $old_config Old config object. + */ + public function fix_on_event( $config, $event, $old_config = null ) { + } + + /** + * Fixes environment after plugin deactivation + * + * @since X.X.X + * + * @throws Util_Environment_Exceptions Exceptions. + */ + public function fix_after_deactivation() { + $exs = new Util_Environment_Exceptions(); + + $this->rules_remove( $exs ); + + if ( count( $exs->exceptions() ) > 0 ) { + throw $exs; + } + } + + /** + * Returns required rules for module. + * + * @since X.X.X + * + * @param Config $config Configuration object. + * @return array + */ + public function get_required_rules( $config ) { + return array( + array( + 'filename' => Util_Rule::get_browsercache_rules_cache_path(), + 'content' => $this->rules_generate(), + ), + ); + } + + /** + * Write rewrite rules. + * + * @since X.X.X + * + * @param Config $config Configuration. + * @param Util_Environment_Exceptions $exs Exceptions. + * + * @throws Util_WpFile_FilesystemOperationException S/FTP form if it can't get the required filesystem credentials. + */ + private function rules_add( $config, $exs ) { + Util_Rule::add_rules( + $exs, + Util_Rule::get_browsercache_rules_cache_path(), + $this->rules_generate(), + W3TC_MARKER_BEGIN_PARTYTOWN, + W3TC_MARKER_END_PARTYTOWN, + array( + W3TC_MARKER_BEGIN_BROWSERCACHE_CACHE => 0, + W3TC_MARKER_BEGIN_WORDPRESS => 0, + ) + ); + } + + /** + * Generate rewrite rules. + * + * @since X.X.X + * + * @see Dispatcher::nginx_rules_for_browsercache_section() + * + * @return string + */ + private function rules_generate() { + $config = Dispatcher::config(); + + switch ( true ) { + case Util_Environment::is_apache(): + case Util_Environment::is_litespeed(): + return ' +# BEGIN W3TC PartyTown +# Serve all Partytown JavaScript files with the correct MIME type + + ForceType application/javascript + + +' . ( $config->get_boolean( array( 'user-experience-partytown', 'atomics' ) ) ? ' +Header set Cross-Origin-Opener-Policy "same-origin" +Header set Cross-Origin-Embedder-Policy "require-corp" +' : '' ) . ' +# END W3TC PartyTown +'; + + case Util_Environment::is_nginx(): + return ' +# BEGIN W3TC PartyTown +# Serve the actual PartyTown JavaScript files with the correct MIME type +location ~ ^' . preg_quote( plugin_dir_path( __FILE__ ) . '/lib/PartyTown/lib/' ) . 'partytown-.*\.js$ { + default_type application/javascript; +} + +' . ( $config->get_boolean( array( 'user-experience-partytown', 'atomics' ) ) ? ' +add_header Cross-Origin-Opener-Policy same-origin; +add_header Cross-Origin-Embedder-Policy require-corp; +' : '' ) . ' +# END W3TC PartyTown +'; + + default: + return ''; + } + } + + /** + * Removes cache directives + * + * @since X.X.X + * + * @param Util_Environment_Exceptions $exs Exceptions. + * + * @throws Util_WpFile_FilesystemOperationException S/FTP form if it can't get the required filesystem credentials. + */ + private function rules_remove( $exs ) { + Util_Rule::remove_rules( + $exs, + Util_Rule::get_browsercache_rules_cache_path(), + W3TC_MARKER_BEGIN_PARTYTOWN, + W3TC_MARKER_END_PARTYTOWN + ); + } +} diff --git a/UserExperience_PartyTown_Extension.php b/UserExperience_PartyTown_Extension.php new file mode 100644 index 000000000..2eed7d030 --- /dev/null +++ b/UserExperience_PartyTown_Extension.php @@ -0,0 +1,284 @@ +config = Dispatcher::config(); + } + + /** + * Runs User Experience PartyTown feature. + * + * @since X.X.X + * + * @return void + */ + public function run() { + add_action( 'w3tc_userexperience_page', array( $this, 'w3tc_userexperience_page' ), 14 ); + + /** + * This filter is documented in Generic_AdminActions_Default.php under the read_request method. + */ + add_filter( 'w3tc_config_key_descriptor', array( $this, 'w3tc_config_key_descriptor' ), 10, 2 ); + + if ( ! Util_Environment::is_w3tc_pro( $this->config ) ) { + $this->config->set_extension_active_frontend( 'user-experience-partytown', false ); + return; + } + + Util_Bus::add_ob_callback( 'partytown', array( $this, 'ob_callback' ) ); + + add_filter( 'w3tc_save_options', array( $this, 'w3tc_save_options' ), 11, 2 ); + + add_action( 'wp_enqueue_scripts', array( $this, 'w3tc_enqueue_partytown' ) ); + } + + /** + * Enqueue main PartyTown script. + * + * @since X.X.X + * + * @return void + */ + public function w3tc_enqueue_partytown() { + $script_path = plugins_url( 'lib/PartyTown/lib/partytown.js', __FILE__ ); + $party_path = wp_make_link_relative( plugins_url( 'lib/PartyTown/lib/', __FILE__ ) ); + $debug = $this->config->get_boolean( array( 'user-experience-partytown', 'debug' ) ) ? 'true' : 'false'; + $timeout = $this->config->get_integer( array( 'user-experience-partytown', 'timeout' ) ) ?? 5000; + $worker_concurrency = $this->config->get_integer( array( 'user-experience-partytown', 'workers' ) ) ?? 5; + + wp_register_script( 'partytown', $script_path, array(), W3TC_VERSION, true ); + + // Preload Partytown if enabled. + if ( $this->config->get_boolean( array( 'user-experience-partytown', 'preload' ) ) ) { + wp_script_add_data( 'partytown', 'preload', 'true' ); + } + + // Prepare configuration for Partytown. + $inline_script = " + window.partytown = { + lib: '{$party_path}', + debug: {$debug}, + timeout: {$timeout}, + workerConcurrency: {$worker_concurrency}, + }; + "; + + // Add inline script to configure Partytown. + wp_add_inline_script( 'partytown', $inline_script, 'before' ); + + wp_enqueue_script( 'partytown' ); + } + + /** + * Processes the page content buffer to modify target CSS/JS. + * + * @since X.X.X + * + * @param string $buffer page content buffer. + * + * @return string + */ + public function ob_callback( $buffer ) { + if ( '' === $buffer || ! \W3TC\Util_Content::is_html_xml( $buffer ) ) { + return $buffer; + } + + $can_process = array( + 'enabled' => true, + 'buffer' => $buffer, + 'reason' => null, + ); + + $can_process = $this->can_process( $can_process ); + $can_process = apply_filters( 'w3tc_partytown_can_process', $can_process ); + + // set reject reason in comment. + if ( $can_process['enabled'] ) { + $reject_reason = ''; + } else { + $reject_reason = empty( $can_process['reason'] ) ? ' (not specified)' : ' (' . $can_process['reason'] . ')'; + } + + $buffer = str_replace( + '{w3tc_partytown_reject_reason}', + $reject_reason, + $buffer + ); + + // processing. + if ( ! $can_process['enabled'] ) { + return $buffer; + } + + $this->mutator = new UserExperience_PartyTown_Mutator( $this->config ); + + $buffer = $this->mutator->run( $buffer ); + + return $buffer; + } + + /** + * Checks if the request can be processed for PartyTown. + * + * @since X.X.X + * + * @param boolean $can_process flag representing if PartyTown can be executed. + * + * @return boolean + */ + private function can_process( $can_process ) { + if ( defined( 'WP_ADMIN' ) ) { + $can_process['enabled'] = false; + $can_process['reason'] = 'WP_ADMIN'; + + return $can_process; + } + + if ( defined( 'SHORTINIT' ) && SHORTINIT ) { + $can_process['enabled'] = false; + $can_process['reason'] = 'SHORTINIT'; + + return $can_process; + } + + if ( function_exists( 'is_feed' ) && is_feed() ) { + $can_process['enabled'] = false; + $can_process['reason'] = 'feed'; + + return $can_process; + } + + return $can_process; + } + + /** + * Adds PartyTown message to W3TC footer comment. + * + * @since X.X.X + * + * @param array $strings array of W3TC footer comments. + * + * @return array + */ + public function w3tc_footer_comment( $strings ) { + $strings[] = __( 'PartyTown', 'w3-total-cache' ) . '{w3tc_partytown_reject_reason}'; + return $strings; + } + + /** + * Renders the user experience PartyTown settings page. + * + * @since X.X.X + * + * @return void + */ + public function w3tc_userexperience_page() { + include __DIR__ . '/UserExperience_PartyTown_Page_View.php'; + } + + /** + * Specify config key typing for fields that need it. + * + * @since X.X.X + * + * @param mixed $descriptor Descriptor. + * @param mixed $key Compound key array. + * + * @return array + */ + public function w3tc_config_key_descriptor( $descriptor, $key ) { + if ( is_array( $key ) && 'user-experience-partytown.includes' === implode( '.', $key ) ) { + $descriptor = array( 'type' => 'array' ); + } + + return $descriptor; + } + + /** + * Performs actions on save. + * + * @since X.X.X + * + * @param array $data Array of save data. + * @param array $page String page value. + * + * @return array + */ + public function w3tc_save_options( $data, $page ) { + if ( 'w3tc_userexperience' === $page ) { + $new_config =& $data['new_config']; + $old_config =& $data['old_config']; + + $old_partytown_includes = $old_config->get_array( array( 'user-experience-partytown', 'includes' ) ); + $new_partytown_includes = $new_config->get_array( array( 'user-experience-partytown', 'includes' ) ); + + if ( $new_partytown_includes !== $old_partytown_includes ) { + $minify_enabled = $new_config->get_boolean( 'minify.enabled' ); + $pgcache_enabled = $new_config->get_boolean( 'pgcache.enabled' ); + if ( $minify_enabled || $pgcache_enabled ) { + $state = Dispatcher::config_state(); + if ( $minify_enabled ) { + $state->set( 'minify.show_note.need_flush', true ); + } + if ( $pgcache_enabled ) { + $state->set( 'common.show_note.flush_posts_needed', true ); + } + $state->save(); + } + } + } + + return $data; + } + + /** + * Gets the enabled status of the extension. + * + * @since 2.5.1 + * + * @return bool + */ + public static function is_enabled() { + $config = Dispatcher::config(); + $extensions_active = $config->get_array( 'extensions.active' ); + return Util_Environment::is_w3tc_pro( $config ) && array_key_exists( 'user-experience-partytown', $extensions_active ); + } +} + +$o = new UserExperience_PartyTown_Extension(); +$o->run(); diff --git a/UserExperience_PartyTown_Mutator.php b/UserExperience_PartyTown_Mutator.php new file mode 100644 index 000000000..cad885b82 --- /dev/null +++ b/UserExperience_PartyTown_Mutator.php @@ -0,0 +1,137 @@ +config = $config; + } + + /** + * Runs User Experience PartyTown Mutator. + * + * @since X.X.X + * + * @param string $buffer Buffer string containing browser output. + * + * @return string + */ + public function run( $buffer ) { + $r = apply_filters( + 'w3tc_partytown_mutator_before', + array( + 'buffer' => $buffer, + ) + ); + + $this->buffer = $r['buffer']; + + // Sets includes whose matches will be stripped site-wide. + $this->includes = $this->config->get_array( + array( + 'user-experience-partytown', + 'includes', + ) + ); + + $this->buffer = preg_replace_callback( + '~(]*>.*?)~is', + array( $this, 'modify_content' ), + $this->buffer + ); + + return $this->buffer; + } + + /** + * Modifies matched link/script tag from HTML content. + * + * @since X.X.X + * + * @param array $matches array of matched CSS/JS entries. + * + * @return string + */ + public function modify_content( $matches ) { + $content = $matches[0]; + + // Early return if not the main query or content not a match. + if ( ! $this->is_content_included( $content ) ) { + return $content; + } + + // Check if it's a script tag and type="text/partytown" is not already present. + if ( strpos( $content, ')/', ' + * + * ``` + * + * The `nonce` property should be generated by the server, and it should be unique + * for each request. You can leave a placeholder, as shown in the above example, + * to facilitate replacement through a regular expression on the server side. + * For instance, you can use the following code: + * + * ```js + * html.replace(/THIS_SHOULD_BE_REPLACED/g, nonce); + * ``` + */ + nonce?: string; +} + +/** + * A forward property to patch on `window`. The forward config property is an string, + * representing the call to forward, such as `dataLayer.push` or `fbq`. + * + * https://partytown.builder.io/forwarding-events + * + * @public + */ +export declare type PartytownForwardProperty = string | PartytownForwardPropertyWithSettings; + +/** + * @public + */ +export declare type PartytownForwardPropertySettings = { + preserveBehavior?: boolean; +}; + +/** + * @public + */ +export declare type PartytownForwardPropertyWithSettings = [string, PartytownForwardPropertySettings?]; + +/** + * Function that returns the Partytown snippet as a string, which can be + * used as the innerHTML of the inlined Partytown script in the head. + * + * @public + */ +export declare const partytownSnippet: (config?: PartytownConfig) => string; + +/** + * @public + */ +export declare type ResolveUrlType = 'fetch' | 'xhr' | 'script' | 'iframe' | 'image'; + +/** + * The `type` attribute for Partytown scripts, which does two things: + * + * 1. Prevents the ` + * + * ``` + * + * The `nonce` property should be generated by the server, and it should be unique + * for each request. You can leave a placeholder, as shown in the above example, + * to facilitate replacement through a regular expression on the server side. + * For instance, you can use the following code: + * + * ```js + * html.replace(/THIS_SHOULD_BE_REPLACED/g, nonce); + * ``` + */ + nonce?: string; +} + +/** + * A forward property to patch on `window`. The forward config property is an string, + * representing the call to forward, such as `dataLayer.push` or `fbq`. + * + * https://partytown.builder.io/forwarding-events + * + * @public + */ +declare type PartytownForwardProperty = string | PartytownForwardPropertyWithSettings; + +/** + * @public + */ +declare type PartytownForwardPropertySettings = { + preserveBehavior?: boolean; +}; + +/** + * @public + */ +declare type PartytownForwardPropertyWithSettings = [string, PartytownForwardPropertySettings?]; + +/** + * Props for ``, which extends the Partytown Config. + * + * https://github.com/BuilderIO/partytown#config + * + * @public + */ +export declare interface PartytownProps extends PartytownConfig { +} + +/** + * @public + */ +declare type ResolveUrlType = 'fetch' | 'xhr' | 'script' | 'iframe' | 'image'; + +/** + * @public + */ +declare type SendBeaconParameters = Pick; + +/** + * @public + */ +declare type SetHook = (opts: SetHookOptions) => any; + +/** + * @public + */ +declare interface SetHookOptions extends HookOptions { + value: any; + prevent: Symbol; +} + +declare type WinId = string; + +declare const WinIdKey: unique symbol; + +declare interface WorkerInstance { + [WinIdKey]: WinId; + [InstanceIdKey]: InstanceId; + [ApplyPathKey]: string[]; + [InstanceDataKey]: string | undefined; + [NamespaceKey]: string | undefined; + [InstanceStateKey]: { + [key: string]: any; + }; +} + +export { } diff --git a/lib/PartyTown/react/index.mjs b/lib/PartyTown/react/index.mjs new file mode 100644 index 000000000..0ce18cf96 --- /dev/null +++ b/lib/PartyTown/react/index.mjs @@ -0,0 +1,42 @@ +import React from 'react'; +import { partytownSnippet } from '../integration/index.mjs'; + +/** + * The React `` component should be placed within the `` + * of the document. This component should work for SSR/SSG only HTML + * (static HTML without javascript), clientside javascript only + * (entire React app is build with clientside javascript), + * and both SSR/SSG HTML that's then hydrated by the client. + * + * @public + */ +const Partytown = ({ nonce, ...props } = {}) => { + // purposely not using useState() or useEffect() so this component + // can also work as a React Server Component + // this check is only be done on the client, and skipped over on the server + if (typeof document !== 'undefined' && !document._partytown) { + if (!document.querySelector('script[data-partytown]')) { + // the append script to document code should only run on the client + // and only if the SSR'd script doesn't already exist within the document. + // If the SSR'd script isn't found in the document, then this + // must be a clientside only render. Append the partytown script + // to the . + const scriptElm = document.createElement('script'); + scriptElm.dataset.partytown = ''; + scriptElm.innerHTML = partytownSnippet(props); + scriptElm.nonce = nonce; + document.head.appendChild(scriptElm); + } + // should only append this script once per document, and is not dynamic + document._partytown = true; + } + // `dangerouslySetInnerHTML` only works for scripts rendered as HTML from SSR. + // The added code will set the [type="data-pt-script"] attribute to the SSR rendered + //